From 801428bc22db8b14ec92460a633f849428d62752 Mon Sep 17 00:00:00 2001 From: TheOneironaut Date: Tue, 23 Jun 2026 23:58:17 +0300 Subject: [PATCH 01/35] Add BiDi support to chat text --- src/client/src/components/CodeViewer.ts | 14 ++++++++++++++ src/client/src/components/FormattedText.ts | 2 +- src/client/src/components/PromptEditor.ts | 2 ++ src/client/src/components/SessionList.ts | 5 +++-- src/client/src/components/shared.ts | 15 ++++++++------- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/client/src/components/CodeViewer.ts b/src/client/src/components/CodeViewer.ts index 7523f15..91b132a 100644 --- a/src/client/src/components/CodeViewer.ts +++ b/src/client/src/components/CodeViewer.ts @@ -55,6 +55,7 @@ export class CodeViewer extends LitElement { EditorView.editable.of(false), EditorView.lineWrapping, viewerTheme, + ...bidiTextExtensions(this.language), ...languageExtensions(this.language), ], }), @@ -97,6 +98,19 @@ const viewerTheme = EditorView.theme({ }, }); +const bidiTextTheme = EditorView.theme({ + ".cm-content": { + textAlign: "start", + }, + ".cm-line": { + unicodeBidi: "plaintext", + }, +}); + +function bidiTextExtensions(language: string | undefined): Extension[] { + return language === "markdown" ? [EditorView.contentAttributes.of({ dir: "auto" }), bidiTextTheme] : []; +} + function languageExtensions(language: string | undefined): Extension[] { if (language === undefined) return []; switch (language) { diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts index 259204b..dccf74e 100644 --- a/src/client/src/components/FormattedText.ts +++ b/src/client/src/components/FormattedText.ts @@ -9,7 +9,7 @@ export class FormattedText extends LitElement { @property() text = ""; override render() { - return html`
${unsafeHTML(toSafeMarkdownHtml(this.text))}
`; + return html`
${unsafeHTML(toSafeMarkdownHtml(this.text))}
`; } override updated(): void { diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 449c045..ee88588 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -433,6 +433,7 @@ const proseInputAssistanceAttributes: Record = { autocorrect: "on", autocapitalize: "sentences", writingsuggestions: "true", + dir: "auto", }; const codeLikeInputAssistanceAttributes: Record = { @@ -440,6 +441,7 @@ const codeLikeInputAssistanceAttributes: Record = { autocorrect: "off", autocapitalize: "off", writingsuggestions: "false", + dir: "auto", }; function inputAssistanceContentAttributes(draftBeforeCursor: string): Record { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index e2c156d..fea428c 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -134,7 +134,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection const selectedTitle = this.selected?.path ?? selectedSummary; return html`

- + ${this.renderCurrentSelectionButton(currentSessions)} ${sessionCount} @@ -213,7 +213,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection >
${showsCheckbox ? html` { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null} - ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages + ${row.depth > 0 ? html`` : null}${sessionLabel(session)}${row.depth > 2 ? html` depth ${row.depth}` : null}${row.hasMissingParent ? html` parent unavailable` : null}${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages ${this.renderActivity(session)}
@@ -375,6 +375,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection .bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; } .bulk-row button { padding: 5px 7px; font-size: 12px; } .bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); } + .action-name, .section-selected { text-align: start; unicode-bidi: plaintext; } .bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); } .bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); } button.danger, .action-menu-panel button.danger { color: var(--pi-danger); } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 91165e5..f7f24dd 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -345,6 +345,7 @@ export const chatStyles = css` .msg-meta:focus::before, .msg-meta.expanded::before { content: ""; } } formatted-text.part { display: block; } + formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; } .part { max-width: 100%; min-width: 0; box-sizing: border-box; overflow: visible; } .part + .part { margin-top: 10px; } .tool-line { color: var(--pi-warning); } @@ -355,22 +356,22 @@ export const chatStyles = css` .skill-invocation > summary, .skill-read > strong { color: var(--pi-purple); } .skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: var(--pi-muted); } summary { cursor: pointer; color: var(--pi-muted); } - pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } - .shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; } + pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; direction: ltr; text-align: left; unicode-bidi: isolate; } + .shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; direction: ltr; text-align: left; unicode-bidi: isolate; } @keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } } `; export const formattedTextStyles = css` :host { display: block; } - .formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; } + .formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; text-align: start; unicode-bidi: plaintext; } p, ul, ol, pre, blockquote, table, .code-block-wrapper { margin: 0 0 10px; } :is(p, ul, ol, pre, blockquote, table, .code-block-wrapper):last-child { margin-bottom: 0; } ul, ol { padding-left: 22px; } li + li { margin-top: 3px; } - code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; } .code-block-wrapper { position: relative; } .code-block-wrapper pre { margin: 0; padding-right: 40px; } - pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; } + pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; direction: ltr; text-align: left; unicode-bidi: isolate; } pre code { border: 0; padding: 0; background: transparent; } .code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } .code-copy-button:hover, .code-copy-button:focus { color: var(--pi-text); border-color: var(--pi-accent); } @@ -466,8 +467,8 @@ export const promptEditorStyles = css` textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid var(--pi-border); background: var(--pi-bg); color: var(--pi-text); font: 16px/1.4 system-ui, sans-serif; } textarea { overflow-y: auto; padding: 8px; } .markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: system-ui, sans-serif; line-height: 1.4; } - .markdown-editor .cm-content { min-height: 38px; padding: 8px 44px 8px 8px; caret-color: var(--pi-text); } - .markdown-editor .cm-line { padding: 0; } + .markdown-editor .cm-content { min-height: 38px; padding: 8px 44px 8px 8px; caret-color: var(--pi-text); text-align: start; unicode-bidi: plaintext; } + .markdown-editor .cm-line { padding: 0; unicode-bidi: plaintext; } .markdown-editor .cm-placeholder { color: var(--pi-dim); } .markdown-editor .cm-focused { outline: none; } .shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); } From 32ea809adcae7d0225ccdbecb8eb05abcd285174 Mon Sep 17 00:00:00 2001 From: Andrey Romantsev Date: Thu, 25 Jun 2026 18:11:41 +0200 Subject: [PATCH 02/35] fix: keep Enter as newline in mobile chat composer --- .changeset/mobile-enter-newline.md | 5 +++++ src/client/src/components/PromptEditor.ts | 9 +++++++-- src/client/src/promptEnterBehavior.test.ts | 17 +++++++++++++++++ src/client/src/promptEnterBehavior.ts | 11 +++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 .changeset/mobile-enter-newline.md create mode 100644 src/client/src/promptEnterBehavior.test.ts create mode 100644 src/client/src/promptEnterBehavior.ts diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md new file mode 100644 index 0000000..fd2296b --- /dev/null +++ b/.changeset/mobile-enter-newline.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep Enter/Return in the mobile chat composer for new lines, and send messages there only from the send button. diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index a5dc404..944c3a9 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,6 +13,7 @@ import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; +import { createMobilePromptEnterMedia, shouldSendPromptOnEnter } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -59,6 +60,7 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); + private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -238,7 +240,7 @@ export class PromptEditor extends LitElement { { key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "Escape", run: () => this.closeCompletions() }, - { key: "Enter", run: () => this.handleEditorEnter() }, + { key: "Enter", run: (view) => this.handleEditorEnter(view) }, { key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, @@ -335,12 +337,15 @@ export class PromptEditor extends LitElement { return true; } - private handleEditorEnter(): boolean { + private handleEditorEnter(view: EditorView): boolean { if (this.completions.length) { const completion = this.completions[this.selectedIndex]; if (completion !== undefined) this.pick(completion); return true; } + if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia)) { + return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); + } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); return true; } diff --git a/src/client/src/promptEnterBehavior.test.ts b/src/client/src/promptEnterBehavior.test.ts new file mode 100644 index 0000000..bb5eabe --- /dev/null +++ b/src/client/src/promptEnterBehavior.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { MOBILE_PROMPT_ENTER_MEDIA_QUERY, shouldSendPromptOnEnter, type PromptEnterMedia } from "./promptEnterBehavior"; + +describe("promptEnterBehavior", () => { + it("uses the expected mobile media query", () => { + expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)"); + }); + + it("sends on Enter outside the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia)).toBe(true); + expect(shouldSendPromptOnEnter(undefined)).toBe(true); + }); + + it("keeps Enter as a newline in the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia)).toBe(false); + }); +}); diff --git a/src/client/src/promptEnterBehavior.ts b/src/client/src/promptEnterBehavior.ts new file mode 100644 index 0000000..faef8ac --- /dev/null +++ b/src/client/src/promptEnterBehavior.ts @@ -0,0 +1,11 @@ +export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)"; + +export type PromptEnterMedia = Pick; + +export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined { + return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined; +} + +export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia()): boolean { + return media?.matches !== true; +} From 7e812aa7f54c45f71f983fbb2a375b15e86b8e93 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 21:32:07 +0200 Subject: [PATCH 03/35] feat: support general chat file attachments --- .changeset/chat-file-uploads.md | 5 + src/client/src/components/PromptEditor.ts | 91 ++++++++++++------- src/client/src/components/shared.ts | 3 + .../src/promptAttachmentCapture.test.ts | 66 ++++++++++---- src/client/src/promptAttachmentCapture.ts | 73 +++++++++++---- src/server/sessions/attachmentService.test.ts | 75 ++++++++++++++- src/server/sessions/attachmentService.ts | 84 ++++++++++++++--- src/server/sessions/piSessionService.ts | 2 +- src/shared/apiTypes.ts | 24 +++-- src/shared/promptAttachments.test.ts | 25 +++++ src/shared/promptAttachments.ts | 45 +++++++-- 11 files changed, 397 insertions(+), 96 deletions(-) create mode 100644 .changeset/chat-file-uploads.md diff --git a/.changeset/chat-file-uploads.md b/.changeset/chat-file-uploads.md new file mode 100644 index 0000000..d65244b --- /dev/null +++ b/.changeset/chat-file-uploads.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index a5dc404..6367c42 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -7,7 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; -import { captureImageAttachments } from "../promptAttachmentCapture"; +import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; import { inputModeForDraft } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; @@ -18,14 +18,7 @@ import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, ren import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; import "./AutocompleteMenu"; -interface PendingAttachment { - id: string; - name: string; - mimeType: string; - /** Base64 payload without the data: URL prefix. */ - data: string; - size: number; -} +type PendingAttachment = CapturedAttachment & { id: string }; @customElement("prompt-editor") export class PromptEditor extends LitElement { @@ -96,8 +89,8 @@ export class PromptEditor extends LitElement {
{ void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
- { void this.handleFileInput(event); }} /> - + { void this.handleFileInput(event); }} /> + ${shellMode ? html`
Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} ${this.isCompacting && !shellMode ? html`
Compacting history · message will be queued
` : null} ${this.renderAttachments()} @@ -137,18 +130,20 @@ export class PromptEditor extends LitElement { private renderAttachments() { if (this.attachments.length === 0 && this.attachmentError === undefined) return null; + const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments); + const delivery = this.effectiveAttachmentDelivery(); return html`
${this.attachments.map((attachment) => html` -
- ${attachment.name} +
+ ${this.renderAttachmentPreview(attachment)}
`)} ${this.attachments.length > 0 ? html` - @@ -158,9 +153,24 @@ export class PromptEditor extends LitElement { `; } + private renderAttachmentPreview(attachment: PendingAttachment) { + if (isInlinePromptAttachment(attachment)) { + return html`${attachment.name}`; + } + return html` + + ${attachment.name} + `; + } + private changeDelivery(event: Event) { if (!(event.target instanceof HTMLSelectElement)) return; - this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline"; + const requested = event.target.value === "folder" ? "folder" : "inline"; + if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) { + event.target.value = "folder"; + return; + } + this.attachmentDelivery = requested; saveAttachmentDelivery(this.attachmentDelivery); } @@ -169,7 +179,7 @@ export class PromptEditor extends LitElement { } private async handlePaste(event: ClipboardEvent) { - const files = imageFilesFromDataTransfer(event.clipboardData); + const files = filesFromDataTransfer(event.clipboardData); if (files.length === 0) return; event.preventDefault(); await this.addAttachmentFiles(files); @@ -177,13 +187,11 @@ export class PromptEditor extends LitElement { private handleDragOver(event: DragEvent) { if (event.dataTransfer === null) return; - if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) { - event.preventDefault(); - } + if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault(); } private async handleDrop(event: DragEvent) { - const files = imageFilesFromDataTransfer(event.dataTransfer); + const files = filesFromDataTransfer(event.dataTransfer); if (files.length === 0) return; event.preventDefault(); await this.addAttachmentFiles(files); @@ -198,7 +206,7 @@ export class PromptEditor extends LitElement { private async addAttachmentFiles(files: File[]) { this.attachmentError = undefined; - const { attachments, error } = await captureImageAttachments(files, readFileAsBase64); + const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64); if (attachments.length > 0) { this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))]; } @@ -206,12 +214,11 @@ export class PromptEditor extends LitElement { } private currentAttachments(): PromptAttachment[] { - return this.attachments.map((attachment) => ({ - kind: "image", - mimeType: attachment.mimeType, - data: attachment.data, - name: attachment.name, - })); + return this.attachments.map((attachment) => pendingToPromptAttachment(attachment)); + } + + private effectiveAttachmentDelivery(): PromptAttachmentDelivery { + return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments); } private createEditor() { @@ -380,7 +387,7 @@ export class PromptEditor extends LitElement { if (text === "" && pending.length === 0) return; const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined; const attachments = pending.length > 0 ? this.currentAttachments() : undefined; - const delivery = this.attachmentDelivery; + const delivery = this.effectiveAttachmentDelivery(); this.resetComposer(); // Sending is owned by the controller (it drives the chat activity dock and, // for folder mode, orchestrates the upload + reference rewrite), so this is @@ -414,9 +421,29 @@ function emptyFileSuggestions(): FileSuggestion[] { return []; } -function imageFilesFromDataTransfer(data: DataTransfer | null): File[] { +function filesFromDataTransfer(data: DataTransfer | null): File[] { if (data === null) return []; - return Array.from(data.files).filter((file) => file.type.startsWith("image/")); + return Array.from(data.files); +} + +function dataTransferHasFiles(data: DataTransfer): boolean { + const items = Array.from(data.items); + if (items.length > 0) return items.some((item) => item.kind === "file"); + return Array.from(data.types).includes("Files"); +} + +function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment { + if (attachment.kind === "image") { + return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name }; + } + return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name }; +} + +function fileExtensionLabel(name: string): string { + const trimmed = name.trim(); + const dotIndex = trimmed.lastIndexOf("."); + if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase(); + return "FILE"; } function readFileAsBase64(file: File): Promise { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index fde76ff..5518d11 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -475,6 +475,9 @@ export const promptEditorStyles = css` .attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; } .attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); } .attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } + .attachment-chip-file { display: grid; place-items: center; } + .attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; } + .attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; } .attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; } .attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; } .attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; } diff --git a/src/client/src/promptAttachmentCapture.test.ts b/src/client/src/promptAttachmentCapture.test.ts index 163d2ee..61dbd26 100644 --- a/src/client/src/promptAttachmentCapture.test.ts +++ b/src/client/src/promptAttachmentCapture.test.ts @@ -1,51 +1,81 @@ import { describe, expect, it } from "vitest"; -import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; +import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; function file(name: string, type: string, size = 10): CapturableFile { return { name, type, size }; } -describe("captureImageAttachments", () => { - it("reads supported images as base64 attachments", async () => { - const result = await captureImageAttachments( +describe("capturePromptAttachments", () => { + it("reads supported images as native inline image attachments", async () => { + const result = await capturePromptAttachments( [file("shot.png", "image/png"), file("pic.webp", "image/webp")], (f) => Promise.resolve(`data-for-${f.name}`), ); expect(result.error).toBeUndefined(); expect(result.attachments).toEqual([ - { name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 }, - { name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 }, + { kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 }, + { kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 }, ]); }); - it("derives a name from the mime type when the file is unnamed", async () => { - const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x")); - expect(result.attachments[0]?.name).toBe("pasted-image.jpg"); + it("captures generic files with their browser MIME type", async () => { + const result = await capturePromptAttachments( + [file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")], + (f) => Promise.resolve(`data-for-${f.name}`), + ); + + expect(result.error).toBeUndefined(); + expect(result.attachments).toEqual([ + { kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 }, + { kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 }, + ]); }); - it("skips unsupported types and reports a single error while keeping valid ones", async () => { - const result = await captureImageAttachments( - [file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")], + it("uses application/octet-stream when the browser does not provide a MIME type", async () => { + const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x")); + + expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE }); + }); + + it("derives fallback names for unnamed pasted attachments", async () => { + const result = await capturePromptAttachments( + [file("", "image/jpeg"), file("", "application/pdf")], () => Promise.resolve("x"), ); - expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE); - expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]); + expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]); }); it("reports a read failure without dropping other attachments", async () => { - const result = await captureImageAttachments( - [file("bad.png", "image/png"), file("good.png", "image/png")], + const result = await capturePromptAttachments( + [file("bad.png", "image/png"), file("good.txt", "text/plain")], (f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"), ); expect(result.error).toBe(READ_FAILURE_MESSAGE); - expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]); + expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]); }); it("returns no attachments and no error for an empty batch", async () => { - const result = await captureImageAttachments([], () => Promise.resolve("x")); + const result = await capturePromptAttachments([], () => Promise.resolve("x")); expect(result).toEqual({ attachments: [] }); }); }); + +describe("effectivePromptAttachmentDelivery", () => { + it("preserves inline delivery when all pending attachments are supported images", () => { + expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline"); + }); + + it("preserves an explicit folder preference for supported images", () => { + expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder"); + }); + + it("forces folder delivery when any attachment is a generic file", () => { + expect(effectivePromptAttachmentDelivery("inline", [ + { kind: "image", mimeType: "image/png" }, + { kind: "file", mimeType: "application/pdf" }, + ])).toBe("folder"); + }); +}); diff --git a/src/client/src/promptAttachmentCapture.ts b/src/client/src/promptAttachmentCapture.ts index 139beb6..1a23160 100644 --- a/src/client/src/promptAttachmentCapture.ts +++ b/src/client/src/promptAttachmentCapture.ts @@ -1,3 +1,4 @@ +import type { PromptAttachmentDelivery } from "../../shared/apiTypes"; import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments"; /** @@ -11,44 +12,51 @@ export interface CapturableFile { size: number; } -export interface CapturedAttachment { - name: string; - mimeType: string; - /** Base64 payload without the data: URL prefix. */ - data: string; - size: number; -} +export type CapturedAttachment = + | { + kind: "image"; + name: string; + mimeType: string; + /** Base64 payload without the data: URL prefix. */ + data: string; + size: number; + } + | { + kind: "file"; + name: string; + mimeType: string; + /** Base64 payload without the data: URL prefix. */ + data: string; + size: number; + }; export interface CaptureResult { attachments: CapturedAttachment[]; error?: string; } -export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported."; +export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream"; export const READ_FAILURE_MESSAGE = "Failed to read an attachment."; /** - * Validate a batch of files and read the supported images as base64. + * Read a batch of browser files as prompt attachments. * * Pure orchestration: the actual byte reading is injected so the side effect * (FileReader/Blob access) stays at the component boundary and tests can supply - * a fake reader. Unsupported types and read failures are collected into a single - * user-facing error while still returning every attachment that did succeed. + * a fake reader. Supported image MIME types stay marked as native inline images; + * every other file is captured as a generic file attachment that must be saved + * into the workspace before being mentioned in the prompt. */ -export async function captureImageAttachments( +export async function capturePromptAttachments( files: readonly T[], readBase64: (file: T) => Promise, ): Promise { const attachments: CapturedAttachment[] = []; let error: string | undefined; for (const file of files) { - if (!isSupportedImageMimeType(file.type)) { - error = UNSUPPORTED_IMAGE_MESSAGE; - continue; - } try { const data = await readBase64(file); - attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size }); + attachments.push(capturedAttachment(file, data)); } catch { error = READ_FAILURE_MESSAGE; } @@ -56,6 +64,35 @@ export async function captureImageAttachments( return { attachments, ...(error === undefined ? {} : { error }) }; } +export function isInlinePromptAttachment(attachment: Pick): boolean { + return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType); +} + +export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick[]): boolean { + return attachments.every((attachment) => isInlinePromptAttachment(attachment)); +} + +export function effectivePromptAttachmentDelivery( + preferredDelivery: PromptAttachmentDelivery, + attachments: readonly Pick[], +): PromptAttachmentDelivery { + return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder"; +} + +function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment { + if (isSupportedImageMimeType(file.type)) { + return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size }; + } + return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size }; +} + +function fileMimeType(file: CapturableFile): string { + const mimeType = file.type.trim(); + return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType; +} + function attachmentName(file: CapturableFile): string { - return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`; + if (file.name !== "") return file.name; + if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`; + return "pasted-file.bin"; } diff --git a/src/server/sessions/attachmentService.test.ts b/src/server/sessions/attachmentService.test.ts index 1388bd7..089c5ef 100644 --- a/src/server/sessions/attachmentService.test.ts +++ b/src/server/sessions/attachmentService.test.ts @@ -1,17 +1,22 @@ -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js"; let workspace: string; +let externalDirectories: string[] = []; beforeEach(async () => { workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-")); + externalDirectories = []; }); afterEach(async () => { - await rm(workspace, { recursive: true, force: true }); + await Promise.all([ + rm(workspace, { recursive: true, force: true }), + ...externalDirectories.map((directory) => rm(directory, { recursive: true, force: true })), + ]); }); const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); @@ -43,6 +48,59 @@ describe("saveAttachmentsToWorkspace", () => { expect(written.equals(pngBytes)).toBe(true); }); + it("saves generic files with sanitized original filenames", async () => { + const pdfBytes = Buffer.from("PDF bytes"); + const saved = await saveAttachmentsToWorkspace( + workspace, + [ + { kind: "file", mimeType: "application/pdf", data: pdfBytes.toString("base64"), name: "../Quarterly Report (final).pdf" }, + { kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }, + ], + { now: () => new Date("2026-06-13T12:05:01.123Z") }, + ); + + expect(saved[0]?.path.startsWith(`${DEFAULT_ATTACHMENT_FOLDER}/attachment-`)).toBe(true); + expect(saved[0]?.path.endsWith("-1-Quarterly-Report-final.pdf")).toBe(true); + expect(saved[0]).toMatchObject({ mimeType: "application/pdf", size: pdfBytes.byteLength }); + expect(saved[1]?.path.endsWith("-2-empty.txt")).toBe(true); + expect(saved[1]).toMatchObject({ mimeType: "text/plain", size: 0 }); + + expect((await readFile(join(workspace, saved[0]?.path ?? ""))).equals(pdfBytes)).toBe(true); + expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0); + }); + + it("does not overwrite an existing attachment name", async () => { + const fixedNow = () => new Date("2026-06-13T12:05:01.123Z"); + const first = await saveAttachmentsToWorkspace( + workspace, + [{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }], + { now: fixedNow }, + ); + const second = await saveAttachmentsToWorkspace( + workspace, + [{ kind: "file", mimeType: "text/plain", data: "REVG", name: "note.txt" }], + { now: fixedNow }, + ); + + expect(second[0]?.path).not.toBe(first[0]?.path); + expect(second[0]?.path.endsWith("-1-note-2.txt")).toBe(true); + expect((await readFile(join(workspace, first[0]?.path ?? ""))).toString()).toBe("ABC"); + expect((await readFile(join(workspace, second[0]?.path ?? ""))).toString()).toBe("DEF"); + }); + + it("rejects unsafe custom folders", async () => { + await expect(saveAttachmentsToWorkspace( + workspace, + [{ kind: "image", mimeType: "image/png", data: pngBase64 }], + { folder: "/tmp/uploads" }, + )).rejects.toThrow(/Absolute paths/); + await expect(saveAttachmentsToWorkspace( + workspace, + [{ kind: "image", mimeType: "image/png", data: pngBase64 }], + { folder: "../uploads" }, + )).rejects.toThrow(/Path traversal/); + }); + it("honors a custom folder", async () => { const saved = await saveAttachmentsToWorkspace( workspace, @@ -52,6 +110,19 @@ describe("saveAttachmentsToWorkspace", () => { expect(saved[0]?.path.startsWith("uploads/images/")).toBe(true); }); + it("rejects attachment folders that resolve outside the workspace", async () => { + const outside = await mkdtemp(join(tmpdir(), "pi-web-attachments-outside-")); + externalDirectories.push(outside); + await mkdir(join(workspace, ".pi-web")); + await symlink(outside, join(workspace, ".pi-web", "attachments"), "dir"); + + await expect(saveAttachmentsToWorkspace( + workspace, + [{ kind: "file", mimeType: "text/plain", data: "QUJD", name: "note.txt" }], + )).rejects.toThrow(/Path escapes workspace/); + await expect(readdir(outside)).resolves.toEqual([]); + }); + it("returns empty for no attachments", async () => { expect(await saveAttachmentsToWorkspace(workspace, [])).toEqual([]); }); diff --git a/src/server/sessions/attachmentService.ts b/src/server/sessions/attachmentService.ts index bc4ad2b..822e62f 100644 --- a/src/server/sessions/attachmentService.ts +++ b/src/server/sessions/attachmentService.ts @@ -1,10 +1,10 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; import type { ImageContent } from "@earendil-works/pi-ai"; import { formatDimensionNote, resizeImage } from "@earendil-works/pi-coding-agent"; -import type { PromptAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js"; +import type { PromptAttachment, PromptImageAttachment, SavedPromptAttachment } from "../../shared/apiTypes.js"; import { extensionForImageMimeType } from "../../shared/promptAttachments.js"; -import { resolveParentInsideWorkspace } from "../workspaces/pathSafety.js"; +import { ensureInside, isNodeErrorWithCode, resolveParentInsideWorkspace } from "../workspaces/pathSafety.js"; /** * Default workspace-relative folder used when saving pasted/dropped @@ -26,7 +26,7 @@ export interface InlineImage { * (2000x2000, ~4.5MB base64). Images that cannot be resized below the limit * are dropped, matching pi's `[Image omitted]` behaviour. */ -export async function attachmentsToInlineImages(attachments: PromptAttachment[]): Promise { +export async function attachmentsToInlineImages(attachments: PromptImageAttachment[]): Promise { const results: InlineImage[] = []; for (const attachment of attachments) { const bytes = Buffer.from(attachment.data, "base64"); @@ -57,25 +57,83 @@ export async function saveAttachmentsToWorkspace( attachments: PromptAttachment[], options: SaveAttachmentsOptions = {}, ): Promise { - const folder = normalizeFolder(options.folder ?? DEFAULT_ATTACHMENT_FOLDER); + const folder = options.folder ?? DEFAULT_ATTACHMENT_FOLDER; const now = options.now ?? (() => new Date()); - const { target: folderTarget } = await resolveParentInsideWorkspace(cwd, folder); - await mkdir(folderTarget, { recursive: true }); + const { root, target: requestedFolderTarget, relativePath: normalizedFolder } = await resolveParentInsideWorkspace(cwd, folder); + await mkdir(requestedFolderTarget, { recursive: true }); + const folderTarget = await realpath(requestedFolderTarget); + ensureInside(root, folderTarget); const stamp = timestamp(now()); const saved: SavedPromptAttachment[] = []; for (const [index, attachment] of attachments.entries()) { const bytes = Buffer.from(attachment.data, "base64"); - const filename = `attachment-${stamp}-${String(index + 1)}.${extensionForImageMimeType(attachment.mimeType)}`; - const relativePath = `${folder}/${filename}`; - await writeFile(join(folderTarget, filename), bytes); + const filename = await writeUniqueAttachmentFile(folderTarget, attachmentFilename(attachment, stamp, index), bytes); + const relativePath = normalizedFolder === "" ? filename : `${normalizedFolder}/${filename}`; saved.push({ path: relativePath, mimeType: attachment.mimeType, size: bytes.byteLength }); } return saved; } -function normalizeFolder(folder: string): string { - return folder.split(/[\\/]+/).filter((part) => part !== "" && part !== ".").join("/"); +async function writeUniqueAttachmentFile(folderTarget: string, filename: string, bytes: Buffer): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const candidate = attempt === 0 ? filename : addCollisionSuffix(filename, attempt + 1); + try { + await writeFile(join(folderTarget, candidate), bytes, { flag: "wx" }); + return candidate; + } catch (error: unknown) { + if (!isNodeErrorWithCode(error, "EEXIST")) throw error; + } + } + throw new Error("Unable to choose a unique attachment filename"); +} + +function addCollisionSuffix(filename: string, suffix: number): string { + const extension = extname(filename); + const stem = filename.slice(0, filename.length - extension.length); + return `${stem}-${String(suffix)}${extension}`; +} + +function attachmentFilename(attachment: PromptAttachment, stamp: string, index: number): string { + const originalName = sanitizeOriginalFilename(attachment.name) ?? fallbackAttachmentFilename(attachment); + return `attachment-${stamp}-${String(index + 1)}-${originalName}`; +} + +function fallbackAttachmentFilename(attachment: PromptAttachment): string { + if (attachment.kind === "image") return `image.${extensionForImageMimeType(attachment.mimeType)}`; + return "file.bin"; +} + +const MAX_ORIGINAL_FILENAME_LENGTH = 96; + +function sanitizeOriginalFilename(name: string | undefined): string | undefined { + const trimmed = name?.trim(); + if (trimmed === undefined || trimmed === "") return undefined; + const leaf = basename(trimmed.replace(/\\/g, "/")); + const sanitized = stripControlCharacters(leaf) + .normalize("NFKC") + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/-+\./g, ".") + .replace(/^\.+/, "") + .replace(/[.-]+$/, ""); + if (sanitized === "") return undefined; + return truncateFilename(sanitized, MAX_ORIGINAL_FILENAME_LENGTH); +} + +function stripControlCharacters(value: string): string { + return Array.from(value).filter((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f; + }).join(""); +} + +function truncateFilename(filename: string, maxLength: number): string { + if (filename.length <= maxLength) return filename; + const extension = extname(filename); + if (extension.length >= maxLength) return filename.slice(0, maxLength); + const stem = filename.slice(0, filename.length - extension.length); + return `${stem.slice(0, maxLength - extension.length)}${extension}`; } function timestamp(date: Date): string { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 35047b9..0d38540 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -957,7 +957,7 @@ export class PiSessionService { } async saveAttachments(ref: PiSessionLookup, attachments: unknown, folder?: string): Promise { - const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false }); + const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true }); if (parsed.length === 0) return []; await this.assertWritable(ref); const active = await this.getActive(ref); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 6d7847e..637dfd0 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -176,14 +176,13 @@ export interface QueuedSessionMessage { } /** - * A binary attachment carried with a prompt. The wire format mirrors pi's own - * `ImageContent` shape (`{ type: "image", data, mimeType }`) so attachments are - * fully compatible with the underlying pi coding agent. + * A pi-native image attachment carried with a prompt. The wire format mirrors + * pi's own `ImageContent` shape (`{ type: "image", data, mimeType }`) so these + * attachments are compatible with native multimodal delivery after validation. */ -export interface PromptAttachment { - /** Kind of attachment. Only images are supported by pi today. */ +export interface PromptImageAttachment { kind: "image"; - /** IANA mime type (for example "image/png"). */ + /** Supported image MIME type (image/png, image/jpeg, image/gif, or image/webp). */ mimeType: string; /** Base64-encoded binary payload (no data: URL prefix). */ data: string; @@ -191,6 +190,19 @@ export interface PromptAttachment { name?: string; } +/** A general file attachment that must be saved into the workspace before use. */ +export interface PromptFileAttachment { + kind: "file"; + /** Non-empty IANA MIME type (for example "application/pdf"). */ + mimeType: string; + /** Base64-encoded binary payload (no data: URL prefix). Empty for zero-byte files. */ + data: string; + /** Optional original filename, used for previews and folder-mode filenames. */ + name?: string; +} + +export type PromptAttachment = PromptImageAttachment | PromptFileAttachment; + /** * How prompt attachments should be delivered to the session. * - "inline": send the binary to pi as native image content (multimodal input). diff --git a/src/shared/promptAttachments.test.ts b/src/shared/promptAttachments.test.ts index 866383e..28ae518 100644 --- a/src/shared/promptAttachments.test.ts +++ b/src/shared/promptAttachments.test.ts @@ -58,9 +58,34 @@ describe("parsePromptAttachments", () => { it("rejects unsupported kinds and mime types", () => { expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/); + expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/); expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/); }); + it("accepts generic files only when file attachments are allowed", () => { + const result = parsePromptAttachments( + [{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }], + { allowFileAttachments: true }, + ); + expect(result).toEqual([{ kind: "file", mimeType: "application/pdf", data: "QUJD", name: "report.pdf" }]); + }); + + it("accepts zero-byte generic files", () => { + const result = parsePromptAttachments( + [{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }], + { allowFileAttachments: true }, + ); + expect(result).toEqual([{ kind: "file", mimeType: "text/plain", data: "", name: "empty.txt" }]); + }); + + it("rejects generic files with empty mime types", () => { + expect(() => parsePromptAttachments([{ kind: "file", mimeType: "", data: "QUJD" }], { allowFileAttachments: true })).toThrow(/invalid file type/); + }); + + it("keeps image MIME validation when file attachments are allowed", () => { + expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/); + }); + it("rejects invalid base64 data", () => { expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: "not base64!!!" }])).toThrow(/invalid base64/); }); diff --git a/src/shared/promptAttachments.ts b/src/shared/promptAttachments.ts index e1034e1..f5c1089 100644 --- a/src/shared/promptAttachments.ts +++ b/src/shared/promptAttachments.ts @@ -1,4 +1,4 @@ -import type { PromptAttachment } from "./apiTypes.js"; +import type { PromptAttachment, PromptFileAttachment, PromptImageAttachment } from "./apiTypes.js"; /** * Image mime types supported by the pi coding agent. Mirrors @@ -44,13 +44,20 @@ export function base64ByteLength(data: string): number { export interface AttachmentValidationOptions { /** When true, enforce the per-image base64 size cap (inline delivery). */ enforceInlineSizeLimit?: boolean; + /** When true, accept general file attachments for save-to-folder delivery. */ + allowFileAttachments?: boolean; maxAttachments?: number; } +type ImageOnlyAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments?: false | undefined }; +type SaveAttachmentValidationOptions = AttachmentValidationOptions & { allowFileAttachments: true }; + /** * Validate and normalize untrusted prompt attachments. Throws on malformed, * unsupported, or oversized input so routes can return a 400. */ +export function parsePromptAttachments(value: unknown, options?: ImageOnlyAttachmentValidationOptions): PromptImageAttachment[]; +export function parsePromptAttachments(value: unknown, options: SaveAttachmentValidationOptions): PromptAttachment[]; export function parsePromptAttachments(value: unknown, options: AttachmentValidationOptions = {}): PromptAttachment[] { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error("attachments must be an array"); @@ -67,19 +74,45 @@ function parsePromptAttachment(value: unknown, index: number, options: Attachmen if (!isRecord(value)) throw new Error(`attachment ${String(index)} must be an object`); const record = value; const kind = record["kind"]; - if (kind !== "image") throw new Error(`attachment ${String(index)} has unsupported kind`); + if (kind === "image") return parseImageAttachment(record, index, options); + if (kind === "file" && options.allowFileAttachments === true) return parseFileAttachment(record, index); + throw new Error(`attachment ${String(index)} has unsupported kind`); +} + +function parseImageAttachment(record: Record, index: number, options: AttachmentValidationOptions): PromptImageAttachment { const mimeType = record["mimeType"]; if (!isSupportedImageMimeType(mimeType)) throw new Error(`attachment ${String(index)} has unsupported image type`); - const data = record["data"]; - if (typeof data !== "string" || data === "" || !base64Pattern.test(data)) throw new Error(`attachment ${String(index)} has invalid base64 data`); + const data = requireBase64Data(record["data"], index, { allowEmpty: false }); if (options.enforceInlineSizeLimit === true && base64ByteLength(data) > MAX_INLINE_IMAGE_BASE64_BYTES) { throw new Error(`attachment ${String(index)} exceeds the inline image size limit`); } - const name = record["name"]; return { kind: "image", mimeType, data, - ...(typeof name === "string" && name !== "" ? { name } : {}), + ...attachmentName(record), }; } + +function parseFileAttachment(record: Record, index: number): PromptFileAttachment { + const mimeType = record["mimeType"]; + if (typeof mimeType !== "string" || mimeType.trim() === "") throw new Error(`attachment ${String(index)} has invalid file type`); + return { + kind: "file", + mimeType: mimeType.trim(), + data: requireBase64Data(record["data"], index, { allowEmpty: true }), + ...attachmentName(record), + }; +} + +function requireBase64Data(value: unknown, index: number, options: { allowEmpty: boolean }): string { + if (typeof value !== "string" || (!options.allowEmpty && value === "") || !base64Pattern.test(value)) { + throw new Error(`attachment ${String(index)} has invalid base64 data`); + } + return value; +} + +function attachmentName(record: Record): { name?: string } { + const name = record["name"]; + return typeof name === "string" && name !== "" ? { name } : {}; +} From b17faeb236a578f6d5466700b6db83d7dbcd1f96 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 22:48:30 +0200 Subject: [PATCH 04/35] chore: add changeset for BiDi chat text --- .changeset/bidi-chat-text.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bidi-chat-text.md diff --git a/.changeset/bidi-chat-text.md b/.changeset/bidi-chat-text.md new file mode 100644 index 0000000..4b3efb7 --- /dev/null +++ b/.changeset/bidi-chat-text.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Improve chat, prompt, and session text rendering for RTL and mixed-direction content. From fd386b2d6be02a7f8d8b4b5af19847b206e8ca7c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 22:55:09 +0200 Subject: [PATCH 05/35] feat: add prompt enter behavior setting --- .changeset/mobile-enter-newline.md | 2 +- package-lock.json | 2 +- src/client/src/components/PromptEditor.ts | 4 +- .../settings/SettingsShortcutsPanel.ts | 69 +++++++++++++++- src/client/src/promptEnterBehavior.test.ts | 81 +++++++++++++++++-- src/client/src/promptEnterBehavior.ts | 39 ++++++++- 6 files changed, 184 insertions(+), 13 deletions(-) diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md index fd2296b..1151cd2 100644 --- a/.changeset/mobile-enter-newline.md +++ b/.changeset/mobile-enter-newline.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Keep Enter/Return in the mobile chat composer for new lines, and send messages there only from the send button. +Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). diff --git a/package-lock.json b/package-lock.json index de4644b..ea3cc33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1575,7 +1575,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 944c3a9..85d022e 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,7 +13,7 @@ import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; -import { createMobilePromptEnterMedia, shouldSendPromptOnEnter } from "../promptEnterBehavior"; +import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnter } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -343,7 +343,7 @@ export class PromptEditor extends LitElement { if (completion !== undefined) this.pick(completion); return true; } - if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia)) { + if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia, readPromptEnterPreference())) { return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts index 34db3f0..c0847a9 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -3,9 +3,28 @@ import { customElement, property, state } from "lit/decorators.js"; import type { AppAction } from "../../actions"; import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api"; import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts"; +import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior"; const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; +const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: string; description: string }[] = [ + { + value: "auto", + label: "Auto/default", + description: "Desktop-like Enter sends; mobile, coarse pointer, or narrow screens insert a new line.", + }, + { + value: "send", + label: "Enter sends message", + description: "Plain Enter sends the chat message from this browser.", + }, + { + value: "newline", + label: "Enter inserts new line", + description: "Plain Enter adds a line break; use the send button to send.", + }, +]; + @customElement("settings-shortcuts-panel") export class SettingsShortcutsPanel extends LitElement { @property({ attribute: false }) actions: AppAction[] = []; @@ -18,6 +37,7 @@ export class SettingsShortcutsPanel extends LitElement { @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; @state() private drafts: Record = {}; @state() private localError = ""; + @state() private promptEnterPreference: PromptEnterPreference = readPromptEnterPreference(); @state() private recording: RecordingState | undefined; private recordingTimer: number | undefined; private recordingListenerActive = false; @@ -75,6 +95,7 @@ export class SettingsShortcutsPanel extends LitElement {
${this.renderMessages()} + ${this.renderPromptEnterPreferenceCard()} ${this.configResponse === undefined && this.loading ? html`
Loading shortcuts…
` : html`
Config file @@ -100,6 +121,40 @@ export class SettingsShortcutsPanel extends LitElement { return null; } + private renderPromptEnterPreferenceCard(): TemplateResult { + return html` +
+
+ Chat composer +

Enter key behavior

+

Choose what plain Enter does in this browser.

+
+
+ ${PROMPT_ENTER_OPTIONS.map((option) => html` + + `)} +
+
+ `; + } + + private updatePromptEnterPreference(preference: PromptEnterPreference): void { + this.promptEnterPreference = preference; + writePromptEnterPreference(preference); + } + private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult { const shortcuts = this.configResponse?.config.shortcuts; const configured = shortcutPreference(action.id, shortcuts); @@ -291,13 +346,22 @@ export class SettingsShortcutsPanel extends LitElement { button:disabled, input:disabled { opacity: .55; cursor: not-allowed; } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } .secondary { flex: 0 0 auto; } - .message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message, .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .message { margin-bottom: 12px; } .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } .loading-card, .config-path-card { color: var(--pi-muted); } .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } - .config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + .config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + .prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; margin-bottom: 14px; } + .prompt-enter-copy { display: grid; gap: 5px; min-width: 0; } + .prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; } + .prompt-enter-options { display: grid; gap: 7px; } + .prompt-enter-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; color: var(--pi-text); } + .prompt-enter-option input { box-sizing: border-box; width: 14px; min-width: 14px; height: 14px; margin: 3px 0 0; padding: 0; border: 0; background: transparent; accent-color: var(--pi-accent); font-family: inherit; } + .prompt-enter-option input:focus { border-color: transparent; box-shadow: none; outline: 2px solid var(--pi-accent-border); outline-offset: 2px; } + .prompt-enter-option span { display: grid; gap: 2px; } + .prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; } code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } .shortcut-group { margin: 0 0 16px; } .shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } @@ -330,6 +394,7 @@ export class SettingsShortcutsPanel extends LitElement { @media (max-width: 760px) { .section-heading { display: grid; gap: 12px; } .section-heading .secondary { justify-self: start; } + .prompt-enter-card { grid-template-columns: minmax(0, 1fr); } .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } .shortcut-status, .shortcut-actions { justify-content: flex-start; } } diff --git a/src/client/src/promptEnterBehavior.test.ts b/src/client/src/promptEnterBehavior.test.ts index bb5eabe..b3a14a9 100644 --- a/src/client/src/promptEnterBehavior.test.ts +++ b/src/client/src/promptEnterBehavior.test.ts @@ -1,17 +1,86 @@ import { describe, expect, it } from "vitest"; -import { MOBILE_PROMPT_ENTER_MEDIA_QUERY, shouldSendPromptOnEnter, type PromptEnterMedia } from "./promptEnterBehavior"; +import { + MOBILE_PROMPT_ENTER_MEDIA_QUERY, + parsePromptEnterPreference, + PROMPT_ENTER_PREFERENCE_STORAGE_KEY, + readPromptEnterPreference, + shouldSendPromptOnEnter, + writePromptEnterPreference, + type PromptEnterMedia, +} from "./promptEnterBehavior"; describe("promptEnterBehavior", () => { it("uses the expected mobile media query", () => { expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)"); }); - it("sends on Enter outside the mobile environment", () => { - expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia)).toBe(true); - expect(shouldSendPromptOnEnter(undefined)).toBe(true); + it("uses the environment default when the preference is auto", () => { + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "auto")).toBe(true); + expect(shouldSendPromptOnEnter(undefined, "auto")).toBe(true); + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "auto")).toBe(false); }); - it("keeps Enter as a newline in the mobile environment", () => { - expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia)).toBe(false); + it("lets explicit preferences override the environment", () => { + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia, "send")).toBe(true); + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia, "newline")).toBe(false); + expect(shouldSendPromptOnEnter(undefined, "newline")).toBe(false); + }); + + it("parses local storage preference values", () => { + expect(parsePromptEnterPreference("auto")).toBe("auto"); + expect(parsePromptEnterPreference("send")).toBe("send"); + expect(parsePromptEnterPreference("newline")).toBe("newline"); + expect(parsePromptEnterPreference(null)).toBe("auto"); + expect(parsePromptEnterPreference("return")).toBe("auto"); + }); + + it("reads and writes the stored preference", () => { + const storage = new FakeStorage(); + + expect(readPromptEnterPreference(storage)).toBe("auto"); + writePromptEnterPreference("send", storage); + expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("send"); + expect(readPromptEnterPreference(storage)).toBe("send"); + + writePromptEnterPreference("newline", storage); + expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("newline"); + expect(readPromptEnterPreference(storage)).toBe("newline"); + + writePromptEnterPreference("auto", storage); + expect(storage.value(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)).toBe("auto"); + expect(readPromptEnterPreference(storage)).toBe("auto"); + }); + + it("ignores storage failures", () => { + const storage = new ThrowingStorage(); + + expect(readPromptEnterPreference(storage)).toBe("auto"); + expect(() => { writePromptEnterPreference("send", storage); }).not.toThrow(); }); }); + +class FakeStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + value(key: string): string | undefined { + return this.values.get(key); + } +} + +class ThrowingStorage { + getItem(): string | null { + throw new Error("blocked"); + } + + setItem(): void { + throw new Error("blocked"); + } +} diff --git a/src/client/src/promptEnterBehavior.ts b/src/client/src/promptEnterBehavior.ts index faef8ac..b47fcb3 100644 --- a/src/client/src/promptEnterBehavior.ts +++ b/src/client/src/promptEnterBehavior.ts @@ -1,11 +1,48 @@ export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)"; +export const PROMPT_ENTER_PREFERENCE_STORAGE_KEY = "pi-web.promptEnterPreference"; +export type PromptEnterPreference = "auto" | "send" | "newline"; export type PromptEnterMedia = Pick; +export type PromptEnterPreferenceStorage = Pick; export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined { return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined; } -export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia()): boolean { +export function parsePromptEnterPreference(value: string | null): PromptEnterPreference { + if (value === "send" || value === "newline") return value; + return "auto"; +} + +export function readPromptEnterPreference(storage = browserStorage()): PromptEnterPreference { + if (storage === undefined) return "auto"; + try { + return parsePromptEnterPreference(storage.getItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY)); + } catch { + return "auto"; + } +} + +export function writePromptEnterPreference(preference: PromptEnterPreference, storage = browserStorage()): void { + if (storage === undefined) return; + try { + storage.setItem(PROMPT_ENTER_PREFERENCE_STORAGE_KEY, preference); + } catch { + // Ignore localStorage quota/privacy errors; Auto remains the safe fallback. + } +} + +export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia(), preference = readPromptEnterPreference()): boolean { + if (preference === "send") return true; + if (preference === "newline") return false; return media?.matches !== true; } + +function browserStorage(): PromptEnterPreferenceStorage | undefined { + if (typeof window === "undefined") return undefined; + try { + return window.localStorage; + } catch { + return undefined; + } +} From a43c782043ad511e09d4d9d4638e3aac42587d3b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 23:34:41 +0200 Subject: [PATCH 06/35] feat: add configurable prompt Enter behavior --- .changeset/mobile-enter-newline.md | 2 +- src/client/src/components/PromptEditor.ts | 42 ++++++++++++++++--- .../settings/SettingsShortcutsPanel.ts | 8 ++-- src/client/src/promptEnterBehavior.test.ts | 19 +++++++++ src/client/src/promptEnterBehavior.ts | 13 ++++++ 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md index 1151cd2..f3978be 100644 --- a/.changeset/mobile-enter-newline.md +++ b/.changeset/mobile-enter-newline.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). +Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 85d022e..0a0a0f5 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,7 +13,7 @@ import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; -import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnter } from "../promptEnterBehavior"; +import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -61,6 +61,7 @@ export class PromptEditor extends LitElement { private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); + private explicitShiftKeyActive = false; protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -230,6 +231,10 @@ export class PromptEditor extends LitElement { syntaxHighlighting(defaultHighlightStyle, { fallback: true }), EditorView.lineWrapping, EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), + EditorView.domEventHandlers({ + keyup: (event) => this.handleEditorKeyUp(event), + blur: () => this.resetEditorModifierState(), + }), placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), @@ -237,11 +242,10 @@ export class PromptEditor extends LitElement { if (update.docChanged) this.updateDraft(update.state.doc.toString()); }), keymap.of([ + { any: (view, event) => this.handleEditorKeyDown(event, view) }, { key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "Escape", run: () => this.closeCompletions() }, - { key: "Enter", run: (view) => this.handleEditorEnter(view) }, - { key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, { key: "Backspace", run: (view) => deleteMarkupBackward(view) }, @@ -337,13 +341,39 @@ export class PromptEditor extends LitElement { return true; } - private handleEditorEnter(view: EditorView): boolean { - if (this.completions.length) { + private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean { + if (event.key === "Shift") { + this.explicitShiftKeyActive = true; + return false; + } + if (event.key !== "Enter") { + this.explicitShiftKeyActive = false; + return false; + } + if (event.defaultPrevented || event.isComposing || view.composing) return false; + + const shiftKey = shouldUsePromptEnterShiftShortcut(event.shiftKey, this.explicitShiftKeyActive, this.mobilePromptEnterMedia); + this.explicitShiftKeyActive = false; + return this.handleEditorEnter(view, shiftKey); + } + + private handleEditorKeyUp(event: KeyboardEvent): boolean { + if (event.key === "Shift") this.explicitShiftKeyActive = false; + return false; + } + + private resetEditorModifierState(): boolean { + this.explicitShiftKeyActive = false; + return false; + } + + private handleEditorEnter(view: EditorView, shiftKey: boolean): boolean { + if (!shiftKey && this.completions.length) { const completion = this.completions[this.selectedIndex]; if (completion !== undefined) this.pick(completion); return true; } - if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia, readPromptEnterPreference())) { + if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) { return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.ts b/src/client/src/components/settings/SettingsShortcutsPanel.ts index c0847a9..0f9cda7 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.ts @@ -16,12 +16,12 @@ const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: stri { value: "send", label: "Enter sends message", - description: "Plain Enter sends the chat message from this browser.", + description: "Enter sends the chat message; Shift+Enter adds a new line when supported.", }, { value: "newline", label: "Enter inserts new line", - description: "Plain Enter adds a line break; use the send button to send.", + description: "Enter adds a line break; Shift+Enter sends the chat message when supported.", }, ]; @@ -127,9 +127,9 @@ export class SettingsShortcutsPanel extends LitElement {
Chat composer

Enter key behavior

-

Choose what plain Enter does in this browser.

+

Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.

-
+
${PROMPT_ENTER_OPTIONS.map((option) => html`

`; @@ -148,6 +153,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection return html``; } + private renderCleanupButton() { + return html``; + } + private renderArchivedHeading(archivedSessions: SessionInfo[]) { const active = this.selectionScopes.has("archived"); return html` @@ -372,6 +381,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection h2 { min-height: 30px; } h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; } .bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; } + .cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; } .bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; } .bulk-row button { padding: 5px 7px; font-size: 12px; } .bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); } diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 4b8bb77..667b192 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -42,7 +42,9 @@ export class AppNavigationPanel extends LitElement { @property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canReloadSessions = false; + @property({ type: Boolean }) canCleanupSessions = false; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; + @property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions."; @property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleProjects?: () => void; @@ -63,6 +65,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) onDeleteArchivedSessions?: (sessions: SessionInfo[]) => void | Promise; @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onCleanupSessions?: () => void | Promise; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise; @@ -159,7 +162,9 @@ export class AppNavigationPanel extends LitElement { .canStart=${this.canStartSession} .canDeleteArchived=${this.canDeleteArchivedSessions} .canReload=${this.canReloadSessions} + .canCleanup=${this.canCleanupSessions} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} + .cleanupUnavailableMessage=${this.cleanupUnavailableMessage} .collapsible=${this.collapsible} .collapsed=${this.sessionsCollapsed} .onToggleCollapsed=${() => { this.onToggleSessions?.(); }} @@ -175,6 +180,7 @@ export class AppNavigationPanel extends LitElement { .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} + .onCleanup=${() => this.onCleanupSessions?.()} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 5056013..df2e812 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -437,10 +437,13 @@ export const actionPaletteStyles = css` header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; } .options { flex: 1 1 auto; min-height: 0; overflow: auto; } .options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } - .options button.selected, .options button:hover { background: var(--pi-selection-bg); } + .options button.selected, .options button:hover:not(:disabled) { background: var(--pi-selection-bg); } + .options button:disabled { cursor: not-allowed; opacity: .68; } + .options button.disabled.selected { background: color-mix(in srgb, var(--pi-selection-bg) 55%, transparent); } .main { min-width: 0; } strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .disabled-reason { color: var(--pi-warning); } .group { grid-column: 1 / -1; font-size: 12px; } kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } .empty { padding: 24px; color: var(--pi-muted); text-align: center; } diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 2216bad..261c36e 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -553,6 +553,55 @@ describe("SessionController", () => { expect(state.selectedSession?.id).toBe(nextSession.id); }); + it("applies cleanup execution results and refreshes the current workspace sessions", async () => { + const archivedAt = "2026-06-25T12:00:00.000Z"; + const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" }; + const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" }; + const refreshedArchived = { ...oldSession, archived: true, archivedAt }; + const sessionsCalls: { cwd: string; machineId: string }[] = []; + let state: AppState = { + ...initialAppState(), + selectedWorkspace: workspace, + selectedSession: oldSession, + sessions: [oldSession, deletedArchived, nextSession], + sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) }, + sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } }, + }; + const api: typeof defaultApi = { + ...defaultApi, + sessions: (cwd, machineId) => { + sessionsCalls.push({ cwd, machineId: machineId ?? "local" }); + return Promise.resolve([refreshedArchived, nextSession]); + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + new InMemorySessionSelectionMemory(), + { api, socket: new FakeSocket() }, + ); + + await controller.applySessionCleanupResult({ + generatedAt: archivedAt, + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 }, + projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }], + totals: { archiveCount: 1, deleteCount: 1 }, + archivedSessionIds: [oldSession.id], + deletedSessionIds: [deletedArchived.id], + }); + + expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]); + expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]); + expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt }); + expect(state.selectedSession?.id).toBe(nextSession.id); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined(); + expect(state.sessionActivities[oldSession.id]).toBeUndefined(); + }); + it("does not delete archived sessions when the selected machine runtime does not support it", async () => { const archivedSession = { ...oldSession, archived: true, archivedAt: "later" }; const deletedIds: string[] = []; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index e12bead..b3576a3 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1,6 +1,6 @@ -import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus } from "../api"; +import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus } from "../api"; import type { AppState } from "../appState"; -import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; +import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { textMessage } from "../chatMessages"; import { machineSessionKey } from "../machineKeys"; import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage"; @@ -358,6 +358,58 @@ export class SessionController { this.applyBulkSessionError("Delete", results); } + async applySessionCleanupResult(result: SessionCleanupExecuteResponse, machineId = selectedMachineId(this.getState())): Promise { + if (selectedMachineId(this.getState()) !== machineId) return; + const archivedIds = result.archivedSessionIds; + const deletedIds = result.deletedSessionIds; + if (archivedIds.length > 0 || deletedIds.length > 0) { + const state = this.getState(); + const deletedIdSet = new Set(deletedIds); + const affectedIds = [...archivedIds, ...deletedIds]; + const nextSessions = markSessionsArchived(state.sessions, archivedIds, result.generatedAt).filter((session) => !deletedIdSet.has(session.id)); + const selectedAffected = state.selectedSession !== undefined && affectedIds.includes(state.selectedSession.id); + this.setState({ + sessions: nextSessions, + sessionStatuses: omitKeys(state.sessionStatuses, affectedIds), + sessionActivities: omitKeys(state.sessionActivities, affectedIds), + ...(selectedAffected ? { status: undefined, activity: undefined } : {}), + }); + + if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) { + const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0]; + if (next !== undefined) await this.selectSession(next); + else this.deselectSession({ forgetRememberedSelection: true }); + } else { + const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds); + if (selectionChange.type === "select") await this.selectSession(selectionChange.session); + else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true }); + } + } + await this.refreshCurrentWorkspaceSessions(machineId); + } + + async refreshCurrentWorkspaceSessions(machineId = selectedMachineId(this.getState())): Promise { + const workspace = this.getState().selectedWorkspace; + if (workspace === undefined) return; + try { + const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId); + if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id) return; + const selectedSession = this.getState().selectedSession; + this.setState({ sessions }); + if (selectedSession === undefined) return; + const refreshedSelected = sessions.find((session) => session.id === selectedSession.id); + if (refreshedSelected !== undefined) { + if (refreshedSelected !== selectedSession) this.setState({ selectedSession: refreshedSelected }); + return; + } + const next = sessions.find((session) => session.archived !== true) ?? sessions[0]; + if (next !== undefined) await this.selectSession(next); + else this.deselectSession({ forgetRememberedSelection: true }); + } catch (error) { + if (selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id) this.setState({ error: String(error) }); + } + } + async deleteCachedNewSession(session = this.getState().selectedSession) { if (!isCachedNewSessionInfo(session)) return; void this.api.stop(session, selectedMachineId(this.getState())).catch(() => { @@ -727,6 +779,12 @@ function omitKey(record: Record, key: string): Record { return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key)); } +function omitKeys(record: Record, keys: readonly string[]): Record { + if (keys.length === 0) return record; + const removed = new Set(keys); + return Object.fromEntries(Object.entries(record).filter(([id]) => !removed.has(id))); +} + function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] { const seen = new Set(); const unique: SessionInfo[] = []; diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 90f0337..e36edb7 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -1,5 +1,5 @@ import { isSessionActive } from "../../../../shared/activity"; -import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; +import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities"; import type { AppState } from "../../appState"; import { isCachedNewSessionInfo } from "../../cachedNewSessions"; import { selectedMachineId } from "../../controllers/types"; @@ -181,6 +181,7 @@ export function createCoreActions(): PluginAction[] { description: "Re-read the selected session from disk to pick up entries written by another process", group: "Session", enabled: hasReloadableSession, + disabledReason: reloadSessionDisabledReason, run: (context) => context.reloadSession(), }, { @@ -227,7 +228,19 @@ function hasCachedNewSession(context: { state: AppState }): boolean { function hasReloadableSession(context: { state: AppState }): boolean { const session = context.state.selectedSession; if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false; - const runtime = context.state.machineRuntimes[selectedMachineId(context.state)]; - if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false; + if (reloadSessionDisabledReason(context) !== undefined) return false; return !isSessionActive(context.state.status, context.state.activity); } + +function reloadSessionDisabledReason(context: { state: AppState }): string | undefined { + const session = context.state.selectedSession; + if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return undefined; + if (isSessionActive(context.state.status, context.state.activity)) return undefined; + return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions"); +} + +function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined { + const runtime = state.machineRuntimes[selectedMachineId(state)]; + if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined; + return `Update and restart Pi-Web on ${state.selectedMachine?.name ?? "this machine"} to ${action}.`; +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index a5b535f..bc40b38 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -196,7 +196,9 @@ describe("PluginRegistry", () => { expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context); - expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); + const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload"); + expect(noCapabilityReload?.enabled).toBe(false); + expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions."); const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context); expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 68774fc..a2a9654 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -60,6 +60,7 @@ export class PluginRegistry { return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => { const scopedContext = pluginRuntimeContextFor(context, action.pluginId); const enabled = action.enabled?.(scopedContext); + const disabledReason = enabled === false ? action.disabledReason?.(scopedContext) : undefined; const qualified: QualifiedPluginAction = { id: action.id, pluginId: action.pluginId, @@ -72,6 +73,7 @@ export class PluginRegistry { if (action.shortcut !== undefined) qualified.shortcut = action.shortcut; if (action.group !== undefined) qualified.group = action.group; if (enabled !== undefined) qualified.enabled = enabled; + if (disabledReason !== undefined && disabledReason !== "") qualified.disabledReason = disabledReason; return qualified; }); } diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 13915a4..fd5cb8e 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -128,6 +128,8 @@ export interface PluginAction { shortcut?: string; group?: string; enabled?: (context: PluginRuntimeContext) => boolean; + /** Explain why a disabled action is visible but unavailable. */ + disabledReason?: (context: PluginRuntimeContext) => string | undefined; run: (context: PluginRuntimeContext) => void | Promise; } diff --git a/src/client/src/sessionCleanupUi.test.ts b/src/client/src/sessionCleanupUi.test.ts new file mode 100644 index 0000000..038d50e --- /dev/null +++ b/src/client/src/sessionCleanupUi.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { SessionCleanupPreviewResponse } from "./api"; +import { canRunSessionCleanup, confirmSessionCleanup, selectedSessionCleanupProjectCwds, sessionCleanupConfirmationMessage, sessionCleanupPreviewForSelectedProjects, sessionCleanupRequestKey, sessionCleanupUnavailableMessage, validateSessionCleanupDraft, type SessionCleanupDraft } from "./sessionCleanupUi"; + +const draft: SessionCleanupDraft = { + archiveIdleEnabled: true, + archiveIdleDays: "30", + deleteArchivedEnabled: true, + deleteArchivedDays: "90", +}; + +const preview: SessionCleanupPreviewResponse = { + generatedAt: "2026-06-25T12:00:00.000Z", + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 90 }, + projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 1 }], + totals: { archiveCount: 2, deleteCount: 1 }, +}; + +describe("session cleanup UI helpers", () => { + it("builds request thresholds from enabled runtime inputs", () => { + expect(validateSessionCleanupDraft(draft)).toEqual({ + ok: true, + request: { archiveIdleDays: 30, deleteArchivedDays: 90 }, + }); + expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false })).toEqual({ + ok: true, + request: { archiveIdleDays: null, deleteArchivedDays: 90 }, + }); + }); + + it("validates threshold inputs before preview or execution", () => { + expect(validateSessionCleanupDraft({ ...draft, archiveIdleDays: "1.5" })).toEqual({ ok: false, error: "Archive idle sessions after must be a non-negative whole number of days." }); + expect(validateSessionCleanupDraft({ ...draft, deleteArchivedDays: "-1" })).toEqual({ ok: false, error: "Delete archived sessions after must be a non-negative whole number of days." }); + expect(validateSessionCleanupDraft({ ...draft, archiveIdleEnabled: false, deleteArchivedEnabled: false })).toEqual({ ok: false, error: "Enable at least one cleanup action." }); + }); + + it("requires a current preview before cleanup can run", () => { + const validation = validateSessionCleanupDraft(draft); + if (!validation.ok) throw new Error(validation.error); + + expect(canRunSessionCleanup({ canCleanup: true, draft, preview, previewRequest: validation.request })).toBe(true); + expect(canRunSessionCleanup({ canCleanup: true, draft: { ...draft, archiveIdleDays: "31" }, preview, previewRequest: validation.request })).toBe(false); + expect(canRunSessionCleanup({ canCleanup: true, draft, preview: { ...preview, totals: { archiveCount: 0, deleteCount: 0 } }, previewRequest: validation.request })).toBe(false); + expect(canRunSessionCleanup({ canCleanup: false, draft, preview, previewRequest: validation.request })).toBe(false); + }); + + it("normalizes request keys for null, omitted disabled actions, and selected projects", () => { + expect(sessionCleanupRequestKey({ archiveIdleDays: 30 })).toBe(sessionCleanupRequestKey({ archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo"] })); + }); + + it("summarizes the preview for selected projects", () => { + const multiProjectPreview: SessionCleanupPreviewResponse = { + ...preview, + projects: [ + { cwd: "/repo-a", archiveCount: 2, deleteCount: 1 }, + { cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }, + ], + totals: { archiveCount: 2, deleteCount: 4 }, + }; + + expect(selectedSessionCleanupProjectCwds(multiProjectPreview, undefined)).toEqual(["/repo-a", "/repo-b"]); + expect(selectedSessionCleanupProjectCwds(multiProjectPreview, ["/missing", "/repo-b"])).toEqual(["/repo-b"]); + expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, ["/repo-b"])).toMatchObject({ + projects: [{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 }], + totals: { archiveCount: 0, deleteCount: 3 }, + }); + expect(sessionCleanupPreviewForSelectedProjects(multiProjectPreview, [])).toMatchObject({ + projects: [], + totals: { archiveCount: 0, deleteCount: 0 }, + }); + }); + + it("uses explicit permanent deletion copy in confirmation and unavailable messages", () => { + const confirmMessages: string[] = []; + expect(confirmSessionCleanup(preview, (message) => { + confirmMessages.push(message); + return true; + })).toBe(true); + expect(confirmMessages[0]).toContain("permanently delete 1 archived session"); + expect(sessionCleanupConfirmationMessage(preview)).toContain("cannot be undone"); + expect(sessionCleanupUnavailableMessage("Remote Dev")).toBe("Update and restart Pi-Web on Remote Dev to clean up sessions."); + }); +}); diff --git a/src/client/src/sessionCleanupUi.ts b/src/client/src/sessionCleanupUi.ts new file mode 100644 index 0000000..c611bda --- /dev/null +++ b/src/client/src/sessionCleanupUi.ts @@ -0,0 +1,116 @@ +import type { SessionCleanupPreviewResponse, SessionCleanupRequest } from "./api"; + +export interface SessionCleanupDraft { + archiveIdleEnabled: boolean; + archiveIdleDays: string; + deleteArchivedEnabled: boolean; + deleteArchivedDays: string; +} + +export type SessionCleanupDraftValidation = + | { ok: true; request: SessionCleanupRequest } + | { ok: false; error: string }; + +export const DEFAULT_SESSION_CLEANUP_DRAFT: SessionCleanupDraft = { + archiveIdleEnabled: true, + archiveIdleDays: "30", + deleteArchivedEnabled: false, + deleteArchivedDays: "90", +}; + +export function validateSessionCleanupDraft(draft: SessionCleanupDraft): SessionCleanupDraftValidation { + if (!draft.archiveIdleEnabled && !draft.deleteArchivedEnabled) return { ok: false, error: "Enable at least one cleanup action." }; + + const request: SessionCleanupRequest = { + archiveIdleDays: null, + deleteArchivedDays: null, + }; + + if (draft.archiveIdleEnabled) { + const archiveIdleDays = parseDayThreshold(draft.archiveIdleDays, "Archive idle sessions after"); + if (typeof archiveIdleDays === "string") return { ok: false, error: archiveIdleDays }; + request.archiveIdleDays = archiveIdleDays; + } + + if (draft.deleteArchivedEnabled) { + const deleteArchivedDays = parseDayThreshold(draft.deleteArchivedDays, "Delete archived sessions after"); + if (typeof deleteArchivedDays === "string") return { ok: false, error: deleteArchivedDays }; + request.deleteArchivedDays = deleteArchivedDays; + } + + return { ok: true, request }; +} + +export function sessionCleanupRequestKey(request: SessionCleanupRequest | undefined): string { + // The preview freshness key is threshold-only: project selection is applied + // to the already-previewed project list and sent separately when running. + return JSON.stringify({ + archiveIdleDays: request?.archiveIdleDays ?? null, + deleteArchivedDays: request?.deleteArchivedDays ?? null, + }); +} + +export function canRunSessionCleanup(input: { + canCleanup: boolean; + draft: SessionCleanupDraft; + preview: SessionCleanupPreviewResponse | undefined; + previewRequest: SessionCleanupRequest | undefined; + loading?: boolean; + running?: boolean; +}): boolean { + if (!input.canCleanup || input.loading === true || input.running === true || input.preview === undefined) return false; + const validation = validateSessionCleanupDraft(input.draft); + if (!validation.ok) return false; + if (sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(input.previewRequest)) return false; + return sessionCleanupPreviewHasTargets(input.preview); +} + +export function sessionCleanupPreviewHasTargets(preview: Pick): boolean { + return preview.totals.archiveCount > 0 || preview.totals.deleteCount > 0; +} + +export function selectedSessionCleanupProjectCwds(preview: Pick, selectedProjectCwds: readonly string[] | undefined): string[] { + const previewCwds = preview.projects.map((project) => project.cwd); + if (selectedProjectCwds === undefined) return previewCwds; + const selected = new Set(selectedProjectCwds); + return previewCwds.filter((cwd) => selected.has(cwd)); +} + +export function sessionCleanupPreviewForSelectedProjects(preview: SessionCleanupPreviewResponse, selectedProjectCwds: readonly string[] | undefined): SessionCleanupPreviewResponse { + const selected = new Set(selectedSessionCleanupProjectCwds(preview, selectedProjectCwds)); + const projects = preview.projects.filter((project) => selected.has(project.cwd)); + return { + ...preview, + projects, + totals: projects.reduce((totals, project) => ({ + archiveCount: totals.archiveCount + project.archiveCount, + deleteCount: totals.deleteCount + project.deleteCount, + }), { archiveCount: 0, deleteCount: 0 }), + }; +} + +export function confirmSessionCleanup(preview: Pick, confirmCleanup: (message: string) => boolean): boolean { + return confirmCleanup(sessionCleanupConfirmationMessage(preview)); +} + +export function sessionCleanupConfirmationMessage(preview: Pick): string { + const archiveCount = preview.totals.archiveCount; + const deleteCount = preview.totals.deleteCount; + const parts: string[] = []; + if (archiveCount > 0) parts.push(`archive ${String(archiveCount)} idle ${archiveCount === 1 ? "session" : "sessions"}`); + if (deleteCount > 0) parts.push(`permanently delete ${String(deleteCount)} archived ${deleteCount === 1 ? "session" : "sessions"}`); + const action = parts.length === 0 ? "run cleanup" : parts.join(" and "); + return `Run cleanup and ${action}?\n\nPermanent deletion only applies to archived sessions and cannot be undone.`; +} + +export function sessionCleanupUnavailableMessage(machineName: string | undefined): string { + return `Update and restart Pi-Web on ${machineName ?? "this machine"} to clean up sessions.`; +} + +function parseDayThreshold(value: string, label: string): number | string { + const trimmed = value.trim(); + if (trimmed === "") return `${label} must be set.`; + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed < 0) return `${label} must be a non-negative whole number of days.`; + return parsed; +} diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 1b8443a..6468152 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -111,6 +111,8 @@ export interface PluginAction { shortcut?: string; group?: string; enabled?: (context: PluginRuntimeContext) => boolean; + /** Explain why a disabled action is visible but unavailable. */ + disabledReason?: (context: PluginRuntimeContext) => string | undefined; run: (context: PluginRuntimeContext) => void | Promise; } diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index fccea21..d787ae6 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -72,6 +72,16 @@ describe("Pi session manager gateway", () => { await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })])); }); + it("includes an absolute env-configured session directory in global listing", async () => { + const envSessionDir = join(tempDir, "env-sessions"); + await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd); + await writeSessionFile(envSessionDir, "env-session", cwd); + const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } }); + + if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); + await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); + }); + it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => { const sharedSessionDir = join(tempDir, "shared-sessions"); const otherCwd = join(tempDir, "other-workspace"); diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index 2282b1b..57a2446 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -34,6 +34,13 @@ export class SessionDirResolver { return defaultPiSessionsRoot(this.agentDir); } + globalEnvSessionDir(): string | undefined { + const envSessionDir = this.env[PI_SESSION_DIR_ENV]; + if (envSessionDir === undefined || envSessionDir === "") return undefined; + const expanded = expandTildePath(envSessionDir); + return isAbsolute(expanded) ? expanded : undefined; + } + resolve(cwd: string): SessionDirResolution { const envSessionDir = this.env[PI_SESSION_DIR_ENV]; if (envSessionDir !== undefined && envSessionDir !== "") { @@ -68,8 +75,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway { return SessionManager.create(cwd, resolution.sessionDir, options?.parentSession === undefined ? undefined : { parentSession: options.parentSession }); } - listAll(): Promise { - return listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()); + async listAll(): Promise { + const envSessionDir = this.resolver.globalEnvSessionDir(); + const [defaultSessions, envSessions] = await Promise.all([ + listSessionsInDefaultPiStore(this.resolver.defaultSessionsRoot()), + envSessionDir === undefined ? Promise.resolve([]) : listSessionsInDir(envSessionDir), + ]); + return uniqueSessionsByPath([...defaultSessions, ...envSessions]); } open(path: string): PiSessionManager { @@ -106,6 +118,12 @@ export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cw return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd)); } +function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] { + const byPath = new Map(); + for (const session of sessions) byPath.set(session.path, session); + return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime()); +} + export function defaultPiSessionsRoot(agentDir = getAgentDir()): string { return join(agentDir, "sessions"); } diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 4c54fde..d2bcdbf 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -446,6 +446,94 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("previews session cleanup without mutating and executes a recomputed plan", async () => { + const archivedInputs: string[] = []; + const deletedSessionIds: string[] = []; + let listAllCalls = 0; + const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; + const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + archiveStore: { + list: () => Promise.resolve([archived, otherArchived]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + deleteArchived: (sessionId) => { + deletedSessionIds.push(sessionId); + return Promise.resolve(); + }, + }, + sessionManager: { + create: () => fakeSessionManager(), + list: () => Promise.resolve([]), + listAll: () => { + listAllCalls += 1; + return Promise.resolve([ + listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"), + listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"), + ]); + }, + open: () => fakeSessionManager(), + }, + heartbeatIntervalMs: 60_000, + }); + + const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]); + expect(archivedInputs).toEqual([]); + expect(deletedSessionIds).toEqual([]); + + const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] }); + expect(result.archivedSessionIds).toEqual(["execute-only"]); + expect(result.deletedSessionIds).toEqual(["archived-old"]); + expect(archivedInputs).toEqual(["execute-only"]); + expect(deletedSessionIds).toEqual(["archived-old"]); + + await service.dispose(); + }); + + it("skips busy active sessions during cleanup execution", async () => { + const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); + const archivedInputs: string[] = []; + const service = new PiSessionService(new CapturingSessionEventHub(), { + now: () => new Date("2026-06-25T00:00:00.000Z"), + createAgentRuntime: runtimeCreator(fake.runtime), + archiveStore: { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: (input) => { + archivedInputs.push(input.sessionId); + return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" }); + }, + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }, + sessionManager: { + create: () => fakeSessionManager("/old-project"), + list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]), + open: () => fakeSessionManager("/old-project"), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status("busy-open"); + const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } }); + + expect(result.archivedSessionIds).toEqual([]); + expect(result.skippedBusySessionIds).toEqual(["busy-open"]); + expect(archivedInputs).toEqual([]); + expect(fake.calls.abort).toBe(0); + + await service.dispose(); + }); + it("reloads a session by closing the active runtime and re-opening it from disk", async () => { const first = fakeRuntime("reload-session"); const second = fakeRuntime("reload-session"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 0d38540..7a3d8dd 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -13,7 +13,7 @@ import { type CreateAgentSessionRuntimeFactory, type EditToolDetails, } from "@earendil-works/pi-coding-agent"; -import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; +import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js"; import { pageMessagesAtSafeBoundary } from "./messagePaging.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; @@ -34,6 +34,7 @@ import type { WorkspaceActivityService } from "../activity/workspaceActivityServ import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; import { buildTranscriptView } from "./subsessionTranscript.js"; +import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js"; import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js"; /** @@ -298,6 +299,8 @@ export interface PiSessionServiceDependencies { subsessionsEnabled?: boolean; /** Structured logger for notable runtime events (e.g. spawns). */ logger?: PiSessionLogger; + /** Clock seam for cleanup planning tests. */ + now?: () => Date; } export class PiSessionService { @@ -331,6 +334,7 @@ export class PiSessionService { private readonly workspaceActivity: Pick | undefined; private readonly spawnTargets: SpawnTargetResolver | undefined; private readonly logger: PiSessionLogger; + private readonly now: () => Date; constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); @@ -339,6 +343,7 @@ export class PiSessionService { this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create()); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; + this.now = deps.now ?? (() => new Date()); // Subsessions are a beta capability gated behind their own flag, and they // also require the spawn capability (they share its project-scope resolver). const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true; @@ -378,6 +383,48 @@ export class PiSessionService { return this.active.size; } + async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { + return previewResponseFromPlan(await this.cleanupPlan(request)); + } + + async cleanup(request: NormalizedSessionCleanupRequest): Promise { + const plan = await this.cleanupPlan(request); + if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion"); + + const archiveInputs: ArchiveSessionInput[] = []; + const deleteRecords: ArchivedSessionRecord[] = []; + const skippedBusySessionIds = new Set(plan.skippedBusySessionIds); + + for (const input of plan.archiveInputs) { + if (this.activeSessionHasWork(input.sessionId)) { + skippedBusySessionIds.add(input.sessionId); + continue; + } + await this.closeActive(input.sessionId); + await this.archiveStore.archive(input); + archiveInputs.push(input); + } + + for (const record of plan.deleteRecords) { + if (this.activeSessionHasWork(record.sessionId)) { + skippedBusySessionIds.add(record.sessionId); + continue; + } + await this.closeActive(record.sessionId); + if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record); + await this.archiveStore.deleteArchived?.(record.sessionId); + deleteRecords.push(record); + } + + return summarizeSessionCleanupExecution({ + archiveInputs, + deleteRecords, + thresholds: plan.thresholds, + generatedAt: plan.generatedAt, + skippedBusySessionIds: [...skippedBusySessionIds], + }); + } + async dispose(): Promise { clearInterval(this.heartbeat); this.clearCompactionDrainTimers(); @@ -1093,6 +1140,30 @@ export class PiSessionService { }); } + private async cleanupPlan(request: NormalizedSessionCleanupRequest) { + const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]); + return planSessionCleanup({ + sessions, + archivedRecords, + activeSessions: this.cleanupActiveSessionStatuses(), + thresholds: request.thresholds, + ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }), + now: this.now(), + }); + } + + private cleanupActiveSessionStatuses(): { sessionId: string; hasActiveWork: boolean }[] { + return [...new Set(this.active.values())].map((active) => ({ + sessionId: active.runtime.session.sessionId, + hasActiveWork: this.hasActiveWork(active.runtime.session), + })); + } + + private activeSessionHasWork(sessionId: string): boolean { + const active = this.active.get(sessionId); + return active !== undefined && this.hasActiveWork(active.runtime.session); + } + private reconcilableSessionIds(cwd: string, listedSessionIds: string[], archivedById: Map): string[] { const sessionIds = new Set(listedSessionIds); for (const active of new Set(this.active.values())) { @@ -1523,6 +1594,16 @@ export class PiSessionService { } } +function previewResponseFromPlan(plan: SessionCleanupPlan): ClientSessionCleanupPreviewResponse { + return { + generatedAt: plan.generatedAt, + thresholds: plan.thresholds, + projects: plan.projects, + totals: plan.totals, + ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }), + }; +} + function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel { if (model === undefined) return {}; const name = getString(model, "name"); diff --git a/src/server/sessions/sessionCleanup.test.ts b/src/server/sessions/sessionCleanup.test.ts new file mode 100644 index 0000000..ef7087d --- /dev/null +++ b/src/server/sessions/sessionCleanup.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSessionCleanupRequest, normalizeSessionCleanupThresholds, planSessionCleanup } from "./sessionCleanup.js"; +import type { PiSessionListEntry } from "./piSessionService.js"; +import type { ArchivedSessionRecord } from "./sessionArchiveStore.js"; + +describe("session cleanup planning", () => { + it("plans cleanup by strict cutoffs and groups counts by stored cwd", () => { + const now = new Date("2026-06-25T00:00:00.000Z"); + const archivedRecords: ArchivedSessionRecord[] = [ + archivedRecord("already-archived", "/unregistered", "2026-06-20T00:00:00.000Z"), + archivedRecord("delete-old", "/other", "2026-06-14T23:59:59.999Z"), + archivedRecord("keep-exact", "/other", "2026-06-15T00:00:00.000Z"), + ]; + + const plan = planSessionCleanup({ + now, + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 10 }, + archivedRecords, + sessions: [ + sessionEntry("archive-old", "/unregistered", "2026-05-25T23:59:59.999Z"), + sessionEntry("keep-exact", "/unregistered", "2026-05-26T00:00:00.000Z"), + sessionEntry("keep-new", "/unregistered", "2026-05-26T00:00:00.001Z"), + sessionEntry("already-archived", "/unregistered", "2026-05-01T00:00:00.000Z"), + ], + }); + + expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-old"]); + expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-old"]); + expect(plan.projects).toEqual([ + { cwd: "/other", archiveCount: 0, deleteCount: 1 }, + { cwd: "/unregistered", archiveCount: 1, deleteCount: 0 }, + ]); + expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + }); + + it("filters cleanup candidates to selected project cwd paths", () => { + const plan = planSessionCleanup({ + now: new Date("2026-06-25T00:00:00.000Z"), + thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, + projectCwds: ["/repo-a"], + sessions: [ + sessionEntry("archive-a", "/repo-a", "2026-05-01T00:00:00.000Z"), + sessionEntry("archive-b", "/repo-b", "2026-05-01T00:00:00.000Z"), + ], + archivedRecords: [ + archivedRecord("delete-a", "/repo-a", "2026-05-01T00:00:00.000Z"), + archivedRecord("delete-b", "/repo-b", "2026-05-01T00:00:00.000Z"), + ], + }); + + expect(plan.archiveInputs.map((input) => input.sessionId)).toEqual(["archive-a"]); + expect(plan.deleteRecords.map((record) => record.sessionId)).toEqual(["delete-a"]); + expect(plan.projects).toEqual([{ cwd: "/repo-a", archiveCount: 1, deleteCount: 1 }]); + expect(plan.totals).toEqual({ archiveCount: 1, deleteCount: 1 }); + }); + + it("skips archive and delete candidates that are busy in memory", () => { + const plan = planSessionCleanup({ + now: new Date("2026-06-25T00:00:00.000Z"), + thresholds: { archiveIdleDays: 1, deleteArchivedDays: 1 }, + sessions: [sessionEntry("busy-open", "/repo", "2026-06-01T00:00:00.000Z")], + archivedRecords: [archivedRecord("busy-archived", "/repo", "2026-06-01T00:00:00.000Z")], + activeSessions: [ + { sessionId: "busy-open", hasActiveWork: true }, + { sessionId: "busy-archived", hasActiveWork: true }, + ], + }); + + expect(plan.archiveInputs).toHaveLength(0); + expect(plan.deleteRecords).toHaveLength(0); + expect(plan.skippedBusySessionIds).toEqual(["busy-archived", "busy-open"]); + expect(plan.totals).toEqual({ archiveCount: 0, deleteCount: 0 }); + }); + + it("validates optional runtime thresholds", () => { + expect(normalizeSessionCleanupThresholds({ archiveIdleDays: 30, deleteArchivedDays: null })).toEqual({ archiveIdleDays: 30 }); + expect(normalizeSessionCleanupThresholds({})).toEqual({}); + expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: -1 })).toThrow("archiveIdleDays field must be a non-negative integer"); + expect(() => normalizeSessionCleanupThresholds({ deleteArchivedDays: 1.5 })).toThrow("deleteArchivedDays field must be a non-negative integer"); + expect(() => normalizeSessionCleanupThresholds({ archiveIdleDays: "30" })).toThrow("archiveIdleDays field must be a non-negative integer"); + }); + + it("validates optional selected project cwd paths", () => { + expect(normalizeSessionCleanupRequest({ archiveIdleDays: 30, projectCwds: ["/repo", "/repo"] })).toEqual({ + thresholds: { archiveIdleDays: 30 }, + projectCwds: ["/repo"], + }); + expect(normalizeSessionCleanupRequest({ projectCwds: null })).toEqual({ thresholds: {} }); + expect(() => normalizeSessionCleanupRequest({ projectCwds: ["/repo", 1] })).toThrow("projectCwds field must be an array of strings"); + }); +}); + +function sessionEntry(id: string, cwd: string, modified: string): PiSessionListEntry { + return { + id, + cwd, + path: `/sessions/${id}.jsonl`, + created: new Date("2026-01-01T00:00:00.000Z"), + modified: new Date(modified), + messageCount: 1, + firstMessage: "hello", + allMessagesText: "hello", + }; +} + +function archivedRecord(sessionId: string, cwd: string, archivedAt: string): ArchivedSessionRecord { + return { sessionId, cwd, archivedAt }; +} diff --git a/src/server/sessions/sessionCleanup.ts b/src/server/sessions/sessionCleanup.ts new file mode 100644 index 0000000..b1b5eb4 --- /dev/null +++ b/src/server/sessions/sessionCleanup.ts @@ -0,0 +1,203 @@ +import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds } from "../../shared/apiTypes.js"; +import type { PiSessionListEntry } from "./piSessionService.js"; +import type { ArchivedSessionRecord, ArchiveSessionInput } from "./sessionArchiveStore.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface CleanupActiveSessionStatus { + sessionId: string; + hasActiveWork: boolean; +} + +export interface PlanSessionCleanupInput { + sessions: readonly PiSessionListEntry[]; + archivedRecords: readonly ArchivedSessionRecord[]; + activeSessions?: readonly CleanupActiveSessionStatus[]; + thresholds: SessionCleanupThresholds; + projectCwds?: readonly string[]; + now: Date; +} + +export interface SessionCleanupPlan extends SessionCleanupPreviewResponse { + archiveInputs: ArchiveSessionInput[]; + deleteRecords: ArchivedSessionRecord[]; + skippedBusySessionIds: string[]; +} + +export interface NormalizedSessionCleanupRequest { + thresholds: SessionCleanupThresholds; + /** Stored cwd paths to include. Undefined means all discovered projects/workspaces. */ + projectCwds?: string[]; +} + +export function normalizeSessionCleanupRequest(record: Record): NormalizedSessionCleanupRequest { + const projectCwds = optionalProjectCwds(record); + return { + thresholds: normalizeSessionCleanupThresholds(record), + ...(projectCwds === undefined ? {} : { projectCwds }), + }; +} + +export function normalizeSessionCleanupThresholds(record: Record): SessionCleanupThresholds { + const thresholds: SessionCleanupThresholds = {}; + const archiveIdleDays = optionalDayThreshold(record, "archiveIdleDays"); + const deleteArchivedDays = optionalDayThreshold(record, "deleteArchivedDays"); + if (archiveIdleDays !== undefined) thresholds.archiveIdleDays = archiveIdleDays; + if (deleteArchivedDays !== undefined) thresholds.deleteArchivedDays = deleteArchivedDays; + return thresholds; +} + +export function planSessionCleanup(input: PlanSessionCleanupInput): SessionCleanupPlan { + const thresholds = copyThresholds(input.thresholds); + const archiveCutoff = cutoffTime(input.now, thresholds.archiveIdleDays); + const deleteCutoff = cutoffTime(input.now, thresholds.deleteArchivedDays); + const archivedIds = new Set(input.archivedRecords.map((record) => record.sessionId)); + const includedCwds = input.projectCwds === undefined ? undefined : new Set(input.projectCwds); + const busySessionIds = new Set((input.activeSessions ?? []).filter((session) => session.hasActiveWork).map((session) => session.sessionId)); + const skippedBusy = new Set(); + const archiveInputs: ArchiveSessionInput[] = []; + const deleteRecords: ArchivedSessionRecord[] = []; + + if (archiveCutoff !== undefined) { + for (const session of uniqueSessionsById(input.sessions)) { + if (archivedIds.has(session.id)) continue; + if (includedCwds !== undefined && !includedCwds.has(session.cwd)) continue; + if (!isBefore(session.modified, archiveCutoff)) continue; + if (busySessionIds.has(session.id)) { + skippedBusy.add(session.id); + continue; + } + archiveInputs.push(archiveInputFromListEntry(session)); + } + } + + if (deleteCutoff !== undefined) { + for (const record of input.archivedRecords) { + if (includedCwds !== undefined && !includedCwds.has(record.cwd)) continue; + if (!isTimestampBefore(record.archivedAt, deleteCutoff)) continue; + if (busySessionIds.has(record.sessionId)) { + skippedBusy.add(record.sessionId); + continue; + } + deleteRecords.push(record); + } + } + + return { + ...summarizeSessionCleanupTargets({ archiveInputs, deleteRecords, thresholds, generatedAt: input.now.toISOString(), skippedBusySessionIds: [...skippedBusy] }), + archiveInputs, + deleteRecords, + skippedBusySessionIds: [...skippedBusy].sort(), + }; +} + +export function summarizeSessionCleanupTargets(input: { + archiveInputs: readonly ArchiveSessionInput[]; + deleteRecords: readonly ArchivedSessionRecord[]; + thresholds: SessionCleanupThresholds; + generatedAt: string; + skippedBusySessionIds?: readonly string[]; +}): SessionCleanupPreviewResponse { + const projectsByCwd = new Map(); + let archiveCount = 0; + let deleteCount = 0; + + for (const session of input.archiveInputs) { + archiveCount += 1; + projectSummary(projectsByCwd, session.cwd).archiveCount += 1; + } + + for (const record of input.deleteRecords) { + deleteCount += 1; + projectSummary(projectsByCwd, record.cwd).deleteCount += 1; + } + + const skippedBusySessionIds = [...new Set(input.skippedBusySessionIds ?? [])].sort(); + return { + generatedAt: input.generatedAt, + thresholds: copyThresholds(input.thresholds), + projects: [...projectsByCwd.values()].sort((a, b) => a.cwd.localeCompare(b.cwd)), + totals: { archiveCount, deleteCount }, + ...(skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds }), + }; +} + +export function summarizeSessionCleanupExecution(input: { + archiveInputs: readonly ArchiveSessionInput[]; + deleteRecords: readonly ArchivedSessionRecord[]; + thresholds: SessionCleanupThresholds; + generatedAt: string; + skippedBusySessionIds?: readonly string[]; +}): SessionCleanupExecuteResponse { + return { + ...summarizeSessionCleanupTargets(input), + archivedSessionIds: input.archiveInputs.map((session) => session.sessionId), + deletedSessionIds: input.deleteRecords.map((record) => record.sessionId), + }; +} + +function optionalDayThreshold(record: Record, field: keyof SessionCleanupThresholds): number | undefined { + const value = record[field]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${field} field must be a non-negative integer`); + return value; +} + +function optionalProjectCwds(record: Record): string[] | undefined { + const value = record["projectCwds"]; + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) throw new Error("projectCwds field must be an array of strings"); + return [...new Set(value)]; +} + +function cutoffTime(now: Date, days: number | undefined): number | undefined { + return days === undefined ? undefined : now.getTime() - days * DAY_MS; +} + +function isBefore(value: Date, cutoff: number): boolean { + const time = value.getTime(); + return Number.isFinite(time) && time < cutoff; +} + +function isTimestampBefore(value: string, cutoff: number): boolean { + const time = Date.parse(value); + return Number.isFinite(time) && time < cutoff; +} + +function uniqueSessionsById(sessions: readonly PiSessionListEntry[]): PiSessionListEntry[] { + const sessionsById = new Map(); + for (const session of sessions) { + const existing = sessionsById.get(session.id); + if (existing === undefined || session.modified.getTime() > existing.modified.getTime()) sessionsById.set(session.id, session); + } + return [...sessionsById.values()]; +} + +function archiveInputFromListEntry(session: PiSessionListEntry): ArchiveSessionInput { + return { + sessionId: session.id, + cwd: session.cwd, + path: session.path, + created: session.created.toISOString(), + modified: session.modified.toISOString(), + messageCount: session.messageCount, + firstMessage: session.firstMessage, + ...(session.name === undefined ? {} : { name: session.name }), + ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }), + }; +} + +function projectSummary(projectsByCwd: Map, cwd: string): SessionCleanupProjectSummary { + const existing = projectsByCwd.get(cwd); + if (existing !== undefined) return existing; + const created = { cwd, archiveCount: 0, deleteCount: 0 }; + projectsByCwd.set(cwd, created); + return created; +} + +function copyThresholds(thresholds: SessionCleanupThresholds): SessionCleanupThresholds { + const copy: SessionCleanupThresholds = {}; + if (thresholds.archiveIdleDays !== undefined) copy.archiveIdleDays = thresholds.archiveIdleDays; + if (thresholds.deleteArchivedDays !== undefined) copy.deleteArchivedDays = thresholds.deleteArchivedDays; + return copy; +} diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index 69562b5..2f2e730 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -2,9 +2,11 @@ import { resolve } from "node:path"; import Fastify, { type FastifyInstance } from "fastify"; import fastifyWebsocket from "@fastify/websocket"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js"; import { SessionEventHub } from "../realtime/sessionEventHub.js"; import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js"; import { registerSessionRoutes } from "./sessionRoutes.js"; +import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; let app: FastifyInstance; let service: PiSessionService; @@ -136,17 +138,69 @@ describe("session routes", () => { await routeApp.close(); } }); + + it("normalizes cleanup requests for preview and execute routes", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const previewResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup/preview", payload: { archiveIdleDays: 30, deleteArchivedDays: null, projectCwds: ["/repo-a", "/repo-a"] } }); + const executeResponse = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: null, deleteArchivedDays: 7, projectCwds: ["/repo-b"] } }); + + expect(previewResponse.statusCode).toBe(200); + expect(executeResponse.statusCode).toBe(200); + expect(routeService.cleanupPreviewCalls).toEqual([{ thresholds: { archiveIdleDays: 30 }, projectCwds: ["/repo-a"] }]); + expect(routeService.cleanupCalls).toEqual([{ thresholds: { deleteArchivedDays: 7 }, projectCwds: ["/repo-b"] }]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); + + it("rejects invalid cleanup thresholds before calling the service", async () => { + const routeApp = Fastify({ logger: false }); + await routeApp.register(fastifyWebsocket); + const eventHub = new SessionEventHub(); + const routeService = new CapturingRouteSessionService(eventHub); + registerSessionRoutes(routeApp, routeService, eventHub); + + try { + const response = await routeApp.inject({ method: "POST", url: "/sessions/cleanup", payload: { archiveIdleDays: -1 } }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "archiveIdleDays field must be a non-negative integer" }); + expect(routeService.cleanupCalls).toEqual([]); + } finally { + await routeService.dispose(); + await routeApp.close(); + } + }); }); class CapturingRouteSessionService extends PiSessionService { readonly calls: unknown[] = []; readonly reloadCalls: (string | PiSessionRef)[] = []; + readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = []; + readonly cleanupCalls: NormalizedSessionCleanupRequest[] = []; reloadError: Error | undefined; constructor(eventHub: SessionEventHub) { super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 }); } + override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise { + this.cleanupPreviewCalls.push(request); + return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } }); + } + + override cleanup(request: NormalizedSessionCleanupRequest): Promise { + this.cleanupCalls.push(request); + return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] }); + } + override reload(lookup: string | PiSessionRef): Promise { this.reloadCalls.push(lookup); if (this.reloadError !== undefined) return Promise.reject(this.reloadError); diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 22078c8..9e0c8a2 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from "fastify"; +import type { SessionCleanupRequest } from "../../shared/apiTypes.js"; import { normalizeRequestCwd } from "../workingDirectory.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiSessionRef, PiSessionService } from "./piSessionService.js"; +import { normalizeSessionCleanupRequest } from "./sessionCleanup.js"; type SessionLookup = string | PiSessionRef; @@ -46,6 +48,22 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); + app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => { + try { + return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body))); + } catch (error) { + return reply.code(400).send({ error: errorMessage(error) }); + } + }); + + app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup`, async (request, reply) => { + try { + return await sessions.cleanup(normalizeSessionCleanupRequest(optionalRecord(request.body))); + } catch (error) { + return reply.code(400).send({ error: errorMessage(error) }); + } + }); + app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { try { const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) }; diff --git a/src/server/types.ts b/src/server/types.ts index 541ac8d..6a3fe5f 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -4,6 +4,10 @@ export type { SessionRef as ClientSessionRef, SessionInfo as ClientSession, ArchiveSessionsResponse as ClientArchiveSessionsResponse, + SessionCleanupRequest as ClientSessionCleanupRequest, + SessionCleanupThresholds as ClientSessionCleanupThresholds, + SessionCleanupPreviewResponse as ClientSessionCleanupPreviewResponse, + SessionCleanupExecuteResponse as ClientSessionCleanupExecuteResponse, MessagePage as ClientMessagePage, SessionStatus as ClientSessionStatus, SessionModel as ClientSessionModel, diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 637dfd0..cf028dc 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -3,6 +3,7 @@ export type MachineStatus = "unknown" | "online" | "offline" | "error"; export const PI_WEB_CAPABILITIES = { sessionsDeleteArchived: "sessions.deleteArchived", + sessionsCleanup: "sessions.cleanup", sessionsReload: "sessions.reload", promptAttachments: "prompt.attachments", workspaceFileSuggestions: "workspace.fileSuggestions", @@ -162,6 +163,44 @@ export interface ArchiveSessionsResponse { skippedAlreadyArchivedCount?: number; } +export interface SessionCleanupRequest { + /** Archive non-archived sessions whose modified time is older than this many days. Omit/null to disable. */ + archiveIdleDays?: number | null; + /** Permanently delete archived sessions whose archivedAt time is older than this many days. Omit/null to disable. */ + deleteArchivedDays?: number | null; + /** Stored cwd paths selected from a preview. Omit/null to include all discovered project/workspace paths. */ + projectCwds?: string[] | null; +} + +export interface SessionCleanupThresholds { + archiveIdleDays?: number; + deleteArchivedDays?: number; +} + +export interface SessionCleanupProjectSummary { + cwd: string; + archiveCount: number; + deleteCount: number; +} + +export interface SessionCleanupTotals { + archiveCount: number; + deleteCount: number; +} + +export interface SessionCleanupPreviewResponse { + generatedAt: string; + thresholds: SessionCleanupThresholds; + projects: SessionCleanupProjectSummary[]; + totals: SessionCleanupTotals; + skippedBusySessionIds?: string[]; +} + +export interface SessionCleanupExecuteResponse extends SessionCleanupPreviewResponse { + archivedSessionIds: string[]; + deletedSessionIds: string[]; +} + export interface SessionActivity { sessionId: string; phase: "active" | "idle" | "error"; diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 39e26a7..87df6f4 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -6,11 +6,12 @@ export type { PiWebCapability }; export const KNOWN_PI_WEB_CAPABILITIES = Object.values(PI_WEB_CAPABILITIES); const knownPiWebCapabilities: ReadonlySet = new Set(KNOWN_PI_WEB_CAPABILITIES); -export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[]; -export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; +export const WEB_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments, PI_WEB_CAPABILITIES.workspaceFileSuggestions] as const satisfies readonly PiWebCapability[]; +export const SESSIOND_RUNTIME_CAPABILITIES = [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsCleanup, PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.promptAttachments] as const satisfies readonly PiWebCapability[]; const EFFECTIVE_CAPABILITY_REQUIREMENTS = { [PI_WEB_CAPABILITIES.sessionsDeleteArchived]: ["web", "sessiond"], + [PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"], [PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"], diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index e5aa6b3..a78fc16 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -35,6 +35,8 @@ export const FEDERATED_HTTP_ROUTES = [ { method: "GET", path: "/activity" }, { method: "GET", path: "/sessions" }, { method: "POST", path: "/sessions" }, + { method: "POST", path: "/sessions/cleanup/preview" }, + { method: "POST", path: "/sessions/cleanup" }, { method: "GET", path: "/sessions/:sessionId/messages" }, { method: "GET", path: "/sessions/:sessionId/status" }, { method: "GET", path: "/sessions/:sessionId/models" }, From 041423a03be0f4748585a26d9afbb37cd7e8812d Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 26 Jun 2026 18:20:39 +0200 Subject: [PATCH 10/35] chore(release): v1.202606.7 --- .changeset/bidi-chat-text.md | 5 ----- .changeset/chat-file-uploads.md | 5 ----- .changeset/fix-fish-doctor-version-check.md | 9 --------- .changeset/git-inline-diff-highlights.md | 5 ----- .changeset/manual-session-cleanup.md | 5 ----- .changeset/manual-workspace-uploads.md | 5 ----- .changeset/mobile-enter-newline.md | 5 ----- .changeset/persist-subsession-links.md | 5 ----- .changeset/plugin-api-completeness.md | 5 ----- .changeset/plugin-panel-prompt-context.md | 5 ----- CHANGELOG.md | 19 +++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 13 files changed, 22 insertions(+), 57 deletions(-) delete mode 100644 .changeset/bidi-chat-text.md delete mode 100644 .changeset/chat-file-uploads.md delete mode 100644 .changeset/fix-fish-doctor-version-check.md delete mode 100644 .changeset/git-inline-diff-highlights.md delete mode 100644 .changeset/manual-session-cleanup.md delete mode 100644 .changeset/manual-workspace-uploads.md delete mode 100644 .changeset/mobile-enter-newline.md delete mode 100644 .changeset/persist-subsession-links.md delete mode 100644 .changeset/plugin-api-completeness.md delete mode 100644 .changeset/plugin-panel-prompt-context.md diff --git a/.changeset/bidi-chat-text.md b/.changeset/bidi-chat-text.md deleted file mode 100644 index 4b3efb7..0000000 --- a/.changeset/bidi-chat-text.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Improve chat, prompt, and session text rendering for RTL and mixed-direction content. diff --git a/.changeset/chat-file-uploads.md b/.changeset/chat-file-uploads.md deleted file mode 100644 index d65244b..0000000 --- a/.changeset/chat-file-uploads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. diff --git a/.changeset/fix-fish-doctor-version-check.md b/.changeset/fix-fish-doctor-version-check.md deleted file mode 100644 index 8163c01..0000000 --- a/.changeset/fix-fish-doctor-version-check.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check -wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, -which fish parses as a command substitution in command position and rejects -(`command substitutions not allowed in command position`), producing a false -negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. diff --git a/.changeset/git-inline-diff-highlights.md b/.changeset/git-inline-diff-highlights.md deleted file mode 100644 index 26e3890..0000000 --- a/.changeset/git-inline-diff-highlights.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Highlight within-line changes in the Git diff viewer. diff --git a/.changeset/manual-session-cleanup.md b/.changeset/manual-session-cleanup.md deleted file mode 100644 index 1ae85b1..0000000 --- a/.changeset/manual-session-cleanup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. diff --git a/.changeset/manual-workspace-uploads.md b/.changeset/manual-workspace-uploads.md deleted file mode 100644 index 033fb63..0000000 --- a/.changeset/manual-workspace-uploads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md deleted file mode 100644 index f3978be..0000000 --- a/.changeset/mobile-enter-newline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). diff --git a/.changeset/persist-subsession-links.md b/.changeset/persist-subsession-links.md deleted file mode 100644 index 8199eaa..0000000 --- a/.changeset/persist-subsession-links.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md deleted file mode 100644 index 5a3b3f3..0000000 --- a/.changeset/plugin-api-completeness.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. diff --git a/.changeset/plugin-panel-prompt-context.md b/.changeset/plugin-panel-prompt-context.md deleted file mode 100644 index e7ee9c2..0000000 --- a/.changeset/plugin-panel-prompt-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7617ac9..6393e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # @jmfederico/pi-web +## 1.202606.7 + +### Patch Changes + +- b17faeb: Improve chat, prompt, and session text rendering for RTL and mixed-direction content. +- 7e812aa: Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches. +- 47c9b66: Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check + wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`, + which fish parses as a command substitution in command position and rejects + (`command substitutions not allowed in command position`), producing a false + negative. Emit fish's `begin; ...; end` grouping when the service shell is fish. +- b14205e: Highlight within-line changes in the Git diff viewer. +- cb13af4: Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation. +- e46d9ec: Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations. +- 32ea809: Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line). +- a99696b: Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications. +- 27a3b2b: Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer. +- 9980027: Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt. + ## 1.202606.6 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index e0d243f..4fb83fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.6", + "version": "1.202606.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202606.6", + "version": "1.202606.7", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index ee6caef..3ca31a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.6", + "version": "1.202606.7", "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.", "license": "MIT", "author": "Federico Jaramillo Martinez", From 2009e6a8838ed60502083f4f0da90926073a1b81 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 27 Jun 2026 08:12:01 +0200 Subject: [PATCH 11/35] fix: keep chat prompt input stable during streaming Coalesce session status/activity updates into one render per animation frame instead of one per token, ignore prompt-editor status changes that do not affect what it displays, and stop per-keystroke draft state from re-rendering the surrounding template. This prevents streaming-driven re-renders from interrupting in-progress touch gestures such as the iOS long-press paste/edit callout. --- .../prompt-editor-stable-during-streaming.md | 5 + src/client/src/components/PiWebApp.ts | 21 ++- src/client/src/components/PromptEditor.ts | 50 ++++++- .../src/controllers/sessionController.test.ts | 133 +++++++++++++++++- .../src/controllers/sessionController.ts | 99 +++++++++---- src/client/src/inputModes.test.ts | 9 +- src/client/src/inputModes.ts | 6 + 7 files changed, 287 insertions(+), 36 deletions(-) create mode 100644 .changeset/prompt-editor-stable-during-streaming.md diff --git a/.changeset/prompt-editor-stable-during-streaming.md b/.changeset/prompt-editor-stable-during-streaming.md new file mode 100644 index 0000000..a4bc709 --- /dev/null +++ b/.changeset/prompt-editor-stable-during-streaming.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the chat prompt input stable during streaming so mobile touch gestures (such as the iOS long-press paste/edit callout) are no longer interrupted. Session status and activity updates are now coalesced into a single render per animation frame instead of one per token, the prompt editor ignores status changes that do not affect what it displays, and per-keystroke draft state no longer triggers surrounding re-renders. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index ba6ae20..30cc18d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1830,6 +1830,25 @@ export class PiWebApp extends LitElement { void this.sessions.send(text, streamingBehavior, attachments, delivery); } + // Stable handler identities for . Inlined arrow closures would + // be a fresh reference on every render, forcing Lit to re-commit the bindings + // each time the app re-renders; bound class fields keep them constant. + private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => { + this.sendPrompt(text, streamingBehavior, attachments, delivery); + }; + + private readonly handleStopActiveWork = (): void => { + void this.sessions.stopActiveWork(); + }; + + private readonly handleSelectModel = (): void => { + void this.openModelDialog(); + }; + + private readonly handleSelectThinking = (): void => { + void this.openThinkingDialog(); + }; + private renderContextBar() { if (!this.appShell.isMobileNavigationLayout) return null; return html` @@ -1889,7 +1908,7 @@ export class PiWebApp extends LitElement {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> - 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> + 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 0fcffaf..7c79bdc 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; -import { inputModeForDraft } from "../inputModes"; +import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; @@ -42,7 +42,14 @@ export class PromptEditor extends LitElement { @property({ attribute: false }) availableThinkingLevels: readonly string[] = []; @query(".markdown-editor") private editorHost?: HTMLDivElement; @query(".attachment-input") private attachmentInput?: HTMLInputElement; - @state() private draft = ""; + // `draft` is the live document text but is intentionally NOT reactive: it + // changes on every keystroke and the visible text is owned by CodeMirror, not + // by Lit's render. Re-rendering the surrounding template on each keystroke is + // wasted work and, on iOS, can interrupt an in-progress touch gesture (the + // long-press edit/paste callout). Only `currentInputMode` (shell vs. normal) + // is reactive, since that is the only draft-derived value the template shows. + private draft = ""; + @state() private currentInputMode: InputMode = { kind: "normal" }; @state() private completions: CompletionItem[] = []; @state() private selectedIndex = 0; @state() private attachments: PendingAttachment[] = []; @@ -64,17 +71,29 @@ export class PromptEditor extends LitElement { if (previousKey !== undefined) saveDraft(previousKey, this.draft); const currentKey = draftStorageKey(this.machineId, this.sessionId); this.draft = currentKey !== undefined ? loadDraft(currentKey) : ""; + this.currentInputMode = inputModeForDraft(this.draft); this.completions = []; this.selectedIndex = 0; } + protected override shouldUpdate(changed: PropertyValues): boolean { + // Status updates churn once per token during streaming and hand us a fresh + // object reference each time. When nothing else changed, only re-render if a + // status field the template actually displays differs, so streaming does not + // disturb the editor DOM (and any in-progress touch gesture survives). + if (changed.has("status") && changed.size === 1) { + return !sessionStatusRenderEqual(changed.get("status"), this.status); + } + return true; + } + override firstUpdated(): void { this.createEditor(); } protected override updated(changed: PropertyValues) { if (changed.has("disabled")) this.updateEditorDisabledState(); - if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); + if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); } override disconnectedCallback(): void { @@ -84,8 +103,8 @@ export class PromptEditor extends LitElement { } override render() { - const inputMode = inputModeForDraft(this.draft); - const shellMode = inputMode.kind === "shell"; + const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined; + const shellMode = shellInputMode !== undefined; const queuesInput = this.canSteer || this.isCompacting; const busy = this.disabled || this.sending; return html` @@ -94,7 +113,7 @@ export class PromptEditor extends LitElement {
{ void this.handleFileInput(event); }} /> - ${shellMode ? html`
Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} + ${shellMode ? html`
Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} ${this.isCompacting && !shellMode ? html`
Compacting history · message will be queued
` : null} ${this.renderAttachments()} { this.pick(item); }}> @@ -288,6 +307,8 @@ export class PromptEditor extends LitElement { this.draft = value; const key = draftStorageKey(this.machineId, this.sessionId); if (key !== undefined) saveDraft(key, this.draft); + const nextInputMode = inputModeForDraft(this.draft); + if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode; void this.refreshCompletions(); } @@ -432,16 +453,33 @@ export class PromptEditor extends LitElement { private resetComposer() { this.draft = ""; + this.currentInputMode = { kind: "normal" }; const key = draftStorageKey(this.machineId, this.sessionId); if (key !== undefined) clearDraft(key); this.completions = []; this.attachments = []; this.attachmentError = undefined; + // `draft` is not reactive, so the cleared text will not flow to CodeMirror + // via `updated()`; push it to the editor document explicitly. + this.syncEditorDoc(); } static override styles = promptEditorStyles; } +// The only `status` fields the template reads directly are the model identity +// and thinking level (shown in renderCompactStatus). Everything else the editor +// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate +// property that Lit already diffs by value. Comparing just these fields lets us +// ignore the per-token status churn that does not change anything on screen. +function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.model?.id === b.model?.id + && a.model?.provider === b.model?.provider + && a.thinkingLevel === b.thinkingLevel; +} + function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined { if (typeof machineId !== "string" || machineId === "") return undefined; if (typeof sessionId !== "string" || sessionId === "") return undefined; diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 261c36e..a58f6c5 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api"; +import type { SessionUiEvent } from "../sessionSocket"; import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; import { machineSessionKey } from "../machineKeys"; @@ -52,6 +53,28 @@ class FakeSocket implements SessionEventSocket { } } +class EmitSocket implements SessionEventSocket { + readonly connectedSessionIds: string[] = []; + private handler: ((event: SessionUiEvent) => void) | undefined; + + connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void { + this.connectedSessionIds.push(session.id); + this.handler = onEvent; + } + + setHandler(onEvent: (event: SessionUiEvent) => void): void { + this.handler = onEvent; + } + + emit(event: SessionUiEvent): void { + this.handler?.(event); + } + + close(): void { + this.handler = undefined; + } +} + const workspace: Workspace = { id: "workspace-1", projectId: "project-1", @@ -93,11 +116,116 @@ function status(sessionId: string): SessionStatus { }; } +const framesById = new Map void>(); +let nextFrameId = 1; + +// The controller coalesces status/activity/transcript updates behind +// requestAnimationFrame. The node test environment has no rAF, so install a +// controllable one: callbacks are queued and only run when a test drives a +// frame, mirroring how the browser defers them until paint. +beforeEach(() => { + framesById.clear(); + nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { + const id = nextFrameId++; + framesById.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function runPendingAnimationFrames(): void { + const frames = Array.from(framesById.values()); + framesById.clear(); + for (const frame of frames) frame(); +} + describe("SessionController", () => { afterEach(() => { Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true }); }); + it("coalesces rapid status updates into a single state write per frame", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } }); + controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } }); + + // Nothing applies until the frame is flushed; last-write-wins per session. + expect(setStateCalls).toHaveLength(0); + expect(state.sessionStatuses[oldSession.id]).toBeUndefined(); + + runPendingAnimationFrames(); + + expect(setStateCalls).toHaveLength(1); + expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 }); + expect(state.status?.messageCount).toBe(3); + }); + + it("applies the latest activity per session on flush", () => { + const setStateCalls: Partial[] = []; + let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } }); + controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } }); + + expect(setStateCalls).toHaveLength(0); + + controller.flushPendingUpdates(); + + expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" }); + expect(state.activity?.phase).toBe("idle"); + }); + + it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => { + const socket = new EmitSocket(); + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] }; + const api: typeof defaultApi = { + ...defaultApi, + messages: () => Promise.resolve(emptyPage), + status: () => Promise.resolve(status(oldSession.id)), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket }, + ); + await controller.selectSession(oldSession, { updateUrl: false }); + + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } }); + socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } }); + + // Buffered, not applied synchronously. + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined(); + + controller.flushPendingUpdates(); + + expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8); + expect(state.status?.messageCount).toBe(8); + }); + it("clears stale active activity when an idle status arrives", () => { const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" }; let state: AppState = { @@ -116,6 +244,7 @@ describe("SessionController", () => { ); controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) }); + controller.flushPendingUpdates(); expect(state.activity).toBeUndefined(); expect(state.sessionActivities[oldSession.id]).toBeUndefined(); @@ -137,6 +266,7 @@ describe("SessionController", () => { ); controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } }); + controller.flushPendingUpdates(); expect(state.sessions[0]?.messageCount).toBe(3); expect(state.selectedSession?.messageCount).toBe(3); @@ -356,6 +486,7 @@ describe("SessionController", () => { const send = controller.send("hello"); controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } }); + controller.flushPendingUpdates(); resolvePrompt?.(); await send; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index b3576a3..a8b5976 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -34,7 +34,9 @@ export class SessionController { private selectionSeq = 0; private catchupStreamSessionId: string | undefined; private pendingTranscriptEvents: SessionUiEvent[] = []; - private pendingTranscriptFrame: number | undefined; + private pendingStatusBySession = new Map(); + private pendingActivityBySession = new Map(); + private pendingFrame: number | undefined; constructor( private readonly getState: GetState, @@ -49,22 +51,22 @@ export class SessionController { } applyGlobalEvent(event: GlobalSessionEvent): void { - if (event.type === "status.update") this.applyStatus(event.status); - else if (event.type === "activity.update") this.applyActivity(event.activity); + if (event.type === "status.update") this.queueStatusUpdate(event.status); + else if (event.type === "activity.update") this.queueActivityUpdate(event.activity); else if (event.type === "session.created") this.applyCreatedSession(event.session); else this.applySessionName(event.sessionId, event.name); } dispose() { this.socket.close(); - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); } clearActiveSession() { this.selectionSeq += 1; this.socket.close(); this.catchupStreamSessionId = undefined; - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); // Note: sendingPrompts is intentionally NOT cleared here. Deselecting a // session must not cancel the in-flight upload indicator of the session // that is still sending; the per-session entry is cleared by send()'s @@ -113,7 +115,7 @@ export class SessionController { const seq = ++this.selectionSeq; this.socket.close(); this.catchupStreamSessionId = undefined; - this.clearPendingTranscriptEvents(); + this.clearPendingUpdates(); const transcriptKey = this.sessionCacheKey(session.id); const cached = this.transcripts.cachedView(transcriptKey); this.setState({ @@ -563,7 +565,7 @@ export class SessionController { const session = this.getState().selectedSession; if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return; try { - this.flushPendingTranscriptEvents(); + this.flushPendingUpdates(); const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]); if (this.getState().selectedSession?.id !== sessionId) return; const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page); @@ -694,19 +696,29 @@ export class SessionController { if (isTranscriptEvent(event)) return; } + // Status and activity arrive once per token (the server republishes them on + // every transcript event). Buffer them alongside high-frequency transcript + // deltas so the host component renders at most once per animation frame + // instead of once per token. Coalescing these here is what keeps the prompt + // editor's DOM stable during streaming, so in-progress touch gestures (e.g. + // the iOS long-press edit/paste callout) are not interrupted by a re-render. + if (event.type === "status.update") { + this.queueStatusUpdate(event.status); + return; + } + if (event.type === "activity.update") { + this.queueActivityUpdate(event.activity); + return; + } if (isHighFrequencyTranscriptEvent(event)) { this.queueTranscriptEvent(event); return; } - this.flushPendingTranscriptEvents(); + this.flushPendingUpdates(); const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event); if (transcript) { this.setState({ messages: transcript }); - } else if (event.type === "status.update") { - this.applyStatus(event.status); - } else if (event.type === "activity.update") { - this.applyActivity(event.activity); } else if (event.type === "session.name") { this.applySessionName(event.sessionId, event.name); } @@ -714,27 +726,60 @@ export class SessionController { private queueTranscriptEvent(event: SessionUiEvent): void { this.pendingTranscriptEvents.push(event); - if (this.pendingTranscriptFrame !== undefined) return; - this.pendingTranscriptFrame = requestAnimationFrame(() => { - this.pendingTranscriptFrame = undefined; - this.flushPendingTranscriptEvents(); + this.schedulePendingFlush(); + } + + private queueStatusUpdate(status: SessionStatus): void { + this.pendingStatusBySession.set(status.sessionId, status); + this.schedulePendingFlush(); + } + + private queueActivityUpdate(activity: SessionActivity): void { + this.pendingActivityBySession.set(activity.sessionId, activity); + this.schedulePendingFlush(); + } + + private schedulePendingFlush(): void { + if (this.pendingFrame !== undefined) return; + this.pendingFrame = requestAnimationFrame(() => { + this.pendingFrame = undefined; + this.flushPendingUpdates(); }); } - private flushPendingTranscriptEvents(): void { - if (this.pendingTranscriptEvents.length === 0) return; - const events = this.pendingTranscriptEvents; - this.pendingTranscriptEvents = []; - let messages = this.getState().messages; - for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; - if (messages !== this.getState().messages) this.setState({ messages }); + // Apply buffered transcript deltas, activity, and status in one task. Activity + // is applied before status to mirror the server's publish order, so an idle + // status can clear the now-stale active activity it supersedes. Status and + // activity are last-write-wins per session, so iterating the maps applies only + // the latest buffered value per session. These writes run in a single task, so + // Lit batches them into one render. + flushPendingUpdates(): void { + if (this.pendingTranscriptEvents.length > 0) { + const events = this.pendingTranscriptEvents; + this.pendingTranscriptEvents = []; + let messages = this.getState().messages; + for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; + if (messages !== this.getState().messages) this.setState({ messages }); + } + if (this.pendingActivityBySession.size > 0) { + const activities = Array.from(this.pendingActivityBySession.values()); + this.pendingActivityBySession.clear(); + for (const activity of activities) this.applyActivity(activity); + } + if (this.pendingStatusBySession.size > 0) { + const statuses = Array.from(this.pendingStatusBySession.values()); + this.pendingStatusBySession.clear(); + for (const status of statuses) this.applyStatus(status); + } } - private clearPendingTranscriptEvents(): void { + private clearPendingUpdates(): void { this.pendingTranscriptEvents = []; - if (this.pendingTranscriptFrame === undefined) return; - cancelAnimationFrame(this.pendingTranscriptFrame); - this.pendingTranscriptFrame = undefined; + this.pendingStatusBySession.clear(); + this.pendingActivityBySession.clear(); + if (this.pendingFrame === undefined) return; + cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = undefined; } // Stream catch-up is a single mode with two coupled facets that must never diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 5f14055..f4e8ea3 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { inputModeForDraft, isShellInput } from "./inputModes"; +import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes"; describe("inputModeForDraft", () => { it("detects shell input and context-excluded shell input after leading whitespace", () => { @@ -14,6 +14,13 @@ describe("inputModeForDraft", () => { expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" }); }); + it("treats modes as equal only when kind and shell context-exclusion match", () => { + expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true); + expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false); + expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true); + expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false); + }); + it("detects file completion contexts", () => { expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index ed59118..bfeb755 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -19,6 +19,12 @@ export function isShellInput(text: string): boolean { return inputModeForDraft(text).kind === "shell"; } +export function inputModesEqual(a: InputMode, b: InputMode): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "shell" && b.kind === "shell") return a.excludeFromContext === b.excludeFromContext; + return true; +} + function currentToken(draft: string): string { const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1; return draft.slice(tokenStart); From 7063c2c3b12cb568c33bc9d025e3d1f5cec6b9a6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 28 Jun 2026 16:11:18 +0200 Subject: [PATCH 12/35] fix: prevent iOS input zoom --- .changeset/quiet-ios-input-zoom.md | 5 +++++ src/client/index.html | 3 +++ src/client/src/components/MachineDialog.ts | 2 +- src/client/src/components/ProjectDialog.ts | 2 +- src/client/src/components/SessionCleanupDialog.ts | 2 +- src/client/src/components/WorkspaceFilesPanel.ts | 2 +- .../src/components/settings/SettingsGeneralPanel.ts | 4 ++-- .../src/components/settings/SettingsShortcutsPanel.ts | 2 +- src/client/src/components/shared.ts | 10 +++++----- 9 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 .changeset/quiet-ios-input-zoom.md diff --git a/.changeset/quiet-ios-input-zoom.md b/.changeset/quiet-ios-input-zoom.md new file mode 100644 index 0000000..90adbfb --- /dev/null +++ b/.changeset/quiet-ios-input-zoom.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Prevent iOS Safari from zooming into small text inputs across the web UI. diff --git a/src/client/index.html b/src/client/index.html index 59dfe94..578d7db 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -11,6 +11,9 @@ -
Updatesbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
+
Updates${messages.length > 0 ? html`${String(messages.length)}` : null}
${messages.length === 0 ? html`

No PI WEB update or restart messages.

` : messages.map((message) => html` @@ -160,7 +160,7 @@ const plugin: PiWebPlugin = { visible: (context) => shouldShowUpdatesPanel(context.state), badge: (context) => { const count = messageCount(context.state); - return html`beta${count > 0 ? html` · ${String(count)}` : null}`; + return count > 0 ? count : undefined; }, render: (context) => renderUpdatesPanel(html, context.terminal, context.state), }, From ad6285355de32d09de221bb234a4cae34f90459f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 2 Jul 2026 22:17:26 +0200 Subject: [PATCH 35/35] fix: show full tool details in chat --- .changeset/horizontal-tool-targets.md | 5 ++ .../src/components/ToolExecutionView.ts | 65 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 .changeset/horizontal-tool-targets.md diff --git a/.changeset/horizontal-tool-targets.md b/.changeset/horizontal-tool-targets.md new file mode 100644 index 0000000..e57abff --- /dev/null +++ b/.changeset/horizontal-tool-targets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Show full chat tool file paths and commands in horizontally scrollable headers, repeat them in expanded tool details, and keep result output horizontally scrollable. diff --git a/src/client/src/components/ToolExecutionView.ts b/src/client/src/components/ToolExecutionView.ts index e573951..4fd09a2 100644 --- a/src/client/src/components/ToolExecutionView.ts +++ b/src/client/src/components/ToolExecutionView.ts @@ -4,6 +4,11 @@ import type { ToolExecutionPart } from "./shared"; const MAX_COLLAPSED_DIFF_LINES = 180; +interface ToolTarget { + label: "Command" | "File" | "Input"; + text: string; +} + @customElement("tool-execution-view") export class ToolExecutionView extends LitElement { @property({ attribute: false }) execution: ToolExecutionPart | undefined; @@ -15,7 +20,6 @@ export class ToolExecutionView extends LitElement { const execution = this.execution; if (execution === undefined) return null; - const edit = execution.toolName === "edit"; const path = pathFromArgs(execution.args); const actualDiff = diffFromDetails(execution.details); const preview = execution.preview; @@ -24,6 +28,7 @@ export class ToolExecutionView extends LitElement { const previewMismatch = actualDiff !== undefined && preview?.diff !== undefined && actualDiff !== preview.diff; const errorText = execution.status === "error" ? execution.resultText : preview?.error; const bodyText = visibleDiff === undefined ? execution.resultText : undefined; + const target = toolTarget(execution, path); return html`
@@ -31,7 +36,7 @@ export class ToolExecutionView extends LitElement {
${execution.toolName} - ${path === undefined ? html`${execution.summary}` : html`${path}`} + ${this.renderHeaderTarget(target)}
${editCountLabel(execution) === undefined ? null : html`${editCountLabel(execution)}`} @@ -42,23 +47,44 @@ export class ToolExecutionView extends LitElement { ${previewMismatch ? html`

Applied diff differs from the preview.

` : null} ${errorText === undefined || errorText === "" ? null : html`
${errorText}
`} - ${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error") : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff")} - ${!edit && visibleDiff === undefined && (bodyText === undefined || bodyText === "") ? html`

${execution.summary}

` : null} + ${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error", target) : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff", target)}
`; } - private renderTextBody(text: string | undefined, open: boolean) { - if (text === undefined || text === "") return null; + private renderHeaderTarget(target: ToolTarget | undefined) { + if (target === undefined) return null; + const className = target.label === "File" ? "path" : "summary"; + return html`${target.text}`; + } + + private renderExpandedTarget(target: ToolTarget | undefined) { + if (target === undefined) return null; + return html` +
+ ${target.label} +
${target.text}
+
+ `; + } + + private renderTextBody(text: string | undefined, open: boolean, target: ToolTarget | undefined) { + if ((text === undefined || text === "") && target === undefined) return null; return html`
- Result -
${text}
+ Details + ${this.renderExpandedTarget(target)} + ${text === undefined || text === "" ? null : html` +
+ Result +
${text}
+
+ `}
`; } - private renderDiffBody(diff: string, label: string) { + private renderDiffBody(diff: string, label: string, target: ToolTarget | undefined) { const lines = diff.split("\n"); const truncated = !this.showFullDiff && lines.length > MAX_COLLAPSED_DIFF_LINES; const visibleLines = truncated ? lines.slice(0, MAX_COLLAPSED_DIFF_LINES) : lines; @@ -68,6 +94,7 @@ export class ToolExecutionView extends LitElement { ${label} ${String(lines.length)} ${lines.length === 1 ? "line" : "lines"} + ${this.renderExpandedTarget(target)}
${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"} @@ -104,10 +131,10 @@ export class ToolExecutionView extends LitElement { .tool-card.success { border-color: var(--pi-success-border); background: var(--pi-success-bg); } .tool-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); } .tool-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; } - .tool-title { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; } + .tool-title { flex: 1 1 auto; display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; } .status-icon { flex: 0 0 auto; color: var(--pi-muted); } - strong { color: var(--pi-text); } - .path, .summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + strong { flex: 0 0 auto; color: var(--pi-text); } + .path, .summary { display: block; flex: 1 1 auto; min-width: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; white-space: pre; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; } .summary { color: var(--pi-muted); font-family: inherit; } .tool-meta { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 8px; color: var(--pi-muted); font-size: 12px; } .diff-stats { display: inline-flex; gap: 3px; } @@ -118,7 +145,11 @@ export class ToolExecutionView extends LitElement { .muted { margin: 0; color: var(--pi-muted); } .error-text { margin: 0; border: 1px solid var(--pi-danger); border-radius: 7px; background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .text-body { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; } - .text-body pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); } + .detail-target, .detail-result { display: grid; gap: 4px; margin-top: 8px; min-width: 0; } + .detail-label { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; } + .text-body pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); } + .detail-result pre { box-sizing: border-box; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; border: 1px solid var(--pi-border-muted); border-radius: 7px; background: var(--pi-bg); padding: 8px; white-space: pre; overflow-wrap: normal; direction: ltr; text-align: left; unicode-bidi: isolate; } + .detail-target-value { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--pi-accent); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; } .diff-details { min-width: 0; max-width: 100%; border-top: 1px solid var(--pi-border-muted); padding-top: 6px; } .diff-details > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; color: var(--pi-muted); cursor: pointer; } .diff-details > summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -140,6 +171,14 @@ export class ToolExecutionView extends LitElement { `; } +function toolTarget(execution: ToolExecutionPart, path: string | undefined): ToolTarget | undefined { + if (path !== undefined && path !== "") return { label: "File", text: path }; + const command = getString(execution.args, "command"); + if (command !== undefined && command !== "") return { label: "Command", text: command }; + if (execution.summary !== "") return { label: "Input", text: execution.summary }; + return undefined; +} + function pathFromArgs(args: unknown): string | undefined { return getString(args, "path") ?? getString(args, "file_path"); }