Skip to content

Commit 060f482

Browse files
committed
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.
1 parent 2fc06c5 commit 060f482

10 files changed

Lines changed: 214 additions & 114 deletions

File tree

‎packages/app/src/pages/layout/sidebar-items.tsx‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useLanguage } from "@/context/language"
1515
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
1616
import { useNotification } from "@/context/notification"
1717
import { usePermission } from "@/context/permission"
18+
import { working } from "@/pages/session/activity"
1819
import { messageAgentColor } from "@/utils/agent"
1920
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
2021
import { hasProjectPermissions } from "./helpers"
@@ -204,18 +205,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
204205
})
205206
const isWorking = createMemo(() => {
206207
if (hasPermissions()) return false
207-
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
208-
(message) =>
209-
message.role === "assistant" &&
210-
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
211-
)
212-
const status = sessionStore.session_status[props.session.id]
213-
return (
214-
pending !== undefined ||
215-
status?.type === "busy" ||
216-
status?.type === "retry" ||
217-
(status !== undefined && status.type !== "idle")
218-
)
208+
return working(sessionStore.session_status[props.session.id])
219209
})
220210

221211
const tint = createMemo(() => {

‎packages/app/src/pages/session.tsx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { useSettings } from "@/context/settings"
3939
import { useSync } from "@/context/sync"
4040
import { useTerminal } from "@/context/terminal"
4141
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
42+
import { working } from "@/pages/session/activity"
4243
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
4344
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
4445
import { MessageTimeline } from "@/pages/session/message-timeline"
@@ -1361,10 +1362,7 @@ export default function Page() {
13611362
})
13621363

13631364
const busy = (sessionID: string) => {
1364-
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
1365-
return (sync.data.message[sessionID] ?? []).some(
1366-
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
1367-
)
1365+
return working(sync.data.session_status[sessionID])
13681366
}
13691367

13701368
const queuedFollowups = createMemo(() => {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { AssistantMessage, Message as MessageType, UserMessage } from "@opencode-ai/sdk/v2"
3+
import { pending, working } from "./activity"
4+
5+
const user = (id: string) =>
6+
({
7+
id,
8+
sessionID: "ses_1",
9+
role: "user",
10+
time: { created: 1 },
11+
}) as UserMessage
12+
13+
const assistant = (id: string, parentID: string, completed?: number) =>
14+
({
15+
id,
16+
sessionID: "ses_1",
17+
parentID,
18+
role: "assistant",
19+
time: completed === undefined ? { created: 2 } : { created: 2, completed },
20+
}) as AssistantMessage
21+
22+
describe("session activity", () => {
23+
test("treats only non-idle status as running", () => {
24+
expect(working(undefined)).toBe(false)
25+
expect(working({ type: "idle" })).toBe(false)
26+
expect(working({ type: "busy" })).toBe(true)
27+
expect(working({ type: "retry", attempt: 1, message: "retry", next: 1 })).toBe(true)
28+
})
29+
30+
test("returns the trailing incomplete assistant", () => {
31+
const messages: MessageType[] = [user("msg_1"), assistant("msg_2", "msg_1")]
32+
33+
expect(pending(messages)?.id).toBe("msg_2")
34+
})
35+
36+
test("ignores older incomplete assistants once a later assistant completed", () => {
37+
const messages: MessageType[] = [
38+
user("msg_1"),
39+
assistant("msg_2", "msg_1"),
40+
user("msg_3"),
41+
assistant("msg_4", "msg_3", 4),
42+
]
43+
44+
expect(pending(messages)).toBeUndefined()
45+
})
46+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2"
2+
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
3+
4+
export const pending = (messages: readonly MessageType[]) => {
5+
const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant")
6+
if (!item || typeof item.time.completed === "number") return
7+
return item
8+
}
9+
10+
export const working = (status: SessionStatus | undefined) => status !== undefined && status.type !== "idle"

‎packages/app/src/pages/session/message-timeline.tsx‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
1212
import { SessionTurn } from "@opencode-ai/ui/session-turn"
1313
import { ScrollView } from "@opencode-ai/ui/scroll-view"
1414
import { TextField } from "@opencode-ai/ui/text-field"
15-
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
15+
import type { Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
1616
import { showToast } from "@opencode-ai/ui/toast"
1717
import { Binary } from "@opencode-ai/util/binary"
1818
import { getFilename } from "@opencode-ai/util/path"
@@ -27,6 +27,7 @@ import { usePlatform } from "@/context/platform"
2727
import { useSettings } from "@/context/settings"
2828
import { useSDK } from "@/context/sdk"
2929
import { useSync } from "@/context/sync"
30+
import { pending, working } from "@/pages/session/activity"
3031
import { messageAgentColor } from "@/utils/agent"
3132
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
3233

@@ -236,17 +237,13 @@ export function MessageTimeline(props: {
236237
if (!id) return emptyMessages
237238
return sync.data.message[id] ?? emptyMessages
238239
})
239-
const pending = createMemo(() =>
240-
sessionMessages().findLast(
241-
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
242-
),
243-
)
240+
const assistant = createMemo(() => pending(sessionMessages()))
244241
const sessionStatus = createMemo(() => {
245242
const id = sessionID()
246243
if (!id) return idle
247244
return sync.data.session_status[id] ?? idle
248245
})
249-
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
246+
const busy = createMemo(() => working(sessionStatus()))
250247
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
251248

252249
const [slot, setSlot] = createStore({
@@ -264,7 +261,7 @@ export function MessageTimeline(props: {
264261
onCleanup(clear)
265262
createEffect(
266263
on(
267-
working,
264+
busy,
268265
(on, prev) => {
269266
clear()
270267
if (on) {
@@ -282,23 +279,20 @@ export function MessageTimeline(props: {
282279
),
283280
)
284281
const activeMessageID = createMemo(() => {
285-
const parentID = pending()?.parentID
282+
if (!busy()) return undefined
283+
284+
const parentID = assistant()?.parentID
286285
if (parentID) {
287286
const messages = sessionMessages()
288287
const result = Binary.search(messages, parentID, (message) => message.id)
289288
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
290289
if (message && message.role === "user") return message.id
291290
}
292291

293-
const status = sessionStatus()
294-
if (status.type !== "idle") {
295-
const messages = sessionMessages()
296-
for (let i = messages.length - 1; i >= 0; i--) {
297-
if (messages[i].role === "user") return messages[i].id
298-
}
292+
const messages = sessionMessages()
293+
for (let i = messages.length - 1; i >= 0; i--) {
294+
if (messages[i].role === "user") return messages[i].id
299295
}
300-
301-
return undefined
302296
})
303297
const info = createMemo(() => {
304298
const id = sessionID()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { MessageV2 } from "./message-v2"
2+
3+
export const done = (msg: MessageV2.Assistant, time = Date.now()) => {
4+
if (typeof msg.time.completed === "number") return msg
5+
msg.time.completed = time
6+
return msg
7+
}

‎packages/opencode/src/session/processor.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Config } from "@/config/config"
1414
import { SessionCompaction } from "./compaction"
1515
import { PermissionNext } from "@/permission/next"
1616
import { Question } from "@/question"
17+
import { done } from "./assistant"
1718
import { PartID } from "./schema"
1819
import type { SessionID, MessageID } from "./schema"
1920

@@ -416,7 +417,7 @@ export namespace SessionProcessor {
416417
})
417418
}
418419
}
419-
input.assistantMessage.time.completed = Date.now()
420+
done(input.assistantMessage)
420421
await Session.updateMessage(input.assistantMessage)
421422
if (needsCompaction) return "compact"
422423
if (blocked) return "stop"

0 commit comments

Comments
 (0)