Merge remote-tracking branch 'upstream/dev' into refactor-shells
# Conflicts: # packages/app/e2e/prompt/prompt-shell.spec.ts # packages/opencode/src/tool/bash.ts # packages/opencode/src/tool/registry.ts
This commit is contained in:
@@ -7,6 +7,21 @@
|
||||
gap: 0px;
|
||||
justify-content: flex-start;
|
||||
|
||||
&[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&[data-hide-details="true"] {
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
@@ -165,3 +180,80 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-card"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-component="task-tool-spinner"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-weak);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
[data-component="task-tool-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface BasicToolProps {
|
||||
locked?: boolean
|
||||
animated?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
|
||||
triggerHref?: string
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||
@@ -121,74 +124,101 @@ export function BasicTool(props: BasicToolProps) {
|
||||
setState("open", value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(trigger) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
const trigger = () => (
|
||||
<div
|
||||
data-component="tool-trigger"
|
||||
data-clickable={props.clickable ? "true" : undefined}
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
>
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(title) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
classList={{
|
||||
[title().titleClass ?? ""]: !!title().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={title().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={title().subtitle}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
classList={{
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
[title().subtitleClass ?? ""]: !!title().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={trigger().title} active={pending()} />
|
||||
{title().subtitle}
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={trigger().subtitle}>
|
||||
</Show>
|
||||
<Show when={title().args?.length}>
|
||||
<For each={title().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
[title().argsClass ?? ""]: !!title().argsClass,
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
{arg}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && trigger().action}>
|
||||
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && title().action}>
|
||||
<span data-slot="basic-tool-tool-action">{title().action}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||
<Show
|
||||
when={props.triggerHref}
|
||||
fallback={
|
||||
<Collapsible.Trigger
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
onClick={props.onTriggerClick}
|
||||
>
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
{(href) => (
|
||||
<Collapsible.Trigger
|
||||
as="a"
|
||||
href={href()}
|
||||
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||
onClick={props.onTriggerClick}
|
||||
>
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&[data-hide-details="true"] {
|
||||
height: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
|
||||
@@ -16,6 +16,7 @@ export type FileMediaOptions = {
|
||||
current?: unknown
|
||||
before?: unknown
|
||||
after?: unknown
|
||||
deleted?: boolean
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
onLoad?: () => void
|
||||
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
|
||||
@@ -49,6 +50,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || !k) return false
|
||||
if (media.deleted) return true
|
||||
if (k === "svg") return false
|
||||
if (media.current !== undefined) return false
|
||||
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
|
||||
type SSRDiffFileProps<T> = DiffFileProps<T> & {
|
||||
preloadedDiff: PreloadMultiFileDiffResult<T>
|
||||
preloadedDiff: DiffPreload<T>
|
||||
}
|
||||
|
||||
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
@@ -32,6 +34,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
const [local, others] = splitProps(props, [
|
||||
"mode",
|
||||
"media",
|
||||
"fileDiff",
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
@@ -90,12 +93,13 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff,
|
||||
...(local.preloadedDiff.options ?? {}),
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
@@ -105,7 +109,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff,
|
||||
...(local.preloadedDiff.options ?? {}),
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
@@ -114,13 +118,24 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
// @ts-expect-error private field required for hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate({
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: local.annotations ?? [],
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
})
|
||||
fileDiffInstance.hydrate(
|
||||
local.fileDiff
|
||||
? {
|
||||
fileDiff: local.fileDiff,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
}
|
||||
: {
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
},
|
||||
)
|
||||
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
type DiffLineAnnotation,
|
||||
type FileContents,
|
||||
type FileDiffMetadata,
|
||||
File as PierreFile,
|
||||
type FileDiffOptions,
|
||||
FileDiff,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
VirtualizedFileDiff,
|
||||
Virtualizer,
|
||||
} from "@pierre/diffs"
|
||||
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
@@ -80,15 +81,29 @@ export type TextFileProps<T = {}> = FileOptions<T> &
|
||||
preloadedDiff?: PreloadMultiFileDiffResult<T>
|
||||
}
|
||||
|
||||
export type DiffFileProps<T = {}> = FileDiffOptions<T> &
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
|
||||
type DiffBaseProps<T> = FileDiffOptions<T> &
|
||||
SharedProps<T> & {
|
||||
mode: "diff"
|
||||
before: FileContents
|
||||
after: FileContents
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
preloadedDiff?: PreloadMultiFileDiffResult<T>
|
||||
preloadedDiff?: DiffPreload<T>
|
||||
}
|
||||
|
||||
type DiffPairProps<T> = DiffBaseProps<T> & {
|
||||
before: FileContents
|
||||
after: FileContents
|
||||
fileDiff?: undefined
|
||||
}
|
||||
|
||||
type DiffPatchProps<T> = DiffBaseProps<T> & {
|
||||
fileDiff: FileDiffMetadata
|
||||
before?: undefined
|
||||
after?: undefined
|
||||
}
|
||||
|
||||
export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
|
||||
|
||||
export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
|
||||
|
||||
const sharedKeys = [
|
||||
@@ -108,7 +123,7 @@ const sharedKeys = [
|
||||
] as const
|
||||
|
||||
const textKeys = ["file", ...sharedKeys] as const
|
||||
const diffKeys = ["before", "after", ...sharedKeys] as const
|
||||
const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared viewer hook
|
||||
@@ -976,6 +991,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
||||
|
||||
const large = createMemo(() => {
|
||||
if (local.fileDiff) {
|
||||
const before = local.fileDiff.deletionLines.join("")
|
||||
const after = local.fileDiff.additionLines.join("")
|
||||
return Math.max(before.length, after.length) > 500_000
|
||||
}
|
||||
|
||||
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
return Math.max(before.length, after.length) > 500_000
|
||||
@@ -1054,6 +1075,17 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
instance = value
|
||||
},
|
||||
draw: (value) => {
|
||||
if (local.fileDiff) {
|
||||
value.render({
|
||||
fileDiff: local.fileDiff,
|
||||
lineAnnotations: [],
|
||||
containerWrapper: viewer.container,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!local.before || !local.after) return
|
||||
|
||||
value.render({
|
||||
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
|
||||
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
ReasoningPart,
|
||||
Session,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
@@ -49,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Spinner } from "./spinner"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { AnimatedCountList } from "./tool-count-summary"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
@@ -276,6 +278,47 @@ function agentTitle(i18n: UiI18n, type?: string) {
|
||||
return i18n.t("ui.tool.agent", { type })
|
||||
}
|
||||
|
||||
const agentTones: Record<string, string> = {
|
||||
ask: "var(--icon-agent-ask-base)",
|
||||
build: "var(--icon-agent-build-base)",
|
||||
docs: "var(--icon-agent-docs-base)",
|
||||
plan: "var(--icon-agent-plan-base)",
|
||||
}
|
||||
|
||||
const agentPalette = [
|
||||
"var(--icon-agent-ask-base)",
|
||||
"var(--icon-agent-build-base)",
|
||||
"var(--icon-agent-docs-base)",
|
||||
"var(--icon-agent-plan-base)",
|
||||
"var(--syntax-info)",
|
||||
"var(--syntax-success)",
|
||||
"var(--syntax-warning)",
|
||||
"var(--syntax-property)",
|
||||
"var(--syntax-constant)",
|
||||
"var(--text-diff-add-base)",
|
||||
"var(--text-diff-delete-base)",
|
||||
"var(--icon-warning-base)",
|
||||
]
|
||||
|
||||
function tone(name: string) {
|
||||
let hash = 0
|
||||
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||
return agentPalette[hash % agentPalette.length]
|
||||
}
|
||||
|
||||
function taskAgent(
|
||||
raw: unknown,
|
||||
list?: readonly { name: string; color?: string }[],
|
||||
): { name?: string; color?: string } {
|
||||
if (typeof raw !== "string" || !raw) return {}
|
||||
const key = raw.toLowerCase()
|
||||
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
|
||||
return {
|
||||
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
|
||||
color: item?.color ?? agentTones[key] ?? tone(key),
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
||||
const i18n = useI18n()
|
||||
if (SHELL.has(tool)) {
|
||||
@@ -406,6 +449,27 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) =
|
||||
return `${path.slice(0, idx)}/session/${id}`
|
||||
}
|
||||
|
||||
function currentSession(path: string) {
|
||||
return path.match(/\/session\/([^/?#]+)/)?.[1]
|
||||
}
|
||||
|
||||
function taskSession(
|
||||
input: Record<string, any>,
|
||||
path: string,
|
||||
sessions: Session[] | undefined,
|
||||
agents?: readonly { name: string; color?: string }[],
|
||||
) {
|
||||
const parentID = currentSession(path)
|
||||
if (!parentID) return
|
||||
const description = typeof input.description === "string" ? input.description : ""
|
||||
const agent = taskAgent(input.subagent_type, agents).name
|
||||
return (sessions ?? [])
|
||||
.filter((session) => session.parentID === parentID && !session.time?.archived)
|
||||
.filter((session) => (description ? session.title.startsWith(description) : true))
|
||||
.filter((session) => (agent ? session.title.includes(`@${agent}`) : true))
|
||||
.sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
|
||||
}
|
||||
|
||||
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
||||
const HIDDEN_TOOLS = new Set(["todowrite"])
|
||||
|
||||
@@ -1682,13 +1746,14 @@ ToolRegistry.register({
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const location = useLocation()
|
||||
const childSessionId = () => props.metadata.sessionId as string | undefined
|
||||
const type = createMemo(() => {
|
||||
const raw = props.input.subagent_type
|
||||
if (typeof raw !== "string" || !raw) return undefined
|
||||
return raw[0]!.toUpperCase() + raw.slice(1)
|
||||
const childSessionId = createMemo(() => {
|
||||
const value = props.metadata.sessionId
|
||||
if (typeof value === "string" && value) return value
|
||||
return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
|
||||
})
|
||||
const title = createMemo(() => agentTitle(i18n, type()))
|
||||
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
|
||||
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
|
||||
const tone = createMemo(() => agent().color)
|
||||
const subtitle = createMemo(() => {
|
||||
const value = props.input.description
|
||||
if (typeof value === "string" && value) return value
|
||||
@@ -1697,37 +1762,62 @@ ToolRegistry.register({
|
||||
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
||||
|
||||
const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
|
||||
const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
|
||||
|
||||
const titleContent = () => <TextShimmer text={title()} active={running()} />
|
||||
const open = () => {
|
||||
const id = childSessionId()
|
||||
if (!id) return
|
||||
if (data.navigateToSession) {
|
||||
data.navigateToSession(id)
|
||||
return
|
||||
}
|
||||
const value = href()
|
||||
if (value) window.location.assign(value)
|
||||
}
|
||||
|
||||
const navigate = (event: MouseEvent) => {
|
||||
if (!data.navigateToSession) return
|
||||
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
open()
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title" class="capitalize agent-title">
|
||||
{titleContent()}
|
||||
</span>
|
||||
<Show when={subtitle()}>
|
||||
<Switch>
|
||||
<Match when={href()}>
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="clickable subagent-link"
|
||||
href={href()!}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{subtitle()}
|
||||
</a>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
<div data-component="task-tool-card">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<Show when={running()}>
|
||||
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
|
||||
<Spinner />
|
||||
</span>
|
||||
</Show>
|
||||
<span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={subtitle()}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={clickable()}>
|
||||
<div data-component="task-tool-action">
|
||||
<Icon name="square-arrow-top-right" size="small" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
|
||||
return (
|
||||
<BasicTool
|
||||
icon="task"
|
||||
status={props.status}
|
||||
trigger={trigger()}
|
||||
hideDetails
|
||||
triggerHref={href()}
|
||||
clickable={clickable()}
|
||||
onTriggerClick={navigate}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalize, text } from "./session-diff"
|
||||
|
||||
describe("session diff", () => {
|
||||
test("keeps unified patch content", () => {
|
||||
const diff = {
|
||||
file: "a.ts",
|
||||
patch:
|
||||
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(view.patch).toBe(diff.patch)
|
||||
expect(view.fileDiff.name).toBe("a.ts")
|
||||
expect(text(view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
|
||||
test("converts legacy content into a patch", () => {
|
||||
const diff = {
|
||||
file: "a.ts",
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
|
||||
expect(text(view, "deletions")).toBe("one\n")
|
||||
expect(text(view, "additions")).toBe("two\n")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type LegacyDiff = {
|
||||
file: string
|
||||
patch?: string
|
||||
before?: string
|
||||
after?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff
|
||||
|
||||
export type ViewDiff = {
|
||||
file: string
|
||||
patch: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
fileDiff: FileDiffMetadata
|
||||
}
|
||||
|
||||
const cache = new Map<string, FileDiffMetadata>()
|
||||
|
||||
function empty(file: string, key: string) {
|
||||
return {
|
||||
name: file,
|
||||
type: "change",
|
||||
hunks: [],
|
||||
splitLineCount: 0,
|
||||
unifiedLineCount: 0,
|
||||
isPartial: true,
|
||||
deletionLines: [],
|
||||
additionLines: [],
|
||||
cacheKey: key,
|
||||
} satisfies FileDiffMetadata
|
||||
}
|
||||
|
||||
function patch(diff: ReviewDiff) {
|
||||
if (typeof diff.patch === "string") return diff.patch
|
||||
return formatPatch(
|
||||
structuredPatch(
|
||||
diff.file,
|
||||
diff.file,
|
||||
"before" in diff && typeof diff.before === "string" ? diff.before : "",
|
||||
"after" in diff && typeof diff.after === "string" ? diff.after : "",
|
||||
"",
|
||||
"",
|
||||
{ context: Number.MAX_SAFE_INTEGER },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function file(file: string, patch: string) {
|
||||
const hit = cache.get(patch)
|
||||
if (hit) return hit
|
||||
|
||||
const key = sampledChecksum(patch) ?? file
|
||||
const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key)
|
||||
cache.set(patch, value)
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalize(diff: ReviewDiff): ViewDiff {
|
||||
const next = patch(diff)
|
||||
return {
|
||||
file: diff.file,
|
||||
patch: next,
|
||||
additions: diff.additions,
|
||||
deletions: diff.deletions,
|
||||
status: diff.status,
|
||||
fileDiff: file(diff.file, next),
|
||||
}
|
||||
}
|
||||
|
||||
export function text(diff: ViewDiff, side: "deletions" | "additions") {
|
||||
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
|
||||
return diff.fileDiff.additionLines.join("")
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -23,6 +23,7 @@ import { mediaKindFromPath } from "../pierre/media"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
||||
import { createLineCommentController } from "./line-comment-annotations"
|
||||
import type { LineCommentEditorProps } from "./line-comment"
|
||||
import { normalize, text, type ViewDiff } from "./session-diff"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
@@ -61,7 +62,8 @@ export type SessionReviewCommentActions = {
|
||||
|
||||
export type SessionReviewFocus = { file: string; id: string }
|
||||
|
||||
type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||
|
||||
export interface SessionReviewProps {
|
||||
title?: JSX.Element
|
||||
@@ -155,8 +157,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const opened = () => store.opened
|
||||
|
||||
const open = () => props.open ?? store.open
|
||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||
const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
|
||||
const files = createMemo(() => items().map((diff) => diff.file))
|
||||
const grouped = createMemo(() => {
|
||||
const next = new Map<string, SessionReviewComment[]>()
|
||||
for (const comment of props.comments ?? []) {
|
||||
@@ -246,10 +248,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||
|
||||
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
|
||||
const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
|
||||
const side = selectionSide(range)
|
||||
const contents = side === "deletions" ? diff.before : diff.after
|
||||
if (typeof contents !== "string" || contents.length === 0) return undefined
|
||||
const contents = text(diff, side)
|
||||
if (contents.length === 0) return undefined
|
||||
|
||||
return previewSelectedLines(contents, range)
|
||||
}
|
||||
@@ -359,11 +361,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<div class="pb-6">
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
<For each={items()}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
|
||||
const item = createMemo(() => diffs().get(file)!)
|
||||
const file = diff.file
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
@@ -372,9 +373,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||
const afterText = () => (typeof item().after === "string" ? item().after : "")
|
||||
const changedLines = () => item().additions + item().deletions
|
||||
const beforeText = () => text(diff, "deletions")
|
||||
const afterText = () => text(diff, "additions")
|
||||
const changedLines = () => diff.additions + diff.deletions
|
||||
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
||||
|
||||
const tooLarge = createMemo(() => {
|
||||
@@ -385,9 +386,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
const isAdded = () =>
|
||||
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
const isDeleted = () =>
|
||||
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
@@ -425,7 +426,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(item(), selection),
|
||||
preview: selectionPreview(diff, selection),
|
||||
})
|
||||
},
|
||||
onUpdate: ({ id, comment, selection }) => {
|
||||
@@ -434,7 +435,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(item(), selection),
|
||||
preview: selectionPreview(diff, selection),
|
||||
})
|
||||
},
|
||||
onDelete: (comment) => {
|
||||
@@ -513,7 +514,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
<DiffChanges changes={item()} />
|
||||
<DiffChanges changes={diff} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
@@ -527,7 +528,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={item()} />
|
||||
<DiffChanges changes={diff} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
@@ -582,7 +583,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
preloadedDiff={item().preloaded}
|
||||
fileDiff={diff.fileDiff}
|
||||
preloadedDiff={diff.preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
@@ -597,20 +599,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
name: file,
|
||||
contents: typeof item().before === "string" ? item().before : "",
|
||||
}}
|
||||
after={{
|
||||
name: file,
|
||||
contents: typeof item().after === "string" ? item().after : "",
|
||||
}}
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: file,
|
||||
before: item().before,
|
||||
after: item().after,
|
||||
readFile: props.readFile,
|
||||
deleted: diff.status === "deleted",
|
||||
readFile: diff.status === "deleted" ? undefined : props.readFile,
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
@@ -92,33 +92,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs"]
|
||||
> [data-component="collapsible"]
|
||||
> [data-slot="collapsible-trigger"][aria-expanded="true"] {
|
||||
position: sticky;
|
||||
top: var(--sticky-accordion-top, 0px);
|
||||
z-index: 20;
|
||||
height: 40px;
|
||||
padding-bottom: 8px;
|
||||
background-color: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-component="session-turn-diffs-trigger"] {
|
||||
width: 100%;
|
||||
[data-slot="session-turn-diffs-header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-title"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-label"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
@@ -126,28 +108,38 @@
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-count"] {
|
||||
color: var(--text-base);
|
||||
[data-slot="session-turn-diffs-toggle"] {
|
||||
color: var(--text-interactive-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-x-large);
|
||||
line-height: var(--line-height-large);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-meta"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
[data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
margin-left: -6px;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
[data-component="session-turn-diffs-group"][data-show-all] [data-slot="session-turn-diffs-toggle"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="diff-changes"][data-variant="bars"] {
|
||||
transform: translateY(1px);
|
||||
[data-slot="session-turn-diffs-more"] {
|
||||
color: var(--text-weak);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
margin-top: 12px;
|
||||
padding: 0 0 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-link-base);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
AssistantMessage,
|
||||
type SnapshotFileDiff,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionStatus } from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useFileComponent } from "../context/file"
|
||||
@@ -12,7 +17,6 @@ import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Icon } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
@@ -20,6 +24,7 @@ import { SessionRetry } from "./session-retry"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { normalize } from "./session-diff"
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -164,7 +169,7 @@ export function SessionTurn(
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyDiffs: FileDiff[] = []
|
||||
const emptyDiffs: SnapshotFileDiff[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
|
||||
@@ -233,7 +238,7 @@ export function SessionTurn(
|
||||
|
||||
const seen = new Set<string>()
|
||||
return files
|
||||
.reduceRight<FileDiff[]>((result, diff) => {
|
||||
.reduceRight<SnapshotFileDiff[]>((result, diff) => {
|
||||
if (seen.has(diff.file)) return result
|
||||
seen.add(diff.file)
|
||||
result.push(diff)
|
||||
@@ -241,23 +246,20 @@ export function SessionTurn(
|
||||
}, [])
|
||||
.reverse()
|
||||
})
|
||||
const MAX_FILES = 10
|
||||
const edited = createMemo(() => diffs().length)
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
showAll: false,
|
||||
expanded: [] as string[],
|
||||
})
|
||||
const open = () => state.open
|
||||
const showAll = () => state.showAll
|
||||
const expanded = () => state.expanded
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(value, prev) => {
|
||||
if (!value && prev) setState("expanded", [])
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
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(
|
||||
() => {
|
||||
@@ -425,101 +427,96 @@ export function SessionTurn(
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={(value) => setState("open", value)} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) =>
|
||||
setState("expanded", Array.isArray(value) ? value : value ? [value] : [])
|
||||
}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<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": "40px" }}
|
||||
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) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setShown(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
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" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
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>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
<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" size="small" />
|
||||
</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>
|
||||
<Show when={error()}>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
type Data = {
|
||||
agent?: {
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
provider?: ProviderListResponse
|
||||
session: Session[]
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
session_diff_preload?: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict: Record<string, string> = {
|
||||
"ui.sessionReview.title": "Session changes",
|
||||
"ui.sessionReview.title.git": "Git changes",
|
||||
"ui.sessionReview.title.branch": "Branch changes",
|
||||
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||
"ui.sessionReview.diffStyle.split": "Split",
|
||||
@@ -38,6 +40,10 @@ export const dict: Record<string, string> = {
|
||||
"ui.sessionTurn.steps.hide": "Hide steps",
|
||||
"ui.sessionTurn.summary.response": "Response",
|
||||
"ui.sessionTurn.diff.showMore": "Show more changes ({{count}})",
|
||||
"ui.sessionTurn.diffs.changed": "Changed",
|
||||
"ui.sessionTurn.diffs.showAll": "Show all",
|
||||
"ui.sessionTurn.diffs.showLess": "Show less",
|
||||
"ui.sessionTurn.diffs.more": "+{{count}} more files",
|
||||
|
||||
"ui.sessionTurn.retry.retrying": "retrying",
|
||||
"ui.sessionTurn.retry.inSeconds": "in {{seconds}}s",
|
||||
|
||||
Reference in New Issue
Block a user