diff --git a/packages/ui/src/components/session-turn-diffs.tsx b/packages/ui/src/components/session-turn-diffs.tsx
new file mode 100644
index 000000000..33896e01b
--- /dev/null
+++ b/packages/ui/src/components/session-turn-diffs.tsx
@@ -0,0 +1,129 @@
+import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2/client"
+import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import { createEffect, createMemo, createSignal, For, on, Show } from "solid-js"
+import { Dynamic } from "solid-js/web"
+import { createStore } from "solid-js/store"
+import { useFileComponent } from "../context/file"
+import { useI18n } from "../context/i18n"
+import { Accordion } from "./accordion"
+import { DiffChanges } from "./diff-changes"
+import { Icon } from "./icon"
+import { normalize } from "./session-diff"
+import { StickyAccordionHeader } from "./sticky-accordion-header"
+
+const MAX_FILES = 10
+
+export function SessionTurnDiffs(props: {
+ diffs: SnapshotFileDiff[]
+ onShowAllToggle?: () => void
+}) {
+ const i18n = useI18n()
+ const fileComponent = useFileComponent()
+ const [state, setState] = createStore({
+ showAll: false,
+ expanded: [] as string[],
+ })
+ const showAll = () => state.showAll
+ const expanded = () => state.expanded
+ const edited = createMemo(() => props.diffs.length)
+ const overflow = createMemo(() => Math.max(0, edited() - MAX_FILES))
+ const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, MAX_FILES)))
+ const toggleAll = () => {
+ props.onShowAllToggle?.()
+ setState("showAll", !showAll())
+ }
+
+ return (
+
+
+
+ {edited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "}
+ {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
+
+
+ 0}>
+
+
+
+
+
setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
+ >
+
+ {(diff) => {
+ const view = normalize(diff)
+ const active = createMemo(() => expanded().includes(diff.file))
+ const [shown, setShown] = createSignal(false)
+
+ createEffect(
+ on(
+ active,
+ (value) => {
+ if (!value) {
+ setShown(false)
+ return
+ }
+
+ requestAnimationFrame(() => {
+ if (!active()) return
+ setShown(true)
+ })
+ },
+ { defer: true },
+ ),
+ )
+
+ return (
+
+
+
+
+
+
+
+ {`\u202A${getDirectory(diff.file)}\u202C`}
+
+
+ {getFilename(diff.file)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }}
+
+
+
0}>
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/session-turn-parent.test.ts b/packages/ui/src/components/session-turn-parent.test.ts
index 2c2cb5a3c..0bdefc954 100644
--- a/packages/ui/src/components/session-turn-parent.test.ts
+++ b/packages/ui/src/components/session-turn-parent.test.ts
@@ -16,7 +16,7 @@ test("session turn collects assistant messages by parent id across the full mess
test("legacy diff fallback is gated by visible turn-change data", () => {
const source = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8")
- expect(source).toContain("!hasVisibleTurnChanges(turnChange()) && edited() > 0 && !working()")
+ expect(source).toContain("!hasVisibleTurnChanges(turnChange()) && diffs().length > 0 && !working()")
expect(source).not.toContain("props.turnChanges === undefined &&")
})
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index db3ec1906..7e9e01d43 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -290,6 +290,9 @@
}
[data-slot="session-turn-diffs-toggle"] {
+ appearance: none;
+ border: 0;
+ background: none;
color: var(--brand-primary);
font-family: var(--font-family-sans);
font-size: var(--font-size-body);
@@ -299,6 +302,7 @@
opacity: 0;
transition: opacity 0.15s ease;
margin-left: 4px;
+ padding: 0;
}
[data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] {
@@ -309,7 +313,16 @@
opacity: 1;
}
+ [data-slot="session-turn-diffs-toggle"]:focus-visible {
+ opacity: 1;
+ outline: 1px solid var(--border-active);
+ outline-offset: 2px;
+ }
+
[data-slot="session-turn-diffs-more"] {
+ appearance: none;
+ border: 0;
+ background: none;
color: var(--fg-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-caption);
@@ -317,6 +330,7 @@
margin-top: 12px;
padding: 0 0 6px;
cursor: pointer;
+ text-align: left;
transition: color 0.15s ease;
&:hover {
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 5a061efbc..460f39fe9 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -7,27 +7,20 @@ import {
} from "@opencode-ai/sdk/v2/client"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
-import { useFileComponent } from "../context/file"
import { Binary } from "@opencode-ai/core/util/binary"
-import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
-import { createStore } from "solid-js/store"
-import { Dynamic } from "solid-js/web"
+import { createEffect, createMemo, createSignal, ParentProps, Show } from "solid-js"
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
import { Card } from "./card"
-import { Accordion } from "./accordion"
-import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
import { TextShimmer } from "./text-shimmer"
import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
-import { normalize } from "./session-diff"
import { hasVisibleTurnChanges, type TurnChangeActions, type TurnChangeDisplay } from "./session-turn-changes"
import { SessionTurnChangesPanel } from "./session-turn-changes-panel"
+import { SessionTurnDiffs } from "./session-turn-diffs"
function record(value: unknown): value is Record {
return !!value && typeof value === "object" && !Array.isArray(value)
@@ -171,7 +164,6 @@ export function SessionTurn(
) {
const data = useData()
const i18n = useI18n()
- const fileComponent = useFileComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@@ -254,20 +246,6 @@ export function SessionTurn(
}, [])
.reverse()
})
- const MAX_FILES = 10
- const edited = createMemo(() => diffs().length)
- const [state, setState] = createStore({
- showAll: false,
- expanded: [] as string[],
- })
- const showAll = () => state.showAll
- const expanded = () => state.expanded
- const overflow = createMemo(() => Math.max(0, edited() - MAX_FILES))
- const visible = createMemo(() => (showAll() ? diffs() : diffs().slice(0, MAX_FILES)))
- const toggleAll = () => {
- autoScroll.pause()
- setState("showAll", !showAll())
- }
const assistantMessages = createMemo(
() => {
@@ -454,98 +432,8 @@ export function SessionTurn(
/>
)}
- 0 && !working()}>
-
-
-
- {edited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "}
- {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
-
-
- 0}>
-
- {showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
-
-
-
-
-
setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
- >
-
- {(diff) => {
- const view = normalize(diff)
- const active = createMemo(() => expanded().includes(diff.file))
- const [shown, setShown] = createSignal(false)
-
- createEffect(
- on(
- active,
- (value) => {
- if (!value) {
- setShown(false)
- return
- }
-
- requestAnimationFrame(() => {
- if (!active()) return
- setShown(true)
- })
- },
- { defer: true },
- ),
- )
-
- return (
-
-
-
-
-
-
-
- {`\u202A${getDirectory(diff.file)}\u202C`}
-
-
- {getFilename(diff.file)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }}
-
-
-
0}>
-
- {i18n.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
-
-
-
-
+ 0 && !working()}>
+ autoScroll.pause()} />