Skip to content

Commit 81d5708

Browse files
feat(session-ui): summarize each turn's changed files (#55064)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
1 parent 34d8ded commit 81d5708

17 files changed

Lines changed: 432 additions & 28 deletions

File tree

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,8 @@ export const dict = {
790790
"settings.timeline.description.text-only": "Hide all activity.",
791791
"settings.timeline.description.custom": "Uses advanced settings.",
792792
"settings.timeline.custom": "Custom",
793+
"settings.timeline.row.turnDiffs.title": "Changed files",
794+
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
793795
"settings.timeline.advanced": "Advanced",
794796
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
795797
"settings.timeline.group": "Group",

‎packages/app/src/session/timeline/controller.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
180180
timelineDetail,
181181
pendingInputIDs,
182182
queuedCompactionIDs,
183+
turnDiffs: settings.general.showTurnDiffs,
183184
})
184185

185186
const [pending, setPending] = createStore({ rename: false })
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { FileDiffInfo } from "@opencode/client/promise"
3+
import { createRoot } from "solid-js"
4+
import { createTurnDiffCache } from "./turn-diffs"
5+
6+
const turn = { sessionID: "ses_a", from: "msg_open" }
7+
8+
// Every pending promise callback runs before the next macrotask.
9+
const settle = () => new Promise((resolve) => setImmediate(resolve))
10+
11+
const diff = { file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "modified" } satisfies FileDiffInfo
12+
13+
describe("turn diff cache", () => {
14+
test("loads each turn once and retries after a failure", async () => {
15+
const cache = createRoot(() => createTurnDiffCache())
16+
const failed = Promise.withResolvers<readonly FileDiffInfo[]>()
17+
const calls: string[] = []
18+
19+
cache.load(turn, () => (calls.push("failed"), failed.promise))
20+
cache.load(turn, () => (calls.push("duplicate"), Promise.resolve([diff])))
21+
failed.reject(new Error("offline"))
22+
await settle()
23+
expect(cache.get(turn)).toBeUndefined()
24+
25+
cache.load(turn, () => (calls.push("retry"), Promise.resolve([diff])))
26+
await settle()
27+
cache.load(turn, () => (calls.push("cached"), Promise.resolve([])))
28+
29+
expect(calls).toEqual(["failed", "retry"])
30+
expect(cache.get(turn)).toEqual([diff])
31+
})
32+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { FileDiffInfo } from "@opencode/client/promise"
2+
import type { TurnDiffInput } from "@opencode/session-ui/context"
3+
import { createStore } from "solid-js/store"
4+
5+
/**
6+
* One tab's in-memory cache of the files each finished turn changed. A finished turn's snapshots never
7+
* change, so each turn loads once per tab and survives the timeline remounting; closing the tab drops it.
8+
*/
9+
export function createTurnDiffCache() {
10+
const [diffs, setDiffs] = createStore<Record<string, readonly FileDiffInfo[] | undefined>>({})
11+
const loading = new Set<string>()
12+
const key = (input: TurnDiffInput) => `${input.sessionID}/${input.from}`
13+
14+
return {
15+
get: (input: TurnDiffInput) => diffs[key(input)],
16+
load(input: TurnDiffInput, fetch: () => Promise<readonly FileDiffInfo[]>) {
17+
const id = key(input)
18+
19+
if (diffs[id] || loading.has(id)) return
20+
loading.add(id)
21+
// A failed load leaves the turn unloaded, so the next request retries.
22+
void fetch()
23+
.then(
24+
(value) => setDiffs(id, value),
25+
() => undefined,
26+
)
27+
.finally(() => loading.delete(id))
28+
},
29+
}
30+
}

‎packages/app/src/settings/general/general.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,17 @@ export const SettingsGeneral: Component = () => {
317317
onChange={settings.general.setTimelineDetail}
318318
/>
319319
</div>
320+
<SettingsRow
321+
title={language.t("settings.timeline.row.turnDiffs.title")}
322+
description={language.t("settings.timeline.row.turnDiffs.description")}
323+
>
324+
<div data-action="settings-show-turn-diffs">
325+
<Switch
326+
checked={settings.general.showTurnDiffs()}
327+
onChange={(checked) => settings.general.setShowTurnDiffs(checked)}
328+
/>
329+
</div>
330+
</SettingsRow>
320331
</SettingsList>
321332
</section>
322333

‎packages/app/src/settings/model.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe("settings schema", () => {
9393
showFileTree: false,
9494
timelineDetail: timelinePresets[2].value,
9595
showCustomAgents: false,
96+
showTurnDiffs: false,
9697
mobileTitlebarPosition: "top",
9798
terminalPlacement: "side",
9899
followUpBehavior: "steer",

‎packages/app/src/settings/model.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const generalSchema = Persistence.struct({
116116
tools: placementOnlySchema,
117117
}),
118118
showCustomAgents: Schema.Boolean,
119+
showTurnDiffs: Schema.Boolean,
119120
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
120121
// Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first.
121122
mobileDiffWrap: Persistence.optional(Schema.Boolean),
@@ -278,6 +279,7 @@ export const defaultSettings: Settings = {
278279
showFileTree: false,
279280
timelineDetail: { ...timelinePresets[2].value },
280281
showCustomAgents: false,
282+
showTurnDiffs: false,
281283
mobileTitlebarPosition: "top",
282284
terminalPlacement: "side",
283285
followUpBehavior: "steer",
@@ -351,6 +353,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
351353
setShowCustomAgents(value: boolean) {
352354
setStore("general", "showCustomAgents", value)
353355
},
356+
showTurnDiffs: withFallback(() => store.general?.showTurnDiffs, defaultSettings.general.showTurnDiffs),
357+
setShowTurnDiffs(value: boolean) {
358+
setStore("general", "showTurnDiffs", value)
359+
},
354360
mobileTitlebarPosition: withFallback(
355361
() => store.general?.mobileTitlebarPosition,
356362
defaultSettings.general.mobileTitlebarPosition,

‎packages/app/src/shell/routes/session-ui-provider.tsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import type { ServerConnection } from "@/runtime/server/registry"
88
import { sessionHref } from "@/shell/routes/session"
99
import { useData } from "@/runtime/server/current"
1010
import { useServerSDK } from "@/runtime/server/client"
11-
import { useTabs } from "@/shell/tabs/tabs"
11+
import { useTabs, type Tab } from "@/shell/tabs/tabs"
1212
import { readLocalImage } from "@/runtime/server/image"
1313
import { useLanguage } from "@/runtime/i18n/language"
1414
import { showToast } from "@/shell/notifications/toast"
15+
import { createTurnDiffCache } from "@/session/timeline/turn-diffs"
1516

1617
export function SessionUIProvider(
1718
props: ParentProps<{
@@ -65,6 +66,23 @@ export function SessionUIProvider(
6566

6667
const providers = useProviders(directory)
6768

69+
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
70+
const turnDiffs = () => {
71+
const id = params.id
72+
73+
if (!id) return
74+
75+
const tab =
76+
tabs.store.find(
77+
(item) =>
78+
item.type === "session" &&
79+
item.server === props.server &&
80+
(item.sessionId === id || item.routeSessionId === id),
81+
) ?? ({ type: "session", server: props.server, sessionId: id } satisfies Tab)
82+
83+
return tabs.state(tab, "turnDiffs", createTurnDiffCache)
84+
}
85+
6886
const sessionUIData = createMemo(() => ({
6987
provider: providers.ready()
7088
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
@@ -88,6 +106,10 @@ export function SessionUIProvider(
88106
sessionID={params.id}
89107
shellRunning={(id) => !!data.shell.get(id)}
90108
shellOutput={(input) => serverSDK.api.shell.output(input)}
109+
turnDiff={{
110+
get: (input) => turnDiffs()?.get(input),
111+
load: (input) => turnDiffs()?.load(input, () => serverSDK.api.session.diff(input)),
112+
}}
91113
onNavigateToSession={navigateToSession}
92114
onSessionHref={href}
93115
>

‎packages/session-ui/src/context/data.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ type Data = {
3636
}
3737
}
3838

39+
/** Names a finished turn by its Session and the user message that opened it. */
40+
export type TurnDiffInput = { sessionID: string; from: string }
41+
42+
/** The host's cache of the files each finished turn changed. */
43+
export type TurnDiffSource = {
44+
/** The turn's diffs, or undefined until they load. */
45+
get: (input: TurnDiffInput) => readonly FileDiffInfo[] | undefined
46+
/** Starts loading the turn's diffs unless they are cached or already loading. */
47+
load: (input: TurnDiffInput) => void
48+
}
49+
3950
export type NavigateToSessionFn = (sessionID: string) => void
4051

4152
export type SessionHrefFn = (sessionID: string) => string
@@ -48,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
4859
sessionID?: string
4960
shellRunning?: (id: string) => boolean
5061
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
62+
turnDiff?: TurnDiffSource
5163
onNavigateToSession?: NavigateToSessionFn
5264
onSessionHref?: SessionHrefFn
5365
}) => {
@@ -65,6 +77,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
6577
sessionHref: props.onSessionHref,
6678
shellRunning: props.shellRunning,
6779
shellOutput: props.shellOutput,
80+
turnDiff: props.turnDiff,
6881
}
6982
},
7083
})

‎packages/session-ui/src/storybook/current-session-story.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
6262
},
6363
session_diff: { [CURRENT_SESSION_ID]: props.document.diffs },
6464
}}
65+
turnDiff={{ get: () => props.document.diffs, load: () => {} }}
6566
>
6667
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
6768
</DataProvider>
@@ -76,6 +77,7 @@ export function CurrentSessionTimelineStory(props: {
7677
width?: string
7778
shellToolDefaultOpen?: boolean
7879
editToolDefaultOpen?: boolean
80+
turnDiffs?: boolean
7981
}) {
8082
// Inline story documents rebuild their messages on every prop read, and the timeline projection keys text by identity.
8183
const document = createMemo(() => props.document)
@@ -118,6 +120,7 @@ export function CurrentSessionTimelineStory(props: {
118120
actions={actions}
119121
shellToolDefaultOpen={props.shellToolDefaultOpen}
120122
editToolDefaultOpen={props.editToolDefaultOpen}
123+
turnDiffs={props.turnDiffs}
121124
/>
122125
</CurrentSessionProviders>
123126
</div>

0 commit comments

Comments
 (0)