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()