From 6bfce604bf80dd801fe498274ecc93b9b3b3acec Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 15 Mar 2026 18:00:33 +0530 Subject: [PATCH] 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(() => {