diff --git a/packages/ui/src/components/session-turn-changes-panel.tsx b/packages/ui/src/components/session-turn-changes-panel.tsx new file mode 100644 index 000000000..4b122fcbc --- /dev/null +++ b/packages/ui/src/components/session-turn-changes-panel.tsx @@ -0,0 +1,213 @@ +import { createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { Dynamic } from "solid-js/web" +import { getDirectory } from "@opencode-ai/core/util/path" +import { useFileComponent } from "../context/file" +import { useI18n } from "../context/i18n" +import { Icon } from "./icon" +import { IconButton } from "./icon-button" +import { Tooltip } from "./tooltip" +import { normalize } from "./session-diff" +import { + hasTurnChangeActionHandler, + turnChangeAction, + type TurnChangeActions, + type TurnChangeDisplay, + type TurnChangeFile, +} from "./session-turn-changes" + +const emptyTurnFiles: TurnChangeFile[] = [] +const emptyExpanded: readonly string[] = [] + +export function SessionTurnChangesPanel(props: { + turnChange: TurnChangeDisplay + actions?: TurnChangeActions + expanded?: readonly string[] + onExpandedChange?: (value: string[]) => void +}) { + const i18n = useI18n() + const fileComponent = useFileComponent() + + const turnFiles = createMemo(() => props.turnChange.files ?? emptyTurnFiles) + const turnEdited = createMemo(() => turnFiles().length) + const turnAdditions = createMemo(() => turnFiles().reduce((sum, file) => sum + (file.additions ?? 0), 0)) + const turnDeletions = createMemo(() => turnFiles().reduce((sum, file) => sum + (file.deletions ?? 0), 0)) + const [confirmAction, setConfirmAction] = createSignal<"undo" | "redo" | undefined>() + let confirmTimer: ReturnType | undefined + const expandedPaths = () => props.expanded ?? emptyExpanded + + const resetConfirm = () => { + if (confirmTimer) clearTimeout(confirmTimer) + confirmTimer = undefined + setConfirmAction(undefined) + } + const primeConfirm = (action: "undo" | "redo") => { + if (confirmAction() === action) return true + setConfirmAction(action) + if (confirmTimer) clearTimeout(confirmTimer) + confirmTimer = setTimeout(resetConfirm, 3000) + return false + } + onCleanup(resetConfirm) + + const mutateTurnChange = async () => { + const id = props.turnChange.messageID + const action = turnChangeAction(props.turnChange) + if (!action || !hasTurnChangeActionHandler(props.turnChange, props.actions)) return + if (!primeConfirm(action)) return + resetConfirm() + if (action === "undo") await props.actions?.undo?.(id) + else await props.actions?.redo?.(id) + } + + const turnActionLabel = createMemo(() => { + const action = turnChangeAction(props.turnChange) + if (!action) return "" + const base = action === "undo" ? i18n.t("ui.sessionTurn.turnChanges.undo") : i18n.t("ui.sessionTurn.turnChanges.reapply") + return confirmAction() === action + ? action === "undo" + ? i18n.t("ui.sessionTurn.turnChanges.undoConfirm") + : i18n.t("ui.sessionTurn.turnChanges.redoConfirm") + : base + }) + + const isUndoneTurn = createMemo(() => props.turnChange.redoAvailable && !props.turnChange.undoAvailable) + const turnStatusLabel = (status: TurnChangeFile["status"]) => { + if (status === "added") return i18n.t("ui.sessionTurn.turnChanges.status.added") + if (status === "deleted") return i18n.t("ui.sessionTurn.turnChanges.status.deleted") + return i18n.t("ui.sessionTurn.turnChanges.status.updated") + } + + return ( +
+
+
+ + {i18n.t( + turnEdited() === 1 + ? "ui.sessionTurn.turnChanges.summary.one" + : "ui.sessionTurn.turnChanges.summary.other", + { count: turnEdited() }, + )} + + +{turnAdditions()} + -{turnDeletions()} + 0}> + + {i18n.t("ui.sessionTurn.turnChanges.omitted", { count: props.turnChange.omittedCount ?? 0 })} + + + + {i18n.t("ui.sessionTurn.turnChanges.undone")} + +
+ + + +
+
+ + {(file) => { + const expanded = createMemo(() => expandedPaths().includes(file.path)) + const toggle = () => { + if (!file.expandable) return + const current = expandedPaths() + props.onExpandedChange?.( + current.includes(file.path) ? current.filter((item) => item !== file.path) : [...current, file.path], + ) + } + const view = createMemo(() => + file.patch + ? normalize({ + file: file.path, + patch: file.patch, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + status: file.status, + }) + : undefined, + ) + return ( +
+
+ + + + + + {file.path} + + {turnStatusLabel(file.status)}} + > + +{file.additions ?? 0} + -{file.deletions ?? 0} + + + + {i18n.t("ui.sessionTurn.turnChanges.unrestorable")} + + + + event.stopPropagation()}> + + file.openPath && props.actions?.openFile?.(file.openPath)} + /> + + + + file.openPath && + props.actions?.showInFolder?.( + file.status === "deleted" ? getDirectory(file.openPath) : file.openPath, + ) + } + /> + + +
+ + {(diff) => ( +
+ +
+ )} +
+
+ ) + }} +
+
+ 0}> +
+ {i18n.t("ui.sessionTurn.turnChanges.skippedNotice", { + count: props.turnChange.skippedCount ?? 0, + })} +
+
+
+ ) +} diff --git a/packages/ui/src/components/session-turn-changes.ts b/packages/ui/src/components/session-turn-changes.ts index 4b3785fd6..beaa689e0 100644 --- a/packages/ui/src/components/session-turn-changes.ts +++ b/packages/ui/src/components/session-turn-changes.ts @@ -24,6 +24,13 @@ export type TurnChangeDisplay = { files: TurnChangeFile[] } +export type TurnChangeActions = { + undo?: (userMessageID: string, options?: { force?: boolean }) => Promise | void + redo?: (userMessageID: string, options?: { force?: boolean }) => Promise | void + openFile?: (path: string) => void + showInFolder?: (path: string) => void +} + export function hasVisibleTurnChanges(display: TurnChangeDisplay | null | undefined) { return !!display && (display.files.length > 0 || !!display.truncated) } @@ -39,7 +46,7 @@ export function turnChangeAction(display: TurnChangeDisplay | null | undefined): export function hasTurnChangeActionHandler( display: TurnChangeDisplay | null | undefined, - actions: { undo?: unknown; redo?: unknown } | null | undefined, + actions: TurnChangeActions | null | undefined, ) { const action = turnChangeAction(display) if (action === "undo") return typeof actions?.undo === "function" diff --git a/packages/ui/src/components/session-turn-parent.test.ts b/packages/ui/src/components/session-turn-parent.test.ts index 68b0b0459..2c2cb5a3c 100644 --- a/packages/ui/src/components/session-turn-parent.test.ts +++ b/packages/ui/src/components/session-turn-parent.test.ts @@ -12,3 +12,26 @@ test("session turn collects assistant messages by parent id across the full mess expect(source).toContain("item.parentID === msg.id") expect(source).not.toContain('if (item.role === "user") break') }) + +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).not.toContain("props.turnChanges === undefined &&") +}) + +test("turn-change expansion state stays owned by session turn", () => { + const turnSource = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8") + const panelSource = readFileSync(new URL("./session-turn-changes-panel.tsx", import.meta.url), "utf8") + + expect(turnSource).toContain("const [turnExpanded, setTurnExpanded] = createSignal([])") + expect(turnSource).toContain("expanded={turnExpanded()}") + expect(turnSource).toContain("onExpandedChange={(value) => setTurnExpanded(value)}") + expect(panelSource).not.toContain("const [turnExpanded, setTurnExpanded] = createSignal([])") +}) + +test("visible turn-change memo is declared after working state", () => { + const source = readFileSync(new URL("./session-turn.tsx", import.meta.url), "utf8") + + expect(source.indexOf("const working = createMemo")).toBeLessThan(source.indexOf("const visibleTurnChange = createMemo")) +}) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 6c79d48de..5a061efbc 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -11,7 +11,7 @@ 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, onCleanup, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part" @@ -20,21 +20,14 @@ import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" -import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { Tooltip } from "./tooltip" 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 { - hasTurnChangeActionHandler, - hasVisibleTurnChanges, - turnChangeAction, - type TurnChangeDisplay, - type TurnChangeFile, -} from "./session-turn-changes" +import { hasVisibleTurnChanges, type TurnChangeActions, type TurnChangeDisplay } from "./session-turn-changes" +import { SessionTurnChangesPanel } from "./session-turn-changes-panel" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -165,18 +158,7 @@ export function SessionTurn( shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean turnChanges?: Record - turnChangeActions?: { - undo?: ( - userMessageID: string, - options?: { force?: boolean }, - ) => Promise | void - redo?: ( - userMessageID: string, - options?: { force?: boolean }, - ) => Promise | void - openFile?: (path: string) => void - showInFolder?: (path: string) => void - } + turnChangeActions?: TurnChangeActions active?: boolean status?: SessionStatus onUserInteracted?: () => void @@ -195,7 +177,6 @@ export function SessionTurn( const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] const emptyDiffs: SnapshotFileDiff[] = [] - const emptyTurnFiles: TurnChangeFile[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -307,62 +288,12 @@ export function SessionTurn( ) const turnChange = createMemo(() => props.turnChanges?.[props.messageID]) + const [turnExpanded, setTurnExpanded] = createSignal([]) const turnInProgress = createMemo(() => { const messages = assistantMessages() if (!messages.length) return false return messages.some((item) => typeof item.time.completed !== "number") }) - const turnFiles = createMemo(() => turnChange()?.files ?? emptyTurnFiles) - const turnEdited = createMemo(() => turnFiles().length) - const turnAdditions = createMemo(() => turnFiles().reduce((sum, file) => sum + (file.additions ?? 0), 0)) - const turnDeletions = createMemo(() => turnFiles().reduce((sum, file) => sum + (file.deletions ?? 0), 0)) - const [turnExpanded, setTurnExpanded] = createSignal([]) - const [confirmAction, setConfirmAction] = createSignal<"undo" | "redo" | undefined>() - let confirmTimer: ReturnType | undefined - const resetConfirm = () => { - if (confirmTimer) clearTimeout(confirmTimer) - confirmTimer = undefined - setConfirmAction(undefined) - } - const primeConfirm = (action: "undo" | "redo") => { - if (confirmAction() === action) return true - setConfirmAction(action) - if (confirmTimer) clearTimeout(confirmTimer) - confirmTimer = setTimeout(resetConfirm, 3000) - return false - } - onCleanup(resetConfirm) - const mutateTurnChange = async () => { - const current = turnChange() - const id = current?.messageID - if (!id) return - const action = turnChangeAction(current) - if (!action || !hasTurnChangeActionHandler(current, props.turnChangeActions)) return - if (!primeConfirm(action)) return - resetConfirm() - if (action === "undo") await props.turnChangeActions?.undo?.(id) - else await props.turnChangeActions?.redo?.(id) - } - const turnActionLabel = createMemo(() => { - const current = turnChange() - const action = turnChangeAction(current) - if (!action) return "" - const base = action === "undo" ? i18n.t("ui.sessionTurn.turnChanges.undo") : i18n.t("ui.sessionTurn.turnChanges.reapply") - return confirmAction() === action - ? action === "undo" - ? i18n.t("ui.sessionTurn.turnChanges.undoConfirm") - : i18n.t("ui.sessionTurn.turnChanges.redoConfirm") - : base - }) - const isUndoneTurn = createMemo(() => { - const current = turnChange() - return !!(current && current.redoAvailable && !current.undoAvailable) - }) - const turnStatusLabel = (status: TurnChangeFile["status"]) => { - if (status === "added") return i18n.t("ui.sessionTurn.turnChanges.status.added") - if (status === "deleted") return i18n.t("ui.sessionTurn.turnChanges.status.deleted") - return i18n.t("ui.sessionTurn.turnChanges.status.updated") - } const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) const divider = createMemo(() => { if (compaction()) return i18n.t("ui.messagePart.compaction") @@ -403,6 +334,11 @@ export function SessionTurn( return data.store.session_status[props.sessionID] ?? idle }) const working = createMemo(() => status().type !== "idle" && active()) + const visibleTurnChange = createMemo(() => { + const current = turnChange() + if (!hasVisibleTurnChanges(current) || working() || turnInProgress()) return + return current + }) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) const assistantCopyPartID = createMemo(() => { @@ -508,143 +444,17 @@ export function SessionTurn( - -
-
-
- - {i18n.t( - turnEdited() === 1 - ? "ui.sessionTurn.turnChanges.summary.one" - : "ui.sessionTurn.turnChanges.summary.other", - { count: turnEdited() }, - )} - - +{turnAdditions()} - -{turnDeletions()} - 0}> - - {i18n.t("ui.sessionTurn.turnChanges.omitted", { count: turnChange()?.omittedCount ?? 0 })} - - - - - {i18n.t("ui.sessionTurn.turnChanges.undone")} - - -
- - - -
-
- - {(file) => { - const expanded = createMemo(() => turnExpanded().includes(file.path)) - const toggle = () => { - if (!file.expandable) return - setTurnExpanded((current) => - current.includes(file.path) - ? current.filter((item) => item !== file.path) - : [...current, file.path], - ) - } - const view = createMemo(() => - file.patch - ? normalize({ - file: file.path, - patch: file.patch, - additions: file.additions ?? 0, - deletions: file.deletions ?? 0, - status: file.status, - }) - : undefined, - ) - return ( -
-
- - - - - - {file.path} - - {turnStatusLabel(file.status)}} - > - +{file.additions ?? 0} - -{file.deletions ?? 0} - - - - {i18n.t("ui.sessionTurn.turnChanges.unrestorable")} - - - - event.stopPropagation()}> - - file.openPath && props.turnChangeActions?.openFile?.(file.openPath)} - /> - - - - file.openPath && - props.turnChangeActions?.showInFolder?.( - file.status === "deleted" ? getDirectory(file.openPath) : file.openPath, - ) - } - /> - - -
- - {(diff) => ( -
- -
- )} -
-
- ) - }} -
-
- 0}> -
- {i18n.t("ui.sessionTurn.turnChanges.skippedNotice", { - count: turnChange()?.skippedCount ?? 0, - })} -
-
-
+ + {(display) => ( + setTurnExpanded(value)} + /> + )} - 0 && !working()}> + 0 && !working()}>