Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions packages/ui/src/components/session-turn-diffs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-slot="session-turn-diffs"
data-component="session-turn-diffs-group"
data-show-all={showAll() || undefined}
>
<div data-slot="session-turn-diffs-header">
<span data-slot="session-turn-diffs-label">
{edited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "}
{i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<DiffChanges changes={props.diffs} />
<Show when={overflow() > 0}>
<button type="button" data-slot="session-turn-diffs-toggle" onClick={toggleAll}>
{showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
</button>
</Show>
</div>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "44px" }}
value={expanded()}
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={visible()}>
{(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 (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={shown()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
<Show when={!showAll() && overflow() > 0}>
<button type="button" data-slot="session-turn-diffs-more" onClick={toggleAll}>
{i18n.t("ui.sessionTurn.diffs.more", { count: overflow() })}
</button>
</Show>
</div>
</div>
)
}
2 changes: 1 addition & 1 deletion packages/ui/src/components/session-turn-parent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&")
})

Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/components/session-turn.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"] {
Expand All @@ -309,14 +313,24 @@
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);
line-height: var(--line-height-h3);
margin-top: 12px;
padding: 0 0 6px;
cursor: pointer;
text-align: left;
transition: color 0.15s ease;

&:hover {
Expand Down
120 changes: 4 additions & 116 deletions packages/ui/src/components/session-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
Expand Down Expand Up @@ -171,7 +164,6 @@ export function SessionTurn(
) {
const data = useData()
const i18n = useI18n()
const fileComponent = useFileComponent()

const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
Expand Down Expand Up @@ -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(
() => {
Expand Down Expand Up @@ -454,98 +432,8 @@ export function SessionTurn(
/>
)}
</Show>
<Show when={!hasVisibleTurnChanges(turnChange()) && edited() > 0 && !working()}>
<div
data-slot="session-turn-diffs"
data-component="session-turn-diffs-group"
data-show-all={showAll() || undefined}
>
<div data-slot="session-turn-diffs-header">
<span data-slot="session-turn-diffs-label">
{edited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "}
{i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<DiffChanges changes={diffs()} />
<Show when={overflow() > 0}>
<span data-slot="session-turn-diffs-toggle" onClick={toggleAll}>
{showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
</span>
</Show>
</div>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "44px" }}
value={expanded()}
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={visible()}>
{(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 (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={shown()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
<Show when={!showAll() && overflow() > 0}>
<div data-slot="session-turn-diffs-more" onClick={toggleAll}>
{i18n.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
</div>
</Show>
</div>
</div>
<Show when={!hasVisibleTurnChanges(turnChange()) && diffs().length > 0 && !working()}>
<SessionTurnDiffs diffs={diffs()} onShowAllToggle={() => autoScroll.pause()} />
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
Expand Down
Loading