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.
pull/17593/head
Shoubhit Dash 2026-03-15 18:00:33 +05:30
parent 060f482eb2
commit 6bfce604bf
6 changed files with 14 additions and 65 deletions

View File

@ -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(() => {

View File

@ -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(() => {

View File

@ -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()
})
})

View File

@ -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"

View File

@ -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({

View File

@ -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(() => {