From b5823d10778f0f18834f9881be83c2a533c706a1 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 1 Jul 2026 21:27:56 +0530 Subject: [PATCH] fix(tui): show queued prompt admissions (#34771) --- packages/tui/src/context/data.tsx | 36 ++++++++++++++++++-- packages/tui/src/routes/session/index.tsx | 23 ++++++++++--- packages/tui/src/routes/session/rows.ts | 1 + packages/tui/test/cli/tui/data.test.tsx | 40 +++++++++++++++-------- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index bd444ea1ef..1589e93f92 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -194,6 +194,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.next.prompted": { setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { + const position = index.get(event.data.messageID) + const existing = position === undefined ? undefined : draft[position] + if (existing?.type === "user") { + existing.text = event.data.prompt.text + existing.files = event.data.prompt.files + existing.agents = event.data.prompt.agents + existing.time.created = event.data.timestamp + if (existing.metadata?.queued === true) { + delete existing.metadata.queued + if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined + } + return + } message.append(draft, index, { id: event.data.messageID, type: "user", @@ -206,6 +219,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break } case "session.next.prompt.admitted": + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { + id: event.data.messageID, + type: "user", + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + metadata: { queued: true }, + time: { created: event.data.timestamp }, + }) + }) break case "session.next.context.updated": message.update(event.data.sessionID, (draft, index) => { @@ -590,15 +614,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return position === undefined ? undefined : messages?.[position] }, async refresh(sessionID: string) { + const live = [...(store.session.message[sessionID] ?? [])] setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) const loaded = mutable( (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, ).toReversed() - const live = store.session.message[sessionID] ?? [] + const loadedIDs = new Set(loaded.map((message) => message.id)) const liveByID = new Map(live.map((message) => [message.id, message])) - const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live] - .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) + const messages = [ + ...loaded.map((message) => { + if (message.type === "user") return message + return liveByID.get(message.id) ?? message + }), + ...live.filter((message) => !loadedIDs.has(message.id)), + ] .toSorted((a, b) => a.time.created - b.time.created) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, messages) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f1f3e18283..04b28465e7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -22,7 +22,7 @@ import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" -import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme" +import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { @@ -1319,11 +1319,15 @@ function RevertMessage(props: { function UserMessage(props: { message: SessionMessageUser }) { const ctx = use() + const data = useData() const local = useLocal() const files = createMemo(() => props.message.files ?? []) const { theme } = useTheme() const [hover, setHover] = createSignal(false) - const color = createMemo(() => local.agent.color(useData().session.get(ctx.sessionID)?.agent ?? "build")) + const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) + const queued = createMemo(() => props.message.metadata?.queued === true) + const queuedFg = createMemo(() => selectedForeground(theme, color())) + const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) const dialog = useDialog() const renderer = useRenderer() @@ -1356,7 +1360,7 @@ function UserMessage(props: { message: SessionMessageUser }) { - + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + } + > - {Locale.todayTimeOrDateTime(props.message.time.created)} + QUEUED diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 3a15d6bc14..3b21eec0cf 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -116,6 +116,7 @@ export function createSessionRows(sessionID: Accessor) { if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID) } const subscriptions = [ + data.on("session.next.prompt.admitted", message), data.on("session.next.prompted", message), data.on("session.next.context.updated", message), data.on("session.next.synthetic", message), diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b7893d8abd..efeab233f3 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -778,9 +778,17 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts only after they become model-visible", async () => { +test("renders admitted prompts immediately with queued marker and clears when promoted", async () => { const events = createEventStream() - const calls = createFetch(undefined, events) + const sessionID = "session-1" + const messageID = "msg_user_1" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) + return json({ + data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }], + cursor: {}, + }) + }, events) let sync!: ReturnType let ready!: () => void const mounted = new Promise((resolve) => { @@ -813,38 +821,44 @@ test("renders admitted prompts only after they become model-visible", async () = id: "evt_admitted_1", type: "session.next.prompt.admitted", data: { - sessionID: "session-1", - messageID: "msg_user_1", + sessionID, + messageID, timestamp: 0, prompt: { text: "hello" }, delivery: "steer", }, }) - expect(sync.session.message.list("session-1") ?? []).toEqual([]) + await wait(() => sync.session.message.list(sessionID)?.length === 1) + const admitted = sync.session.message.list(sessionID)?.[0] + expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello", metadata: { queued: true } }) + + await sync.session.message.refresh(sessionID) + expect(sync.session.message.list(sessionID)?.[0]?.metadata?.queued).toBeUndefined() emitEvent(events, { id: "evt_prompted_1", type: "session.next.prompted", data: { - sessionID: "session-1", - messageID: "msg_user_1", + sessionID, + messageID, timestamp: 0, prompt: { text: "hello" }, delivery: "steer", }, }) - await wait(() => sync.session.message.list("session-1")?.length === 1) + await wait(() => received.at(-1) === "session.next.prompted") expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"]) unsubscribe() - const message = sync.session.message.list("session-1")?.[0] + const message = sync.session.message.list(sessionID)?.[0] expect(message?.type).toBe("user") if (message?.type !== "user") return - expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) - expect(sync.session.message.ids("session-1")).toEqual(["msg_user_1"]) + expect(message).toMatchObject({ id: messageID, text: "hello" }) + expect(message.metadata?.queued).toBeUndefined() + expect(sync.session.message.ids(sessionID)).toEqual([messageID]) expect(sync.session.message.ids("missing")).toEqual([]) - expect(sync.session.message.get("session-1", "msg_user_1")).toBe(message) - expect(sync.session.message.get("session-1", "missing")).toBeUndefined() + expect(sync.session.message.get(sessionID, messageID)).toBe(message) + expect(sync.session.message.get(sessionID, "missing")).toBeUndefined() expect(received).toHaveLength(3) } finally { app.renderer.destroy()