fix(tui): track pending session inputs
This commit is contained in:
@@ -55,6 +55,7 @@ type Data = {
|
|||||||
family: Record<string, string[]>
|
family: Record<string, string[]>
|
||||||
status: Record<string, DataSessionStatus>
|
status: Record<string, DataSessionStatus>
|
||||||
message: Record<string, SessionMessage[]>
|
message: Record<string, SessionMessage[]>
|
||||||
|
input: Record<string, string[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
// Pending forms keyed by session ID.
|
// Pending forms keyed by session ID.
|
||||||
form: Record<string, FormInfo[]>
|
form: Record<string, FormInfo[]>
|
||||||
@@ -90,6 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
family: {},
|
family: {},
|
||||||
status: {},
|
status: {},
|
||||||
message: {},
|
message: {},
|
||||||
|
input: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
form: {},
|
form: {},
|
||||||
},
|
},
|
||||||
@@ -276,10 +278,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const position = index.get(event.data.inputID)
|
const position = index.get(event.data.inputID)
|
||||||
if (position === undefined) return
|
if (position === undefined) return
|
||||||
const existing = draft[position]
|
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
|
existing.time.created = event.created
|
||||||
delete existing.metadata.queued
|
|
||||||
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
|
|
||||||
draft.splice(position, 1)
|
draft.splice(position, 1)
|
||||||
draft.push(existing)
|
draft.push(existing)
|
||||||
index.clear()
|
index.clear()
|
||||||
@@ -287,9 +287,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"input",
|
||||||
|
event.data.sessionID,
|
||||||
|
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.prompt.admitted":
|
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.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(draft, index, {
|
message.append(draft, index, {
|
||||||
id: event.data.inputID,
|
id: event.data.inputID,
|
||||||
@@ -297,7 +308,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
text: event.data.prompt.text,
|
text: event.data.prompt.text,
|
||||||
files: event.data.prompt.files,
|
files: event.data.prompt.files,
|
||||||
agents: event.data.prompt.agents,
|
agents: event.data.prompt.agents,
|
||||||
metadata: { queued: true },
|
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -559,6 +569,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
case "session.revert.committed":
|
case "session.revert.committed":
|
||||||
if (store.session.info[event.data.sessionID])
|
if (store.session.info[event.data.sessionID])
|
||||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
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) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = draft.findIndex((item) => item.id >= event.data.messageID)
|
const position = draft.findIndex((item) => item.id >= event.data.messageID)
|
||||||
if (position === -1) return
|
if (position === -1) return
|
||||||
@@ -686,6 +702,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
status(sessionID: string) {
|
status(sessionID: string) {
|
||||||
return store.session.status[sessionID] ?? "idle"
|
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) {
|
async refresh(sessionID: string) {
|
||||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||||
registerSession(sessionID)
|
registerSession(sessionID)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useData } from "../../context/data"
|
|||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||||
import { Spinner } from "../../component/spinner"
|
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 { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||||
import type {
|
import type {
|
||||||
@@ -1384,9 +1384,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const color = createMemo(() => local.agent.color(data.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 queued = createMemo(
|
||||||
const queuedFg = createMemo(() => selectedForeground(theme, color()))
|
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
||||||
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
|
)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
@@ -1395,7 +1395,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
<box
|
<box
|
||||||
id={props.message.id}
|
id={props.message.id}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={color()}
|
borderColor={queued() ? theme.textMuted : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
>
|
>
|
||||||
<box
|
<box
|
||||||
@@ -1417,15 +1417,19 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
>
|
>
|
||||||
<text fg={theme.text}>{props.message.text}</text>
|
<text fg={theme.text}>{props.message.text}</text>
|
||||||
<Show when={files().length}>
|
<Show when={files().length}>
|
||||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
paddingBottom={ctx.showTimestamps() ? 1 : 0}
|
||||||
|
paddingTop={1}
|
||||||
|
gap={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const label = file.mime === "application/x-directory" ? "Directory" : file.mime
|
const label = file.mime === "application/x-directory" ? "Directory" : file.mime
|
||||||
return (
|
return (
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<span style={{ bg: theme.secondary, fg: theme.background }}>
|
<span style={{ bg: theme.secondary, fg: theme.background }}>{` ${label} `}</span>
|
||||||
{` ${label} `}
|
|
||||||
</span>
|
|
||||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
|
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
|
||||||
{" "}
|
{" "}
|
||||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||||
@@ -1436,18 +1440,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
</For>
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show when={ctx.showTimestamps()}>
|
||||||
when={queued()}
|
|
||||||
fallback={
|
|
||||||
<Show when={ctx.showTimestamps()}>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<text fg={theme.textMuted}>
|
<text fg={theme.textMuted}>
|
||||||
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
|
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
|
|
||||||
function reduce() {
|
function reduce() {
|
||||||
const messages = data.session.message.list(sessionID())
|
const messages = data.session.message.list(sessionID())
|
||||||
|
const inputs = new Set(data.session.input.list(sessionID()))
|
||||||
const boundary = revertBoundary()
|
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())
|
partitionPending(rows, pendingPermissions())
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,13 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
.list(sessionID())
|
.list(sessionID())
|
||||||
.flatMap((message) =>
|
.flatMap((message) =>
|
||||||
message.type === "user"
|
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())),
|
() => setRows(reconcile(reduce())),
|
||||||
@@ -132,8 +139,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isQueued = (messageID: string) => {
|
const isQueued = (messageID: string) => {
|
||||||
const message = data.session.message.get(sessionID(), messageID)
|
return data.session.input.has(sessionID(), messageID)
|
||||||
return message?.type === "user" && message.metadata?.queued === true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queuedStart = (rows: SessionRow[]) => {
|
const queuedStart = (rows: SessionRow[]) => {
|
||||||
@@ -191,30 +197,28 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reduceSessionRows(messages: SessionMessage[]) {
|
export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<string>()) {
|
||||||
return [...messages.filter((message) => !isQueuedMessage(message)), ...messages.filter(isQueuedMessage)].reduce<
|
const isInput = (message: SessionMessage) => inputs.has(message.id)
|
||||||
SessionRow[]
|
return [...messages.filter((message) => !isInput(message)), ...messages.filter(isInput)].reduce<SessionRow[]>(
|
||||||
>((rows, message) => {
|
(rows, message) => {
|
||||||
if (message.type !== "assistant") {
|
if (message.type !== "assistant") {
|
||||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||||
if (!isQueuedMessage(message)) completePrevious(rows)
|
if (!isInput(message)) completePrevious(rows)
|
||||||
rows.push({ type: "message", messageID: message.id })
|
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
|
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]) {
|
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
||||||
|
|||||||
@@ -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 () => {
|
test("tracks session status from active sessions and execution events", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname === "/api/session/active")
|
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
||||||
return json({ data: { "session-active": { type: "running" } } })
|
|
||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
@@ -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 events = createEventStream()
|
||||||
const sessionID = "session-1"
|
const sessionID = "session-1"
|
||||||
const messageID = "msg_user_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)
|
await wait(() => sync.session.message.list(sessionID)?.length === 1)
|
||||||
const admitted = sync.session.message.list(sessionID)?.[0]
|
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)
|
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, {
|
emitEvent(events, {
|
||||||
id: "evt_prompted_1",
|
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")
|
expect(message?.type).toBe("user")
|
||||||
if (message?.type !== "user") return
|
if (message?.type !== "user") return
|
||||||
expect(message).toMatchObject({ id: messageID, text: "hello" })
|
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(sessionID)).toEqual([messageID])
|
||||||
expect(sync.session.message.ids("missing")).toEqual([])
|
expect(sync.session.message.ids("missing")).toEqual([])
|
||||||
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
|
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
|
||||||
|
|||||||
Reference in New Issue
Block a user