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