Skip to content

Commit 718354e

Browse files
committed
fix(app): sync only unsynced running children
1 parent fa7612d commit 718354e

4 files changed

Lines changed: 150 additions & 74 deletions

File tree

‎packages/gui-extensions/src/agents/panel.tsx‎

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,12 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
163163
const sorted = createMemo(() => sortAgents(filtered(), { sort: sort(), ascending: ascending(), name: nameOf }))
164164

165165
const busyIDs = createMemo(
166-
() => new Set(sorted().filter((child) => statusOf(child.id) === "busy").map((child) => child.id)),
166+
() =>
167+
new Set(
168+
sorted()
169+
.filter((child) => statusOf(child.id) === "busy")
170+
.map((child) => child.id),
171+
),
167172
)
168173

169174
const running = createMemo(() => sorted().filter((child) => busyIDs().has(child.id)))
@@ -172,26 +177,37 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
172177
const runningCount = createMemo(() => running().length + unclaimed().length)
173178

174179
// Message detail for the few running children; history rows sync on expand instead.
180+
// Already-synced children stay out: membership churn must not refetch the survivors.
181+
const [synced, setSynced] = createSignal<ReadonlySet<string>>(new Set())
182+
175183
const runningKey = createMemo(() => {
176184
const ids = children()
177185
.filter((child) => statusOf(child.id) === "busy")
178186
.map((child) => child.id)
179187
.sort()
180188

181-
return ids.length > 0 ? ids.join("\0") : undefined
189+
return ids.length > 0 ? ids.join(" ") : undefined
182190
})
183191

184192
createKeyed(runningKey, (key) => {
185193
const store = props.session.server.data
186-
void Promise.all(key.split("\0").map((id) => store.session.message.sync(id))).catch(() => undefined)
194+
const fresh = key.split(" ").filter((id) => !synced().has(id))
195+
196+
if (fresh.length === 0) return
197+
198+
setSynced((prev) => new Set([...prev, ...fresh]))
199+
void Promise.all(fresh.map((id) => store.session.message.sync(id))).catch(() => undefined)
187200
})
188201

189202
const toggle = (child: SessionInfo, defaultOpen: boolean) => {
190203
const open = expanded[child.id] ?? defaultOpen
191204
setExpanded(child.id, !open)
192205

193206
if (!open && !loaded[child.id]) {
194-
void data().session.message.sync(child.id).then(() => setLoaded(child.id, true)).catch(() => undefined)
207+
void data()
208+
.session.message.sync(child.id)
209+
.then(() => setLoaded(child.id, true))
210+
.catch(() => undefined)
195211
}
196212
}
197213

@@ -212,9 +228,7 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
212228
}
213229

214230
const showSections = () =>
215-
visibleRunning().length + visibleUnclaimed().length + visibleHistory().length > 0 ||
216-
tab() !== "all" ||
217-
searching()
231+
visibleRunning().length + visibleUnclaimed().length + visibleHistory().length > 0 || tab() !== "all" || searching()
218232

219233
return (
220234
<ScrollView class="h-full">
@@ -276,15 +290,27 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
276290
</div>
277291

278292
<div class="flex items-center gap-4">
279-
<TabButton active={tab() === "all"} onClick={() => setTab("all")} label={ctx.t("tab.all")} count={children().length + unclaimed().length} />
280-
<TabButton active={tab() === "running"} onClick={() => setTab("running")} label={ctx.t("tab.running")} count={runningCount()} />
281-
<TabButton active={tab() === "history"} onClick={() => setTab("history")} label={ctx.t("tab.history")} count={history().length} />
293+
<TabButton
294+
active={tab() === "all"}
295+
onClick={() => setTab("all")}
296+
label={ctx.t("tab.all")}
297+
count={children().length + unclaimed().length}
298+
/>
299+
<TabButton
300+
active={tab() === "running"}
301+
onClick={() => setTab("running")}
302+
label={ctx.t("tab.running")}
303+
count={runningCount()}
304+
/>
305+
<TabButton
306+
active={tab() === "history"}
307+
onClick={() => setTab("history")}
308+
label={ctx.t("tab.history")}
309+
count={history().length}
310+
/>
282311
</div>
283312

284-
<Show
285-
when={showSections()}
286-
fallback={<div class="text-12-regular text-text-weak">{emptyText()}</div>}
287-
>
313+
<Show when={showSections()} fallback={<div class="text-12-regular text-text-weak">{emptyText()}</div>}>
288314
<Show when={visibleRunning().length + visibleUnclaimed().length > 0}>
289315
<div class="flex flex-col gap-2">
290316
<Show when={tab() === "all"}>
@@ -324,11 +350,7 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
324350
</div>
325351
</Show>
326352

327-
<Show
328-
when={
329-
visibleRunning().length + visibleUnclaimed().length + visibleHistory().length === 0
330-
}
331-
>
353+
<Show when={visibleRunning().length + visibleUnclaimed().length + visibleHistory().length === 0}>
332354
<div class="text-12-regular text-text-weak">{emptyText()}</div>
333355
</Show>
334356
</Show>
@@ -415,9 +437,7 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
415437
<Show when={!(open() && status() === "busy")}>
416438
<div class="text-12-regular text-text-weak truncate pl-8">
417439
{statusLabel(status(), ctx.t)}
418-
<Show when={props.child.model}>
419-
{(model) => <> · {model().id}</>}
420-
</Show>
440+
<Show when={props.child.model}>{(model) => <> · {model().id}</>}</Show>
421441
</div>
422442
</Show>
423443

@@ -449,9 +469,7 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
449469
<Show when={entry.target}>
450470
<span class="text-text-weak truncate min-w-0 flex-1">{entry.target}</span>
451471
</Show>
452-
<span class="text-text-faint shrink-0">
453-
{formatRelative(entry.at, now(), i18n.locale())}
454-
</span>
472+
<span class="text-text-faint shrink-0">{formatRelative(entry.at, now(), i18n.locale())}</span>
455473
</div>
456474
)}
457475
</For>
@@ -462,7 +480,14 @@ export default function SessionAgentsPanel(props: { session: MountedSession }) {
462480
<Show when={open() && status() !== "busy"}>
463481
<div class="flex flex-col gap-1 pt-1 pl-8">
464482
<div class="text-12-regular text-text-weak">{ctx.t("lastResponse")}</div>
465-
<Show when={response()} fallback={<Show when={!ready()}><SessionProgressIndicatorV2 class="size-3.5" /></Show>}>
483+
<Show
484+
when={response()}
485+
fallback={
486+
<Show when={!ready()}>
487+
<SessionProgressIndicatorV2 class="size-3.5" />
488+
</Show>
489+
}
490+
>
466491
{(text) => (
467492
<div class="rounded-md border border-[var(--v2-border-border-base)] px-3 py-2 text-12-regular text-text-strong whitespace-pre-wrap break-words">
468493
{text()}

‎packages/gui-extensions/src/agents/renderer.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const setup: Setup<typeof definition> = (ctx) => {
2121
<AgentsTabIcon session={state.session} />
2222
<span>{ctx.t("tab.title")}</span>
2323
</div>
24-
), draggable: false,
24+
),
25+
draggable: false,
2526
closable: "compact",
2627
}
2728

‎packages/gui-extensions/src/agents/status.test.ts‎

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from "bun:test"
2-
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise"
2+
import type {
3+
JsonValue,
4+
SessionMessageAssistant,
5+
SessionMessageAssistantTool,
6+
SessionMessageInfo,
7+
} from "@opencode/client/promise"
38
import {
49
childHref,
510
childrenOf,
@@ -58,14 +63,14 @@ describe("taskStatus", () => {
5863
expect(taskStatus(tool({ status: "streaming", input: "..." }))).toBe("running")
5964
})
6065
test("completed parts are completed", () => {
61-
expect(
62-
taskStatus(tool({ status: "completed", input: {}, content: [{ type: "text", text: "done" }] })),
63-
).toBe("completed")
66+
expect(taskStatus(tool({ status: "completed", input: {}, content: [{ type: "text", text: "done" }] }))).toBe(
67+
"completed",
68+
)
6469
})
6570
test("aborted errors are cancelled", () => {
66-
expect(
67-
taskStatus(tool({ status: "error", input: {}, error: { type: "aborted", message: "stopped" } })),
68-
).toBe("cancelled")
71+
expect(taskStatus(tool({ status: "error", input: {}, error: { type: "aborted", message: "stopped" } }))).toBe(
72+
"cancelled",
73+
)
6974
})
7075
test("interrupted metadata is cancelled", () => {
7176
expect(
@@ -88,12 +93,17 @@ describe("partBackgrounded", () => {
8893
test("completed parts with a running metadata status are backgrounded", () => {
8994
expect(
9095
partBackgrounded(
91-
tool({ status: "completed", input: {}, content: [{ type: "text", text: "x" }], metadata: { status: "running" } }),
96+
tool({
97+
status: "completed",
98+
input: {},
99+
content: [{ type: "text", text: "x" }],
100+
metadata: { status: "running" },
101+
}),
92102
),
93103
).toBe(true)
94-
expect(
95-
partBackgrounded(tool({ status: "completed", input: {}, content: [{ type: "text", text: "x" }] })),
96-
).toBe(false)
104+
expect(partBackgrounded(tool({ status: "completed", input: {}, content: [{ type: "text", text: "x" }] }))).toBe(
105+
false,
106+
)
97107
expect(partBackgrounded(tool({ status: "running", input: {}, metadata: {} }))).toBe(false)
98108
})
99109
})
@@ -159,7 +169,14 @@ describe("subagentTasks", () => {
159169
{ id: "a" },
160170
)
161171

162-
const notice = { id: "n1", type: "synthetic", metadata: { source: "subagent", childID: "child" }, text: "", time: { created: 2 } } as const
172+
const notice = {
173+
id: "n1",
174+
type: "synthetic",
175+
metadata: { source: "subagent", childID: "child" },
176+
text: "",
177+
time: { created: 2 },
178+
} as const
179+
163180
expect(subagentTasks([completedMessage([backgrounded])]).parts.has("child")).toBe(true)
164181
expect(subagentTasks([completedMessage([backgrounded]), notice]).parts.has("child")).toBe(false)
165182
})
@@ -193,16 +210,26 @@ describe("deriveStatus", () => {
193210
expect(deriveStatus({ part: "completed", backgrounded: true, running: false, outcome: undefined })).toBe("busy")
194211
})
195212
test("the outcome settles everything else", () => {
196-
expect(deriveStatus({ part: "completed", backgrounded: true, running: false, outcome: "succeeded" })).toBe("completed")
213+
expect(deriveStatus({ part: "completed", backgrounded: true, running: false, outcome: "succeeded" })).toBe(
214+
"completed",
215+
)
197216
expect(deriveStatus({ part: "completed", backgrounded: false, running: false, outcome: "failed" })).toBe("error")
198217
expect(deriveStatus({ part: undefined, running: true, outcome: undefined, backgrounded: false })).toBe("busy")
199-
expect(deriveStatus({ part: "cancelled", backgrounded: false, running: false, outcome: "succeeded" })).toBe("completed")
218+
expect(deriveStatus({ part: "cancelled", backgrounded: false, running: false, outcome: "succeeded" })).toBe(
219+
"completed",
220+
)
200221
})
201222
test("terminal parts and outcomes fall through in order", () => {
202-
expect(deriveStatus({ part: "completed", backgrounded: false, running: false, outcome: undefined })).toBe("completed")
203-
expect(deriveStatus({ part: undefined, running: false, outcome: "interrupted", backgrounded: false })).toBe("cancelled")
223+
expect(deriveStatus({ part: "completed", backgrounded: false, running: false, outcome: undefined })).toBe(
224+
"completed",
225+
)
226+
expect(deriveStatus({ part: undefined, running: false, outcome: "interrupted", backgrounded: false })).toBe(
227+
"cancelled",
228+
)
204229
expect(deriveStatus({ part: undefined, running: false, outcome: "failed", backgrounded: false })).toBe("error")
205-
expect(deriveStatus({ part: undefined, running: false, outcome: "succeeded", backgrounded: false })).toBe("completed")
230+
expect(deriveStatus({ part: undefined, running: false, outcome: "succeeded", backgrounded: false })).toBe(
231+
"completed",
232+
)
206233
expect(deriveStatus({ part: undefined, running: false, outcome: undefined, backgrounded: false })).toBe("idle")
207234
})
208235
})
@@ -254,9 +281,27 @@ describe("formatting", () => {
254281

255282
describe("sortAgents", () => {
256283
const sessions = [
257-
{ id: "a", cost: 3, tokens: { input: 1, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, title: "Zulu", time: { created: 1000 } },
258-
{ id: "b", cost: 1, tokens: { input: 9, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, title: "Alpha", time: { created: 3000 } },
259-
{ id: "c", cost: 2, tokens: { input: 5, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, title: "Mike", time: { created: 2000 } },
284+
{
285+
id: "a",
286+
cost: 3,
287+
tokens: { input: 1, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
288+
title: "Zulu",
289+
time: { created: 1000 },
290+
},
291+
{
292+
id: "b",
293+
cost: 1,
294+
tokens: { input: 9, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
295+
title: "Alpha",
296+
time: { created: 3000 },
297+
},
298+
{
299+
id: "c",
300+
cost: 2,
301+
tokens: { input: 5, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
302+
title: "Mike",
303+
time: { created: 2000 },
304+
},
260305
]
261306

262307
const name = (id: string) => sessions.find((session) => session.id === id)?.title ?? id
@@ -294,7 +339,14 @@ describe("sortAgents", () => {
294339

295340
describe("filterAgents", () => {
296341
const sessions = [
297-
{ id: "abc", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, title: "Explore auth", time: { created: 1 }, model: { id: "m1", providerID: "test" } },
342+
{
343+
id: "abc",
344+
cost: 0,
345+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
346+
title: "Explore auth",
347+
time: { created: 1 },
348+
model: { id: "m1", providerID: "test" },
349+
},
298350
]
299351

300352
test("matches titles, models and ids case-insensitively", () => {
@@ -321,22 +373,24 @@ describe("clocks", () => {
321373
})
322374

323375
describe("transcript detail", () => {
324-
const activityPart = (id: string, name: string, input: { [x: string]: JsonValue }, at: number) => ({
325-
type: "tool",
326-
id,
327-
name,
328-
state: { status: "completed", input, content: [{ type: "text", text: "x" }] },
329-
time: { created: at, completed: at },
330-
}) satisfies SessionMessageAssistantTool
376+
const activityPart = (id: string, name: string, input: { [x: string]: JsonValue }, at: number) =>
377+
({
378+
type: "tool",
379+
id,
380+
name,
381+
state: { status: "completed", input, content: [{ type: "text", text: "x" }] },
382+
time: { created: at, completed: at },
383+
}) satisfies SessionMessageAssistantTool
331384

332-
const assistantMessage = (id: string, content: SessionMessageAssistant["content"], at: number, done: boolean) => ({
333-
id,
334-
type: "assistant",
335-
agent: "build",
336-
model: { id: "m", providerID: "p" },
337-
content,
338-
time: done ? { created: at, completed: at + 500 } : { created: at },
339-
}) satisfies SessionMessageInfo
385+
const assistantMessage = (id: string, content: SessionMessageAssistant["content"], at: number, done: boolean) =>
386+
({
387+
id,
388+
type: "assistant",
389+
agent: "build",
390+
model: { id: "m", providerID: "p" },
391+
content,
392+
time: done ? { created: at, completed: at + 500 } : { created: at },
393+
}) satisfies SessionMessageInfo
340394

341395
test("latest activity walks newest tool calls first", () => {
342396
const messages = [

‎packages/gui-extensions/src/agents/status.ts‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,7 @@ export function subagentDescriptions(messages: SessionMessageInfo[]): Map<string
175175
}
176176

177177
/** Direct children of one session, oldest first by id. */
178-
export function childrenOf<T extends { id: string; parentID?: string }>(
179-
sessions: readonly T[],
180-
parentID: string,
181-
): T[] {
178+
export function childrenOf<T extends { id: string; parentID?: string }>(sessions: readonly T[], parentID: string): T[] {
182179
return sessions.filter((session) => session.parentID === parentID).sort((a, b) => (a.id < b.id ? -1 : 1))
183180
}
184181

@@ -286,9 +283,7 @@ function compareKeys(a: number | string, b: number | string): number {
286283
}
287284

288285
/** Sorted children: recent means newest first unless ascending flips it. */
289-
export function sortAgents<
290-
T extends { id: string; cost: number; tokens: TokenUsageInfo; time: { created: number } },
291-
>(
286+
export function sortAgents<T extends { id: string; cost: number; tokens: TokenUsageInfo; time: { created: number } }>(
292287
sessions: readonly T[],
293288
options: { sort: AgentSort; ascending: boolean; name: (sessionID: string) => string },
294289
): T[] {
@@ -344,19 +339,20 @@ export function formatElapsed(started: number, now: number): string {
344339
return `${rest}s`
345340
}
346341

347-
/** Short relative stamp: 2s ago, 18m ago, 3h ago, older falls back to the clock. */
342+
/** Short relative stamp in the active locale: 2s ago, 18m ago, 3h ago, older falls back to the clock. */
348343
export function formatRelative(when: number, now: number, locale: string): string {
344+
const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "narrow" })
349345
const seconds = Math.max(0, Math.floor((now - when) / 1000))
350346

351-
if (seconds < 60) return `${seconds}s ago`
347+
if (seconds < 60) return format.format(-seconds, "second")
352348

353349
const minutes = Math.floor(seconds / 60)
354350

355-
if (minutes < 60) return `${minutes}m ago`
351+
if (minutes < 60) return format.format(-minutes, "minute")
356352

357353
const hours = Math.floor(minutes / 60)
358354

359-
if (hours < 24) return `${hours}h ago`
355+
if (hours < 24) return format.format(-hours, "hour")
360356

361357
return formatTime(when, locale)
362358
}

0 commit comments

Comments
 (0)