diff --git a/apps/vscode/src/chatViewProvider.ts b/apps/vscode/src/chatViewProvider.ts index 133d503e0e6..a1d51e8220c 100644 --- a/apps/vscode/src/chatViewProvider.ts +++ b/apps/vscode/src/chatViewProvider.ts @@ -1,5 +1,5 @@ /* oxlint-disable unicorn/require-post-message-target-origin -- VS Code Webview.postMessage is not Window.postMessage. */ -// @effect-diagnostics globalTimers:off +// @effect-diagnostics globalTimers:off globalDate:off cryptoRandomUUID:off import type { ModelSelection, OrchestrationThread, @@ -19,6 +19,8 @@ import { import { resolveChangeRequestIndicator } from "@t3tools/shared/sourceControl"; import { composePrompt, type TextContext } from "./editorContext.ts"; import { derivePendingInteractions } from "./pendingInteractions.ts"; +import { fitPromptStashImages } from "./promptStashModel.ts"; +import { PromptStashStore, type PromptStashEntry } from "./promptStashStore.ts"; import type { T3Client } from "./t3Client.ts"; import { resolveThreadDisplayStatus } from "./threadStatus.ts"; import { presentTasks } from "./taskPresentation.ts"; @@ -97,7 +99,22 @@ type WebviewRequest = readonly images: ReadonlyArray; readonly interactionMode?: "default" | "plan"; } - | { readonly type: "setInteractionMode"; readonly interactionMode: "default" | "plan" }; + | { readonly type: "setInteractionMode"; readonly interactionMode: "default" | "plan" } + | { + readonly type: "stashPrompt"; + readonly prompt: string; + readonly images: ReadonlyArray; + readonly providerInstanceId: string | null; + readonly modelSelection: ModelSelection | null; + readonly interactionMode: "default" | "plan"; + } + | { readonly type: "listPromptStash"; readonly providerInstanceId: string | null } + | { readonly type: "restorePromptStash"; readonly id: string } + | { + readonly type: "removePromptStash"; + readonly id: string; + readonly providerInstanceId: string | null; + }; function hasImageArray(value: object): boolean { return ( @@ -226,6 +243,32 @@ function isRequest(value: unknown): value is WebviewRequest { (value.interactionMode === "default" || value.interactionMode === "plan") ); } + if (type === "listPromptStash") { + return ( + "providerInstanceId" in value && + (value.providerInstanceId === null || typeof value.providerInstanceId === "string") + ); + } + if (type === "restorePromptStash") return "id" in value && typeof value.id === "string"; + if (type === "removePromptStash") { + return ( + "id" in value && + typeof value.id === "string" && + "providerInstanceId" in value && + (value.providerInstanceId === null || typeof value.providerInstanceId === "string") + ); + } + if (type === "stashPrompt") { + return ( + "prompt" in value && + typeof value.prompt === "string" && + hasImageArray(value) && + "providerInstanceId" in value && + (value.providerInstanceId === null || typeof value.providerInstanceId === "string") && + "interactionMode" in value && + (value.interactionMode === "default" || value.interactionMode === "plan") + ); + } if (type === "send") { if (!("text" in value) || typeof value.text !== "string" || !hasImageArray(value)) { return false; @@ -258,11 +301,18 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di readonly #disposables: vscode.Disposable[]; readonly #attachmentUrls = new Map(); readonly #attachmentLoads = new Set(); + readonly #promptStash: PromptStashStore; - constructor(client: T3Client, actions: ChatViewActions, extensionUri: vscode.Uri) { + constructor( + client: T3Client, + actions: ChatViewActions, + extensionUri: vscode.Uri, + storageUri: vscode.Uri | undefined, + ) { this.client = client; this.actions = actions; this.extensionUri = extensionUri; + this.#promptStash = new PromptStashStore(storageUri); this.#disposables = [ client.onShellChanged(() => this.#publish()), client.onThreadChanged(() => this.#publish()), @@ -351,6 +401,35 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di await this.openInT3(); return; } + case "listPromptStash": + await this.#publishPromptStash(message.providerInstanceId); + return; + case "stashPrompt": { + const entry: PromptStashEntry = { + id: globalThis.crypto.randomUUID(), + createdAt: new Date().toISOString(), + prompt: message.prompt, + ...fitPromptStashImages(message.images), + providerInstanceId: message.providerInstanceId, + modelSelection: message.modelSelection, + interactionMode: message.interactionMode, + }; + const entries = await this.#promptStash.add(entry); + await this.#view?.webview.postMessage({ type: "promptStash", entries, stashed: true }); + return; + } + case "restorePromptStash": { + const entry = await this.#promptStash.take(message.id); + if (entry !== null) { + await this.#view?.webview.postMessage({ type: "restorePromptStash", entry }); + await this.#publishPromptStash(entry.providerInstanceId); + } + return; + } + case "removePromptStash": + await this.#promptStash.remove(message.id); + await this.#publishPromptStash(message.providerInstanceId); + return; case "archiveThread": { const thread = this.client.activeThread; if (thread === null) return; @@ -553,6 +632,11 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di } } + async #publishPromptStash(providerInstanceId: string | null): Promise { + const entries = await this.#promptStash.list(providerInstanceId); + await this.#view?.webview.postMessage({ type: "promptStash", entries }); + } + async #refresh(): Promise { await this.#run(async () => { await this.actions.ensureConnected(); @@ -1036,6 +1120,18 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di #usage-toggle.warning { --usage-primary-color: var(--vscode-charts-orange); } #usage-toggle.critical { --usage-primary-color: var(--vscode-charts-red); } .composer-actions select { border: 0; padding: 3px 18px 3px 0; color: var(--vscode-descriptionForeground); background-color: transparent; font-size: 11px; text-overflow: ellipsis; } + .stash-control { position: relative; } + .stash-control > summary { list-style: none; cursor: pointer; padding: 3px 6px; color: var(--vscode-descriptionForeground); } + .stash-control > summary::-webkit-details-marker { display: none; } + .stash-control[open] > summary, .stash-control > summary:hover { color: var(--vscode-foreground); background: var(--vscode-toolbar-hoverBackground); border-radius: 4px; } + .stash-popup { position: absolute; right: 0; bottom: calc(100% + 6px); z-index: 20; width: min(330px, 82vw); max-height: 260px; overflow-y: auto; padding: 6px; border: 1px solid var(--vscode-editorWidget-border); border-radius: 7px; background: var(--vscode-editorWidget-background); box-shadow: 0 4px 14px var(--vscode-widget-shadow); } + .stash-empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: 11px; } + .stash-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; padding: 5px; border-radius: 4px; } + .stash-entry:hover { background: var(--vscode-list-hoverBackground); } + .stash-restore { min-width: 0; border: 0; padding: 2px; background: transparent; color: var(--vscode-foreground); text-align: left; } + .stash-preview { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .stash-meta { display: block; color: var(--vscode-descriptionForeground); font-size: 10px; } + .stash-remove { border: 0; background: transparent; color: var(--vscode-descriptionForeground); } #provider { max-width: 9rem; font-weight: 600; } #model { max-width: 14rem; } #provider:disabled, #model:disabled { opacity: .72; } @@ -1074,7 +1170,7 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di
-
+
0
Open to load stashed prompts.
diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 8b2e72b6f51..6e0232723ac 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -321,6 +321,7 @@ export function activate(context: vscode.ExtensionContext): void { onFavoritesChanged: desktopFavorites.onDidChange, }, context.extensionUri, + context.storageUri, ); const handler: vscode.ChatRequestHandler = async (request, _chatContext, response, token) => { diff --git a/apps/vscode/src/promptStashModel.ts b/apps/vscode/src/promptStashModel.ts new file mode 100644 index 00000000000..0edd5e03c4c --- /dev/null +++ b/apps/vscode/src/promptStashModel.ts @@ -0,0 +1,49 @@ +import type { ModelSelection, UploadChatAttachment } from "@t3tools/contracts"; + +export interface PromptStashEntry { + readonly id: string; + readonly createdAt: string; + readonly prompt: string; + readonly images: ReadonlyArray; + readonly droppedImageNames: ReadonlyArray; + readonly providerInstanceId: string | null; + readonly modelSelection: ModelSelection | null; + readonly interactionMode: "default" | "plan"; +} + +const MAX_ENTRIES_PER_PROVIDER = 20; +const MAX_TOTAL_ENTRIES = 100; +const MAX_ENTRY_IMAGE_CHARS = 2_700_000; + +export function fitPromptStashImages( + images: ReadonlyArray, +): Pick { + let used = 0; + const kept: UploadChatAttachment[] = []; + const droppedImageNames: string[] = []; + for (const image of images) { + if (used + image.dataUrl.length > MAX_ENTRY_IMAGE_CHARS) { + droppedImageNames.push(image.name); + continue; + } + used += image.dataUrl.length; + kept.push(image); + } + return { images: kept, droppedImageNames }; +} + +export function addPromptStashEntry( + entries: ReadonlyArray, + entry: PromptStashEntry, +): ReadonlyArray { + let scopeEntries = 0; + return [entry, ...entries] + .filter((candidate) => { + if (candidate.providerInstanceId !== entry.providerInstanceId) return true; + scopeEntries += 1; + return scopeEntries <= MAX_ENTRIES_PER_PROVIDER; + }) + .slice(0, MAX_TOTAL_ENTRIES); +} + +export const MAX_PERSISTED_PROMPT_STASH_ENTRIES = MAX_TOTAL_ENTRIES; diff --git a/apps/vscode/src/promptStashStore.test.ts b/apps/vscode/src/promptStashStore.test.ts new file mode 100644 index 00000000000..1abbd8b0c21 --- /dev/null +++ b/apps/vscode/src/promptStashStore.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + addPromptStashEntry, + fitPromptStashImages, + type PromptStashEntry, +} from "./promptStashModel.ts"; + +function entry(id: string, providerInstanceId: string): PromptStashEntry { + return { + id, + createdAt: "2026-07-28T00:00:00.000Z", + prompt: id, + images: [], + droppedImageNames: [], + providerInstanceId, + modelSelection: null, + interactionMode: "default", + }; +} + +describe("addPromptStashEntry", () => { + it("caps each provider queue independently", () => { + const existing = [ + ...Array.from({ length: 20 }, (_, index) => entry(`a-${index}`, "a")), + entry("b-0", "b"), + ]; + + const next = addPromptStashEntry(existing, entry("a-new", "a")); + + expect(next.filter((candidate) => candidate.providerInstanceId === "a")).toHaveLength(20); + expect(next[0]?.id).toBe("a-new"); + expect(next.some((candidate) => candidate.id === "a-19")).toBe(false); + expect(next.some((candidate) => candidate.id === "b-0")).toBe(true); + }); + + it("bounds attachment data stored for an entry", () => { + const image = (name: string, chars: number) => ({ + type: "image" as const, + name, + mimeType: "image/png", + sizeBytes: chars, + dataUrl: "x".repeat(chars), + }); + + const result = fitPromptStashImages([ + image("first.png", 2_000_000), + image("second.png", 800_000), + ]); + + expect(result.images.map(({ name }) => name)).toEqual(["first.png"]); + expect(result.droppedImageNames).toEqual(["second.png"]); + }); +}); diff --git a/apps/vscode/src/promptStashStore.ts b/apps/vscode/src/promptStashStore.ts new file mode 100644 index 00000000000..9ea7614e6d2 --- /dev/null +++ b/apps/vscode/src/promptStashStore.ts @@ -0,0 +1,110 @@ +import * as vscode from "vscode"; + +import { + addPromptStashEntry, + MAX_PERSISTED_PROMPT_STASH_ENTRIES, + type PromptStashEntry, +} from "./promptStashModel.ts"; +export type { PromptStashEntry } from "./promptStashModel.ts"; + +interface PersistedPromptStash { + readonly version: 1; + readonly entries: ReadonlyArray; +} + +const FILE_NAME = "prompt-stash-v1.json"; + +function isEntry(value: unknown): value is PromptStashEntry { + if (typeof value !== "object" || value === null) return false; + const entry = value as Partial; + return ( + typeof entry.id === "string" && + typeof entry.createdAt === "string" && + typeof entry.prompt === "string" && + Array.isArray(entry.images) && + Array.isArray(entry.droppedImageNames) && + (entry.providerInstanceId === null || typeof entry.providerInstanceId === "string") && + (entry.modelSelection === null || + (typeof entry.modelSelection === "object" && entry.modelSelection !== null)) && + (entry.interactionMode === "default" || entry.interactionMode === "plan") + ); +} + +export class PromptStashStore { + readonly #storageUri: vscode.Uri | undefined; + #entries: ReadonlyArray | null = null; + #write: Promise = Promise.resolve(); + + constructor(storageUri: vscode.Uri | undefined) { + this.#storageUri = storageUri; + } + + async list(providerInstanceId: string | null): Promise> { + const entries = await this.#load(); + return entries.filter((entry) => entry.providerInstanceId === providerInstanceId); + } + + async add(entry: PromptStashEntry): Promise> { + const entries = await this.#load(); + this.#entries = addPromptStashEntry(entries, entry); + await this.#persist(); + return this.list(entry.providerInstanceId); + } + + async take(id: string): Promise { + const entries = await this.#load(); + const entry = entries.find((candidate) => candidate.id === id) ?? null; + if (entry === null) return null; + this.#entries = entries.filter((candidate) => candidate.id !== id); + await this.#persist(); + return entry; + } + + async remove(id: string): Promise { + const entries = await this.#load(); + const next = entries.filter((candidate) => candidate.id !== id); + if (next.length === entries.length) return; + this.#entries = next; + await this.#persist(); + } + + async #load(): Promise> { + if (this.#entries !== null) return this.#entries; + if (this.#storageUri === undefined) return (this.#entries = []); + try { + const bytes = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(this.#storageUri, FILE_NAME), + ); + const decoded: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if ( + typeof decoded === "object" && + decoded !== null && + "version" in decoded && + decoded.version === 1 && + "entries" in decoded && + Array.isArray(decoded.entries) + ) { + return (this.#entries = decoded.entries + .filter(isEntry) + .slice(0, MAX_PERSISTED_PROMPT_STASH_ENTRIES)); + } + } catch (cause) { + if (!(cause instanceof vscode.FileSystemError && cause.code === "FileNotFound")) throw cause; + } + return (this.#entries = []); + } + + async #persist(): Promise { + if (this.#storageUri === undefined) return; + const payload: PersistedPromptStash = { version: 1, entries: this.#entries ?? [] }; + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + this.#write = this.#write.then(async () => { + await vscode.workspace.fs.createDirectory(this.#storageUri!); + const target = vscode.Uri.joinPath(this.#storageUri!, FILE_NAME); + const temporary = vscode.Uri.joinPath(this.#storageUri!, `${FILE_NAME}.tmp`); + await vscode.workspace.fs.writeFile(temporary, bytes); + await vscode.workspace.fs.rename(temporary, target, { overwrite: true }); + }); + await this.#write; + } +} diff --git a/apps/vscode/src/webview.ts b/apps/vscode/src/webview.ts index 247f9e417b0..564d4653aed 100644 --- a/apps/vscode/src/webview.ts +++ b/apps/vscode/src/webview.ts @@ -223,6 +223,10 @@ const slashCommands = requiredElement("slash-commands"); const send = requiredElement("send"); const pendingAttachments = requiredElement("pending-attachments"); const pendingInteractions = requiredElement("pending-interactions"); +const stashControl = requiredElement("stash-control"); +const stashNow = requiredElement("stash-now"); +const stashPopup = requiredElement("stash-popup"); +const stashCount = requiredElement("stash-count"); const planReady = requiredElement("plan-ready"); const interactionModeSelect = requiredElement("interaction-mode"); const contextButton = requiredElement("context"); @@ -272,6 +276,23 @@ let inputHistoryScopeKey = "__none__"; let inputHistory: ComposerInputHistoryState = EMPTY_COMPOSER_INPUT_HISTORY; let editingQueuedMessage: { readonly messageId: string; readonly previousDraft: string } | null = null; +interface PromptStashEntry { + readonly id: string; + readonly createdAt: string; + readonly prompt: string; + readonly images: ReadonlyArray>; + readonly droppedImageNames: ReadonlyArray; + readonly providerInstanceId: string | null; + readonly modelSelection: null | { + readonly instanceId: string; + readonly model: string; + readonly options?: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }>; + }; + readonly interactionMode: "default" | "plan"; +} +let promptStash: ReadonlyArray = []; +let pendingStash: { readonly prompt: string; readonly imageKeys: ReadonlyArray } | null = + null; function restoreDraftAfterQueuedEdit(): void { if (editingQueuedMessage === null) return; @@ -367,6 +388,73 @@ function post(message: unknown): void { vscode.postMessage(message); } +function stashProviderInstanceId(): string | null { + return currentState === null ? null : (currentSelection(currentState)?.instanceId ?? null); +} + +function requestPromptStash(): void { + post({ type: "listPromptStash", providerInstanceId: stashProviderInstanceId() }); +} + +function renderPromptStash(): void { + stashCount.textContent = String(promptStash.length); + stashPopup.replaceChildren(); + if (promptStash.length === 0) { + const empty = document.createElement("div"); + empty.className = "stash-empty"; + empty.textContent = "No stashed prompts for this provider."; + stashPopup.append(empty); + return; + } + for (const entry of promptStash) { + const row = document.createElement("div"); + row.className = "stash-entry"; + const restore = document.createElement("button"); + restore.className = "stash-restore"; + restore.title = "Restore and remove from stash"; + const preview = document.createElement("span"); + preview.className = "stash-preview"; + preview.textContent = entry.prompt.trim() || `${entry.images.length} image(s)`; + const meta = document.createElement("span"); + meta.className = "stash-meta"; + const dropped = + entry.droppedImageNames.length === 0 + ? "" + : ` · ${entry.droppedImageNames.length} image(s) omitted`; + meta.textContent = `${new Date(entry.createdAt).toLocaleString()}${dropped}`; + restore.append(preview, meta); + restore.addEventListener("click", () => post({ type: "restorePromptStash", id: entry.id })); + const remove = document.createElement("button"); + remove.className = "stash-remove"; + remove.textContent = "×"; + remove.title = "Delete stashed prompt"; + remove.addEventListener("click", () => + post({ + type: "removePromptStash", + id: entry.id, + providerInstanceId: stashProviderInstanceId(), + }), + ); + row.append(restore, remove); + stashPopup.append(row); + } +} + +function stashCurrentPrompt(): void { + if (editingQueuedMessage !== null || !hasComposerInput() || currentState === null) return; + const selection = currentSelection(currentState); + const images = uploadImages(); + pendingStash = { prompt: prompt.value, imageKeys: pendingImages.map((image) => image.key) }; + post({ + type: "stashPrompt", + prompt: prompt.value, + images, + providerInstanceId: selection?.instanceId ?? null, + modelSelection: selection, + interactionMode: composerInteractionMode, + }); +} + function emptyMessage(text: string): HTMLElement { const element = document.createElement("div"); element.className = "empty"; @@ -1436,6 +1524,7 @@ function showPlanFollowUp(): boolean { function renderComposerAction(): void { const planFollowUp = showPlanFollowUp(); const stopping = isRunning() && !hasComposerInput() && !planFollowUp; + stashNow.disabled = editingQueuedMessage !== null || !hasComposerInput(); if (stopping) { send.textContent = "Stop"; send.title = "Stop active turn"; @@ -1844,6 +1933,46 @@ window.addEventListener("message", (event: MessageEvent) => { if (event.data.type === "hostResources" && "snapshot" in event.data) { renderHostResources(event.data.snapshot as ServerHostResourceSnapshot | null); } + if (event.data.type === "promptStash" && "entries" in event.data) { + promptStash = event.data.entries as ReadonlyArray; + renderPromptStash(); + if ("stashed" in event.data && event.data.stashed === true && pendingStash !== null) { + if (prompt.value === pendingStash.prompt) prompt.value = ""; + const stashedKeys = new Set(pendingStash.imageKeys); + pendingImages = pendingImages.filter((image) => !stashedKeys.has(image.key)); + pendingStash = null; + renderPendingImages(); + renderComposerAction(); + } + } + if (event.data.type === "restorePromptStash" && "entry" in event.data) { + const entry = event.data.entry as PromptStashEntry; + prompt.value = entry.prompt; + pendingImages = entry.images.map((image, index) => ({ + ...image, + key: `stash:${entry.id}:${index}`, + })); + setComposerInteractionMode(entry.interactionMode, false); + if (entry.modelSelection !== null && currentState !== null) { + const options = [...(entry.modelSelection.options ?? [])]; + if (draftSelection !== null) { + draftSelection = { ...entry.modelSelection, options }; + render(currentState); + } else { + post({ + type: "selectModel", + instanceId: entry.modelSelection.instanceId, + model: entry.modelSelection.model, + options, + }); + } + } + renderPendingImages(); + renderComposerAction(); + stashControl.open = false; + prompt.focus(); + prompt.setSelectionRange(prompt.value.length, prompt.value.length); + } if (event.data.type === "sent") { prompt.value = ""; clearPendingImages(); @@ -1878,7 +2007,12 @@ provider.addEventListener("change", () => { if (firstModel === undefined) return; draftSelection = { instanceId: firstModel.instanceId, model: firstModel.model, options: [] }; render(currentState); + requestPromptStash(); }); +stashControl.addEventListener("toggle", () => { + if (stashControl.open) requestPromptStash(); +}); +stashNow.addEventListener("click", stashCurrentPrompt); model.addEventListener("change", () => { if (model.value === "" || currentState === null) return; const selection = currentSelection(currentState); @@ -1959,6 +2093,15 @@ document.addEventListener("pointerdown", (event) => { closeComposerPopovers(); }); document.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + if (hasComposerInput()) stashCurrentPrompt(); + else { + stashControl.open = true; + requestPromptStash(); + } + return; + } if (event.key === "Escape" && (usageExpanded || tasksExpanded)) { closeComposerPopovers(); event.preventDefault();