diff --git a/apps/vscode/src/chatViewProvider.ts b/apps/vscode/src/chatViewProvider.ts index d4844fea4b5..ee6048d4b63 100644 --- a/apps/vscode/src/chatViewProvider.ts +++ b/apps/vscode/src/chatViewProvider.ts @@ -8,7 +8,7 @@ import type { ThreadId, UploadChatAttachment, } from "@t3tools/contracts"; -import { ApprovalRequestId, ProviderInstanceId } from "@t3tools/contracts"; +import { ApprovalRequestId, MessageId, ProviderInstanceId } from "@t3tools/contracts"; import * as vscode from "vscode"; import { @@ -88,6 +88,8 @@ type WebviewRequest = } | { readonly type: "toggleProviderFavorite"; readonly instanceId: string } | { readonly type: "toggleModelFavorite"; readonly modelKey: string } + | { readonly type: "steerQueuedMessage"; readonly messageId: string } + | { readonly type: "removeQueuedMessage"; readonly messageId: string } | { readonly type: "send"; readonly text: string; @@ -206,6 +208,9 @@ function isRequest(value: unknown): value is WebviewRequest { if (type === "toggleModelFavorite") { return "modelKey" in value && typeof value.modelKey === "string"; } + if (type === "steerQueuedMessage" || type === "removeQueuedMessage") { + return "messageId" in value && typeof value.messageId === "string"; + } if (type === "setInteractionMode") { return ( "interactionMode" in value && @@ -452,6 +457,12 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di await this.client.setInteractionMode(message.interactionMode); }); return; + case "steerQueuedMessage": + await this.#run(() => this.client.steerQueuedMessage(MessageId.make(message.messageId))); + return; + case "removeQueuedMessage": + await this.#run(() => this.client.removeQueuedMessage(MessageId.make(message.messageId))); + return; case "send": { await this.#run(async () => { await this.actions.ensureConnected(); @@ -712,6 +723,12 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di createdAt: activeProposedPlan.createdAt, } : null, + queuedMessages: thread.queuedMessages.map((message) => ({ + messageId: message.messageId, + text: message.text, + attachmentCount: message.attachments.length, + queuedAt: message.queuedAt, + })), proposedPlans: proposedPlans.map((plan) => ({ id: plan.id, planMarkdown: plan.planMarkdown, @@ -894,6 +911,13 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di .streaming::after { content: '▋'; animation: blink 1s steps(1) infinite; } @keyframes blink { 50% { opacity: 0; } } #pending-interactions:empty { display: none; } + #queued-messages:empty { display: none; } + #queued-messages { display: flex; flex-direction: column; gap: 5px; margin-bottom: 7px; } + .queued-message { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 6px; border: 1px solid var(--vscode-editorWidget-border); border-radius: 7px; padding: 5px 6px 5px 8px; background: color-mix(in srgb, var(--vscode-editorWidget-background) 72%, var(--vscode-sideBar-background)); } + .queued-message-icon { color: var(--vscode-descriptionForeground); } + .queued-message-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } + .queued-message button { border: 0; padding: 3px 6px; background: transparent; color: var(--vscode-descriptionForeground); font-size: 10px; } + .queued-message button:hover:not(:disabled) { color: var(--vscode-foreground); background: color-mix(in srgb, var(--vscode-descriptionForeground) 10%, transparent); } #plan-ready:empty { display: none; } #plan-ready { margin-bottom: 8px; border: 1px solid color-mix(in srgb, var(--vscode-charts-purple, #a970ff) 45%, var(--vscode-editorWidget-border)); border-radius: 7px; padding: 8px 10px; background: color-mix(in srgb, var(--vscode-charts-purple, #a970ff) 12%, var(--vscode-sideBar-background)); } .plan-ready-badge { display: inline-block; margin-right: 8px; border-radius: 4px; padding: 1px 6px; background: color-mix(in srgb, var(--vscode-charts-purple, #a970ff) 28%, transparent); color: var(--vscode-foreground); font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } @@ -1031,6 +1055,7 @@ export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Di
+
diff --git a/apps/vscode/src/t3Client.ts b/apps/vscode/src/t3Client.ts index 428e081fcd8..07e5a1a4b36 100644 --- a/apps/vscode/src/t3Client.ts +++ b/apps/vscode/src/t3Client.ts @@ -14,6 +14,7 @@ import { OrchestrationThreadDetailSnapshot, type OrchestrationThread, type OrchestrationThreadShell, + type MessageId, type RuntimeMode, type ProviderInteractionMode, type ApprovalRequestId, @@ -439,6 +440,30 @@ export class T3Client { }); } + async steerQueuedMessage(messageId: MessageId): Promise { + const thread = this.#activeThread; + if (thread === null) throw new Error("Select a T3 Code thread before steering its queue."); + await this.#dispatch({ + type: "thread.queue.steer", + commandId: newCommandId(), + threadId: thread.id, + messageId, + createdAt: new Date().toISOString(), + }); + } + + async removeQueuedMessage(messageId: MessageId): Promise { + const thread = this.#activeThread; + if (thread === null) throw new Error("Select a T3 Code thread before changing its queue."); + await this.#dispatch({ + type: "thread.queue.remove", + commandId: newCommandId(), + threadId: thread.id, + messageId, + createdAt: new Date().toISOString(), + }); + } + async setInteractionMode(interactionMode: ProviderInteractionMode): Promise { const thread = this.#activeThread; if (thread === null) diff --git a/apps/vscode/src/webview.ts b/apps/vscode/src/webview.ts index b3fe12d8c0c..b96a16d8a40 100644 --- a/apps/vscode/src/webview.ts +++ b/apps/vscode/src/webview.ts @@ -68,6 +68,13 @@ interface ViewActiveProposedPlan { readonly createdAt: string; } +interface ViewQueuedMessage { + readonly messageId: string; + readonly text: string; + readonly attachmentCount: number; + readonly queuedAt: string; +} + interface ViewMessage { readonly id: string; readonly role: "user" | "assistant" | "system"; @@ -150,6 +157,7 @@ interface ViewState { readonly pendingInteractions: ReadonlyArray; readonly showPlanFollowUp: boolean; readonly activeProposedPlan: ViewActiveProposedPlan | null; + readonly queuedMessages: ReadonlyArray; readonly proposedPlans: ReadonlyArray; readonly tasks: null | { readonly explanation: string | null; @@ -208,6 +216,7 @@ const threadActions = requiredElement("thread-actions"); const archiveThread = requiredElement("archive-thread"); const visitT3 = requiredElement("visit-t3"); const status = requiredElement("status"); +const queuedMessages = requiredElement("queued-messages"); const prompt = requiredElement("prompt"); const slashCommands = requiredElement("slash-commands"); const send = requiredElement("send"); @@ -838,6 +847,7 @@ function activityRevision(thread: ViewState["activeThread"]): string { lastResolvedUserInput: thread.resolvedUserInputs.at(-1) ?? null, proposedPlanCount: thread.proposedPlans.length, lastProposedPlan: thread.proposedPlans.at(-1) ?? null, + queuedMessages: thread.queuedMessages, }); } @@ -1077,6 +1087,49 @@ function render(next: ViewState): void { visitT3.disabled = !next.connected || next.activeThread === null; syncPromptDisabled(next.busy); renderComposerAction(); + renderQueuedMessages(next); +} + +function renderQueuedMessages(state: ViewState): void { + queuedMessages.replaceChildren(); + for (const message of state.activeThread?.queuedMessages ?? []) { + const row = document.createElement("div"); + row.className = "queued-message"; + row.title = message.text; + + const icon = document.createElement("span"); + icon.className = "queued-message-icon"; + icon.textContent = "↳"; + + const text = document.createElement("span"); + text.className = "queued-message-text"; + text.textContent = + message.text.trim() || + `${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}`; + + const steer = document.createElement("button"); + steer.type = "button"; + steer.textContent = "Steer"; + steer.title = "Send now, interrupting the current step"; + steer.ariaLabel = "Steer queued message"; + steer.disabled = state.busy; + steer.addEventListener("click", () => + post({ type: "steerQueuedMessage", messageId: message.messageId }), + ); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "×"; + remove.title = "Remove queued message"; + remove.ariaLabel = "Remove queued message"; + remove.disabled = state.busy; + remove.addEventListener("click", () => + post({ type: "removeQueuedMessage", messageId: message.messageId }), + ); + + row.append(icon, text, steer, remove); + queuedMessages.append(row); + } } /** @@ -1366,8 +1419,9 @@ function renderComposerAction(): void { send.title = prompt.value.trim().length > 0 ? "Send plan feedback" : "Implement the proposed plan"; } else { - send.textContent = "Send"; - send.title = "Send message"; + send.textContent = isRunning() && hasComposerInput() ? "Queue" : "Send"; + send.title = + isRunning() && hasComposerInput() ? "Queue message after the active turn" : "Send message"; } send.classList.toggle("stop-action", stopping); prompt.placeholder = planFollowUp