From 060f482eb27a178ebf26a00470d23c60a29ca206 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 15 Mar 2026 17:39:44 +0530 Subject: [PATCH 1/3] fix(app): clear stale running session indicators Use live session status for the running UI and finalize assistant messages when prompt setup fails so crashed runs do not leave sessions stuck as active. --- .../app/src/pages/layout/sidebar-items.tsx | 14 +- packages/app/src/pages/session.tsx | 6 +- .../app/src/pages/session/activity.test.ts | 46 +++++ packages/app/src/pages/session/activity.ts | 10 ++ .../src/pages/session/message-timeline.tsx | 28 ++- packages/opencode/src/session/assistant.ts | 7 + packages/opencode/src/session/processor.ts | 3 +- packages/opencode/src/session/prompt.ts | 164 ++++++++++-------- .../opencode/test/session/assistant.test.ts | 44 +++++ packages/ui/src/components/session-turn.tsx | 6 +- 10 files changed, 214 insertions(+), 114 deletions(-) create mode 100644 packages/app/src/pages/session/activity.test.ts create mode 100644 packages/app/src/pages/session/activity.ts create mode 100644 packages/opencode/src/session/assistant.ts create mode 100644 packages/opencode/test/session/assistant.test.ts diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index f8e16f3e12..b4b1e5f8a2 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -15,6 +15,7 @@ import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" +import { working } from "@/pages/session/activity" import { messageAgentColor } from "@/utils/agent" import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { hasProjectPermissions } from "./helpers" @@ -204,18 +205,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false - const pending = (sessionStore.message[props.session.id] ?? []).findLast( - (message) => - message.role === "assistant" && - typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number", - ) - const status = sessionStore.session_status[props.session.id] - return ( - pending !== undefined || - status?.type === "busy" || - status?.type === "retry" || - (status !== undefined && status.type !== "idle") - ) + return working(sessionStore.session_status[props.session.id]) }) const tint = createMemo(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081..84e46b96d1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -39,6 +39,7 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" +import { working } from "@/pages/session/activity" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" @@ -1361,10 +1362,7 @@ export default function Page() { }) const busy = (sessionID: string) => { - if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true - return (sync.data.message[sessionID] ?? []).some( - (item) => item.role === "assistant" && typeof item.time.completed !== "number", - ) + return working(sync.data.session_status[sessionID]) } const queuedFollowups = createMemo(() => { diff --git a/packages/app/src/pages/session/activity.test.ts b/packages/app/src/pages/session/activity.test.ts new file mode 100644 index 0000000000..3c9bb11d7a --- /dev/null +++ b/packages/app/src/pages/session/activity.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import type { AssistantMessage, Message as MessageType, UserMessage } from "@opencode-ai/sdk/v2" +import { pending, working } from "./activity" + +const user = (id: string) => + ({ + id, + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + }) as UserMessage + +const assistant = (id: string, parentID: string, completed?: number) => + ({ + id, + sessionID: "ses_1", + parentID, + role: "assistant", + time: completed === undefined ? { created: 2 } : { created: 2, completed }, + }) as AssistantMessage + +describe("session activity", () => { + test("treats only non-idle status as running", () => { + expect(working(undefined)).toBe(false) + expect(working({ type: "idle" })).toBe(false) + expect(working({ type: "busy" })).toBe(true) + expect(working({ type: "retry", attempt: 1, message: "retry", next: 1 })).toBe(true) + }) + + test("returns the trailing incomplete assistant", () => { + const messages: MessageType[] = [user("msg_1"), assistant("msg_2", "msg_1")] + + expect(pending(messages)?.id).toBe("msg_2") + }) + + test("ignores older incomplete assistants once a later assistant completed", () => { + const messages: MessageType[] = [ + user("msg_1"), + assistant("msg_2", "msg_1"), + user("msg_3"), + assistant("msg_4", "msg_3", 4), + ] + + expect(pending(messages)).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/activity.ts b/packages/app/src/pages/session/activity.ts new file mode 100644 index 0000000000..3f66309f78 --- /dev/null +++ b/packages/app/src/pages/session/activity.ts @@ -0,0 +1,10 @@ +import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" + +export const pending = (messages: readonly MessageType[]) => { + const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant") + if (!item || typeof item.time.completed === "number") return + return item +} + +export const working = (status: SessionStatus | undefined) => status !== undefined && status.type !== "idle" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 74f2e8c2c1..7784215ea1 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -12,7 +12,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" -import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import type { Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" @@ -27,6 +27,7 @@ import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { pending, working } from "@/pages/session/activity" import { messageAgentColor } from "@/utils/agent" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" @@ -236,17 +237,13 @@ export function MessageTimeline(props: { if (!id) return emptyMessages return sync.data.message[id] ?? emptyMessages }) - const pending = createMemo(() => - sessionMessages().findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ), - ) + const assistant = createMemo(() => pending(sessionMessages())) const sessionStatus = createMemo(() => { const id = sessionID() if (!id) return idle return sync.data.session_status[id] ?? idle }) - const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") + const busy = createMemo(() => working(sessionStatus())) const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const [slot, setSlot] = createStore({ @@ -264,7 +261,7 @@ export function MessageTimeline(props: { onCleanup(clear) createEffect( on( - working, + busy, (on, prev) => { clear() if (on) { @@ -282,7 +279,9 @@ export function MessageTimeline(props: { ), ) const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID + if (!busy()) return undefined + + const parentID = assistant()?.parentID if (parentID) { const messages = sessionMessages() const result = Binary.search(messages, parentID, (message) => message.id) @@ -290,15 +289,10 @@ export function MessageTimeline(props: { if (message && message.role === "user") return message.id } - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } + const messages = sessionMessages() + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id } - - return undefined }) const info = createMemo(() => { const id = sessionID() diff --git a/packages/opencode/src/session/assistant.ts b/packages/opencode/src/session/assistant.ts new file mode 100644 index 0000000000..586ec6a9b3 --- /dev/null +++ b/packages/opencode/src/session/assistant.ts @@ -0,0 +1,7 @@ +import { MessageV2 } from "./message-v2" + +export const done = (msg: MessageV2.Assistant, time = Date.now()) => { + if (typeof msg.time.completed === "number") return msg + msg.time.completed = time + return msg +} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 38dac41b05..387e96974e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -14,6 +14,7 @@ import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" +import { done } from "./assistant" import { PartID } from "./schema" import type { SessionID, MessageID } from "./schema" @@ -416,7 +417,7 @@ export namespace SessionProcessor { }) } } - input.assistantMessage.time.completed = Date.now() + done(input.assistantMessage) await Session.updateMessage(input.assistantMessage) if (needsCompaction) return "compact" if (blocked) return "stop" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 743537f598..d000793db0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import z from "zod" import { Filesystem } from "../util/filesystem" import { SessionID, MessageID, PartID } from "./schema" +import { done } from "./assistant" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" import { SessionRevert } from "./revert" @@ -465,7 +466,7 @@ export namespace SessionPrompt { result, ) assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() + done(assistantMessage) await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { await Session.updatePart({ @@ -599,90 +600,99 @@ export namespace SessionPrompt { }) using _ = defer(() => InstructionPrompt.clear(processor.message.id)) - // Check if user explicitly invoked an agent via @ in this turn - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") - const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false + const format = lastUser.format ?? { type: "text" } + const result = await (async () => { + const lastUserMsg = msgs.findLast((m) => m.info.role === "user") + const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - const tools = await resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor, - bypassAgentCheck, - messages: msgs, - }) - - // Inject StructuredOutput tool if JSON schema mode enabled - if (lastUser.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser.format.schema, - onSuccess(output) { - structuredOutput = output - }, + const tools = await resolveTools({ + agent, + session, + model, + tools: lastUser.tools, + processor, + bypassAgentCheck, + messages: msgs, }) - } - if (step === 1) { - SessionSummary.summarize({ - sessionID: sessionID, - messageID: lastUser.id, - }) - } + if (format.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: format.schema, + onSuccess(output) { + structuredOutput = output + }, + }) + } - // Ephemerally wrap queued user messages with a reminder to stay on track - if (step > 1 && lastFinished) { - for (const msg of msgs) { - if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue - for (const part of msg.parts) { - if (part.type !== "text" || part.ignored || part.synthetic) continue - if (!part.text.trim()) continue - part.text = [ - "", - "The user sent the following message:", - part.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") + if (step === 1) { + SessionSummary.summarize({ + sessionID: sessionID, + messageID: lastUser.id, + }) + } + + if (step > 1 && lastFinished) { + for (const msg of msgs) { + if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue + for (const part of msg.parts) { + if (part.type !== "text" || part.ignored || part.synthetic) continue + if (!part.text.trim()) continue + part.text = [ + "", + "The user sent the following message:", + part.text, + "", + "Please address this message and continue with your tasks.", + "", + ].join("\n") + } } } - } - await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - // Build system prompt, adding structured output instruction if needed - const skills = await SystemPrompt.skills(agent) - const system = [ - ...(await SystemPrompt.environment(model)), - ...(skills ? [skills] : []), - ...(await InstructionPrompt.system()), - ] - const format = lastUser.format ?? { type: "text" } - if (format.type === "json_schema") { - system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - } + const skills = await SystemPrompt.skills(agent) + const system = [ + ...(await SystemPrompt.environment(model)), + ...(skills ? [skills] : []), + ...(await InstructionPrompt.system()), + ] + if (format.type === "json_schema") { + system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + } - const result = await processor.process({ - user: lastUser, - agent, - abort, - sessionID, - system, - messages: [ - ...MessageV2.toModelMessages(msgs, model), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ], - tools, - model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + return processor.process({ + user: lastUser, + agent, + abort, + sessionID, + system, + messages: [ + ...MessageV2.toModelMessages(msgs, model), + ...(isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), + ], + tools, + model, + toolChoice: format.type === "json_schema" ? "required" : undefined, + }) + })().catch(async (err) => { + if (typeof processor.message.time.completed !== "number") { + await Session.updateMessage(done(processor.message)).catch((cause) => { + log.error("failed to finalize assistant after prompt error", { + cause, + messageID: processor.message.id, + sessionID, + }) + }) + } + throw err }) // If structured output was captured, save it and exit immediately @@ -1697,7 +1707,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } - msg.time.completed = Date.now() + done(msg) await Session.updateMessage(msg) if (part.state.status === "running") { part.state = { diff --git a/packages/opencode/test/session/assistant.test.ts b/packages/opencode/test/session/assistant.test.ts new file mode 100644 index 0000000000..caa21db132 --- /dev/null +++ b/packages/opencode/test/session/assistant.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { done } from "../../src/session/assistant" +import type { MessageV2 } from "../../src/session/message-v2" +import { SessionID } from "../../src/session/schema" + +const sessionID = SessionID.make("session") +const providerID = ProviderID.make("test") +const modelID = ModelID.make("model") + +const assistant = (completed?: number) => + ({ + id: "msg_1", + sessionID, + parentID: "msg_0", + role: "assistant", + time: completed === undefined ? { created: 1 } : { created: 1, completed }, + mode: "build", + agent: "build", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID, + providerID, + }) as MessageV2.Assistant + +describe("session assistant", () => { + test("marks incomplete assistants as done", () => { + const msg = assistant() + + expect(done(msg, 10).time.completed).toBe(10) + }) + + test("preserves existing completion time", () => { + const msg = assistant(5) + + expect(done(msg, 10).time.completed).toBe(5) + }) +}) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8c9c1ffe40..7ba9e84d58 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -195,9 +195,9 @@ export function SessionTurn( const pending = createMemo(() => { if (typeof props.active === "boolean") return const messages = allMessages() ?? emptyMessages - return messages.findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ) + const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant") + if (!item || typeof item.time.completed === "number") return + return item }) const pendingUser = createMemo(() => { From 6bfce604bf80dd801fe498274ecc93b9b3b3acec Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 15 Mar 2026 18:00:33 +0530 Subject: [PATCH 2/3] fix(session): ignore stale pending ui state Inline the app-side running checks and make the tui only treat the trailing assistant as pending so old crashed messages do not keep sessions active or queued. --- .../app/src/pages/layout/sidebar-items.tsx | 4 +- packages/app/src/pages/session.tsx | 4 +- .../app/src/pages/session/activity.test.ts | 46 ------------------- packages/app/src/pages/session/activity.ts | 10 ---- .../src/pages/session/message-timeline.tsx | 11 +++-- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- 6 files changed, 14 insertions(+), 65 deletions(-) delete mode 100644 packages/app/src/pages/session/activity.test.ts delete mode 100644 packages/app/src/pages/session/activity.ts diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index b4b1e5f8a2..3c37b3f65e 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -15,7 +15,6 @@ import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { working } from "@/pages/session/activity" import { messageAgentColor } from "@/utils/agent" import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { hasProjectPermissions } from "./helpers" @@ -205,7 +204,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false - return working(sessionStore.session_status[props.session.id]) + const status = sessionStore.session_status[props.session.id] + return status !== undefined && status.type !== "idle" }) const tint = createMemo(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 84e46b96d1..842116be55 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -39,7 +39,6 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" -import { working } from "@/pages/session/activity" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" @@ -1362,7 +1361,8 @@ export default function Page() { }) const busy = (sessionID: string) => { - return working(sync.data.session_status[sessionID]) + const status = sync.data.session_status[sessionID] + return status !== undefined && status.type !== "idle" } const queuedFollowups = createMemo(() => { diff --git a/packages/app/src/pages/session/activity.test.ts b/packages/app/src/pages/session/activity.test.ts deleted file mode 100644 index 3c9bb11d7a..0000000000 --- a/packages/app/src/pages/session/activity.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { AssistantMessage, Message as MessageType, UserMessage } from "@opencode-ai/sdk/v2" -import { pending, working } from "./activity" - -const user = (id: string) => - ({ - id, - sessionID: "ses_1", - role: "user", - time: { created: 1 }, - }) as UserMessage - -const assistant = (id: string, parentID: string, completed?: number) => - ({ - id, - sessionID: "ses_1", - parentID, - role: "assistant", - time: completed === undefined ? { created: 2 } : { created: 2, completed }, - }) as AssistantMessage - -describe("session activity", () => { - test("treats only non-idle status as running", () => { - expect(working(undefined)).toBe(false) - expect(working({ type: "idle" })).toBe(false) - expect(working({ type: "busy" })).toBe(true) - expect(working({ type: "retry", attempt: 1, message: "retry", next: 1 })).toBe(true) - }) - - test("returns the trailing incomplete assistant", () => { - const messages: MessageType[] = [user("msg_1"), assistant("msg_2", "msg_1")] - - expect(pending(messages)?.id).toBe("msg_2") - }) - - test("ignores older incomplete assistants once a later assistant completed", () => { - const messages: MessageType[] = [ - user("msg_1"), - assistant("msg_2", "msg_1"), - user("msg_3"), - assistant("msg_4", "msg_3", 4), - ] - - expect(pending(messages)).toBeUndefined() - }) -}) diff --git a/packages/app/src/pages/session/activity.ts b/packages/app/src/pages/session/activity.ts deleted file mode 100644 index 3f66309f78..0000000000 --- a/packages/app/src/pages/session/activity.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2" -import type { SessionStatus } from "@opencode-ai/sdk/v2/client" - -export const pending = (messages: readonly MessageType[]) => { - const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant") - if (!item || typeof item.time.completed === "number") return - return item -} - -export const working = (status: SessionStatus | undefined) => status !== undefined && status.type !== "idle" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 7784215ea1..de4dd84166 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -12,7 +12,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" -import type { Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" @@ -27,7 +27,6 @@ import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { pending, working } from "@/pages/session/activity" import { messageAgentColor } from "@/utils/agent" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" @@ -237,13 +236,17 @@ export function MessageTimeline(props: { if (!id) return emptyMessages return sync.data.message[id] ?? emptyMessages }) - const assistant = createMemo(() => pending(sessionMessages())) + const assistant = createMemo(() => { + const item = sessionMessages().findLast((item): item is AssistantMessage => item.role === "assistant") + if (!item || typeof item.time.completed === "number") return + return item + }) const sessionStatus = createMemo(() => { const id = sessionID() if (!id) return idle return sync.data.session_status[id] ?? idle }) - const busy = createMemo(() => working(sessionStatus())) + const busy = createMemo(() => sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const [slot, setSlot] = createStore({ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7456742cdf..6d3a429de0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -139,7 +139,9 @@ export function Session() { }) const pending = createMemo(() => { - return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id + const last = messages().findLast((x) => x.role === "assistant") + if (!last || last.time.completed) return + return last.id }) const lastAssistant = createMemo(() => { From 5a7a86810a9b330a3e2ea093797e9e6872e6b667 Mon Sep 17 00:00:00 2001 From: ismeth Date: Sun, 15 Mar 2026 22:33:42 +0100 Subject: [PATCH 3/3] fix(app): use reconcile for session_status in bootstrap to clear stale entries (cherry picked from commit 912ba2c57fb1879891b93e239eec2374c6efe277) --- packages/app/src/context/global-sync/bootstrap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..65aaa450c2 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -151,7 +151,7 @@ export async function bootstrapDirectory(input: { Promise.all([ input.sdk.path.get().then((x) => input.setStore("path", x.data!)), input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), + input.sdk.session.status().then((x) => input.setStore("session_status", reconcile(x.data!))), input.loadSessions(input.directory), input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),