feat(tui): show retry countdown (#47145)

Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-04 11:24:04 -05:00
committed by GitHub
Unverified
parent a26a978051
commit bff58fc387
2 changed files with 105 additions and 1 deletions
+13 -1
View File
@@ -2522,12 +2522,24 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
const [seconds, setSeconds] = createSignal(0)
createEffect(() => {
const at = props.retry?.at
if (at === undefined) return
const update = () => setSeconds(Math.max(0, Math.ceil((at - Date.now()) / 1_000)))
if (update() === 0) return
const timer = setInterval(() => {
if (update() === 0) clearInterval(timer)
}, 1_000)
onCleanup(() => clearInterval(timer))
})
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3}>
<text fg={theme.text.feedback.warning.default}>
⚠ Retry attempt {retry().attempt} scheduled: {retry().error.message}
⚠ {seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "}
{retry().error.message}
</text>
</box>
)}
+92
View File
@@ -1529,3 +1529,95 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
test.each([44, 100])(
"retry countdown updates and clears with the retry lifecycle at width %s",
async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_countdown",
projectID: "proj_test",
location: { directory },
title: "Retry countdown",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
const model = { id: "model", providerID: "provider" }
const error = { type: "provider.transport" as const, message: "Provider unavailable" }
await using setup = await createAppFixture({
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_countdown",
type: "assistant",
agent: "build",
model,
content: [],
error,
retry: { attempt: 2, at: Date.now() + 2_500, error },
time: { created: 1 },
},
],
cursor: {},
})
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Retrying in 3s"))
expect(setup.captureCharFrame()).toContain("attempt 2")
expect(setup.captureCharFrame()).toContain("Provider unavailable")
expect(setup.captureCharFrame()).not.toContain("Error:")
await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 })
expect(setup.captureCharFrame()).not.toContain("in 0s")
setup.events.emit({
id: "evt_countdown_rescheduled",
created: 2,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error },
})
await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3"))
setup.events.emit({
id: "evt_countdown_started",
created: 3,
type: "session.step.started",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
setup.events.emit({
id: "evt_countdown_expired",
created: 4,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 3, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error },
})
await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4"))
expect(setup.captureCharFrame()).not.toContain("in -")
setup.events.emit({
id: "evt_countdown_interrupted",
created: 5,
type: "session.execution.interrupted",
durable: { aggregateID: session.id, seq: 4, version: 1 },
data: { sessionID: session.id, reason: "shutdown" },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
},
15_000,
)