diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 7fdde0d49a..8e3e8973f3 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -55,6 +55,7 @@ type Data = { family: Record status: Record message: Record + input: Record permission: Record // Pending forms keyed by session ID. form: Record @@ -90,6 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ family: {}, status: {}, message: {}, + input: {}, permission: {}, form: {}, }, @@ -276,10 +278,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const position = index.get(event.data.inputID) if (position === undefined) return const existing = draft[position] - if (existing?.type === "user" && existing.metadata?.queued === true) { + if (existing?.type === "user" && store.session.input[event.data.sessionID]?.includes(event.data.inputID)) { existing.time.created = event.created - delete existing.metadata.queued - if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined draft.splice(position, 1) draft.push(existing) index.clear() @@ -287,9 +287,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return } }) + setStore( + "session", + "input", + event.data.sessionID, + (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), + ) break } case "session.prompt.admitted": + if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) + setStore("session", "input", event.data.sessionID, [ + ...(store.session.input[event.data.sessionID] ?? []), + event.data.inputID, + ]) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: event.data.inputID, @@ -297,7 +308,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, - metadata: { queued: true }, time: { created: event.created }, }) }) @@ -559,6 +569,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.revert.committed": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "revert", undefined) + setStore( + "session", + "input", + event.data.sessionID, + (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.messageID), + ) message.update(event.data.sessionID, (draft, index) => { const position = draft.findIndex((item) => item.id >= event.data.messageID) if (position === -1) return @@ -686,6 +702,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status(sessionID: string) { return store.session.status[sessionID] ?? "idle" }, + input: { + list(sessionID: string) { + return store.session.input[sessionID] ?? [] + }, + has(sessionID: string, inputID: string) { + return store.session.input[sessionID]?.includes(inputID) ?? false + }, + }, async refresh(sessionID: string) { setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 43103c3d84..3eb2724bda 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, selectedForeground, useTheme } from "../../context/theme" +import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { @@ -1384,9 +1384,9 @@ function UserMessage(props: { message: SessionMessageUser }) { const { theme } = useTheme() const [hover, setHover] = createSignal(false) 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 queued = createMemo( + () => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id), + ) const dialog = useDialog() const renderer = useRenderer() @@ -1395,7 +1395,7 @@ function UserMessage(props: { message: SessionMessageUser }) { {props.message.text} - + {(file) => { const label = file.mime === "application/x-directory" ? "Directory" : file.mime return ( - - {` ${label} `} - + {` ${label} `} {" "} {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} @@ -1436,18 +1440,9 @@ function UserMessage(props: { message: SessionMessageUser }) { - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - } - > + - QUEUED + {Locale.todayTimeOrDateTime(props.message.time.created)} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index c0d44c3d80..ff8e2d5155 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -27,8 +27,9 @@ export function createSessionRows(sessionID: Accessor) { function reduce() { const messages = data.session.message.list(sessionID()) + const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages) + const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) partitionPending(rows, pendingPermissions()) return rows } @@ -77,7 +78,13 @@ export function createSessionRows(sessionID: Accessor) { .list(sessionID()) .flatMap((message) => message.type === "user" - ? [{ id: message.id, created: message.time.created, queued: message.metadata?.queued === true }] + ? [ + { + id: message.id, + created: message.time.created, + input: data.session.input.has(sessionID(), message.id), + }, + ] : [], ), () => setRows(reconcile(reduce())), @@ -132,8 +139,7 @@ export function createSessionRows(sessionID: Accessor) { ) const isQueued = (messageID: string) => { - const message = data.session.message.get(sessionID(), messageID) - return message?.type === "user" && message.metadata?.queued === true + return data.session.input.has(sessionID(), messageID) } const queuedStart = (rows: SessionRow[]) => { @@ -191,30 +197,28 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessage[]) { - return [...messages.filter((message) => !isQueuedMessage(message)), ...messages.filter(isQueuedMessage)].reduce< - SessionRow[] - >((rows, message) => { - if (message.type !== "assistant") { - if (message.type === "synthetic" && !message.description?.trim()) return rows - if (!isQueuedMessage(message)) completePrevious(rows) - rows.push({ type: "message", messageID: message.id }) +export function reduceSessionRows(messages: SessionMessage[], inputs = new Set()) { + const isInput = (message: SessionMessage) => inputs.has(message.id) + return [...messages.filter((message) => !isInput(message)), ...messages.filter(isInput)].reduce( + (rows, message) => { + if (message.type !== "assistant") { + if (message.type === "synthetic" && !message.description?.trim()) return rows + if (!isInput(message)) completePrevious(rows) + rows.push({ type: "message", messageID: message.id }) + return rows + } + message.content.forEach((part) => { + if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return + append(rows, { messageID: message.id, partID: part.id }, part) + }) + if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) { + completePrevious(rows) + rows.push({ type: "assistant-footer", messageID: message.id }) + } return rows - } - message.content.forEach((part) => { - if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return - append(rows, { messageID: message.id, partID: part.id }, part) - }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) { - completePrevious(rows) - rows.push({ type: "assistant-footer", messageID: message.id }) - } - return rows - }, []) -} - -function isQueuedMessage(message: SessionMessage) { - return message.type === "user" && message.metadata?.queued === true + }, + [], + ) } function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b9beb0e536..6bd413b39d 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -403,8 +403,7 @@ test("connectedOnce is false until first connect and persists across disconnect" test("tracks session status from active sessions and execution events", async () => { const events = createEventStream() const calls = createFetch((url) => { - if (url.pathname === "/api/session/active") - return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) }, events) let data!: ReturnType @@ -1082,7 +1081,7 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts immediately with queued marker and clears when promoted", async () => { +test("renders admitted prompts immediately and tracks them until promoted", async () => { const events = createEventStream() const sessionID = "session-1" const messageID = "msg_user_1" @@ -1135,10 +1134,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr }) 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 } }) + expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" }) + expect(admitted?.metadata).toBeUndefined() + expect(sync.session.input.list(sessionID)).toEqual([messageID]) await sync.session.message.refresh(sessionID) - expect(sync.session.message.list(sessionID)?.[0]?.metadata?.queued).toBeUndefined() + expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() emitEvent(events, { id: "evt_prompted_1", @@ -1158,7 +1159,8 @@ test("renders admitted prompts immediately with queued marker and clears when pr expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: messageID, text: "hello" }) - expect(message.metadata?.queued).toBeUndefined() + expect(message.metadata).toBeUndefined() + expect(sync.session.input.list(sessionID)).toEqual([]) expect(sync.session.message.ids(sessionID)).toEqual([messageID]) expect(sync.session.message.ids("missing")).toEqual([]) expect(sync.session.message.get(sessionID, messageID)).toBe(message)