refactor(ui): isolate session components (#33670)
This commit is contained in:
parent
1ef0fd5d01
commit
04c1730c90
126 changed files with 294 additions and 142 deletions
|
|
@ -1,43 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { patchFiles } from "./apply-patch-file"
|
||||
import { text } from "./session-diff"
|
||||
|
||||
describe("apply patch file", () => {
|
||||
test("parses patch metadata from the server", () => {
|
||||
const file = patchFiles([
|
||||
{
|
||||
filePath: "/tmp/a.ts",
|
||||
relativePath: "a.ts",
|
||||
type: "update",
|
||||
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,
|
||||
},
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.view.fileDiff.name).toBe("a.ts")
|
||||
expect(file?.view.fileDiff.isPartial).toBe(false)
|
||||
expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(file!.view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
|
||||
test("keeps legacy before and after payloads working", () => {
|
||||
const file = patchFiles([
|
||||
{
|
||||
filePath: "/tmp/a.ts",
|
||||
relativePath: "a.ts",
|
||||
type: "update",
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(text(file!.view, "deletions")).toBe("one\n")
|
||||
expect(text(file!.view, "additions")).toBe("two\n")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { normalize, type ViewDiff } from "./session-diff"
|
||||
|
||||
type Kind = "add" | "update" | "delete" | "move"
|
||||
|
||||
type Raw = {
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: Kind
|
||||
patch?: string
|
||||
diff?: string
|
||||
before?: string
|
||||
after?: string
|
||||
additions?: number
|
||||
deletions?: number
|
||||
movePath?: string
|
||||
}
|
||||
|
||||
export type ApplyPatchFile = {
|
||||
filePath: string
|
||||
relativePath: string
|
||||
type: Kind
|
||||
additions: number
|
||||
deletions: number
|
||||
movePath?: string
|
||||
view: ViewDiff
|
||||
}
|
||||
|
||||
function kind(value: unknown) {
|
||||
if (value === "add" || value === "update" || value === "delete" || value === "move") return value
|
||||
}
|
||||
|
||||
function status(type: Kind): "added" | "deleted" | "modified" {
|
||||
if (type === "add") return "added"
|
||||
if (type === "delete") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
|
||||
if (!raw || typeof raw !== "object") return
|
||||
|
||||
const value = raw as Raw
|
||||
const type = kind(value.type)
|
||||
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
|
||||
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
|
||||
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
|
||||
const before = typeof value.before === "string" ? value.before : undefined
|
||||
const after = typeof value.after === "string" ? value.after : undefined
|
||||
|
||||
if (!type || !filePath || !relativePath) return
|
||||
if (!patch && before === undefined && after === undefined) return
|
||||
|
||||
const additions = typeof value.additions === "number" ? value.additions : 0
|
||||
const deletions = typeof value.deletions === "number" ? value.deletions : 0
|
||||
const movePath = typeof value.movePath === "string" ? value.movePath : undefined
|
||||
|
||||
return {
|
||||
filePath,
|
||||
relativePath,
|
||||
type,
|
||||
additions,
|
||||
deletions,
|
||||
movePath,
|
||||
view: normalize({
|
||||
file: relativePath,
|
||||
patch,
|
||||
before,
|
||||
after,
|
||||
additions,
|
||||
deletions,
|
||||
status: status(type),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function patchFiles(raw: unknown) {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
[data-component="tool-trigger"] {
|
||||
content-visibility: auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 0px;
|
||||
justify-content: flex-start;
|
||||
|
||||
&[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
max-width: calc(100% - 24px);
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-indicator"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-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);
|
||||
color: var(--text-strong);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&.agent-title {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.subagent-link {
|
||||
color: var(--text-interactive-base);
|
||||
text-decoration: none;
|
||||
text-underline-offset: 2px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-interactive-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--text-interactive-base);
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: var(--text-interactive-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-arg"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { createSignal } from "solid-js"
|
||||
import * as mod from "./basic-tool"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Expandable tool panel with a structured trigger and optional details.
|
||||
|
||||
Use structured triggers for consistent layout; custom triggers allowed.
|
||||
|
||||
### API
|
||||
- Required: \`icon\` and \`trigger\` (structured or custom JSX).
|
||||
- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`.
|
||||
|
||||
### Variants and states
|
||||
- Pending/running status animates the title via TextShimmer.
|
||||
|
||||
### Behavior
|
||||
- Uses Collapsible; can defer content rendering until open.
|
||||
- Locked state prevents closing.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm trigger semantics and aria labeling.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="tool-trigger"\` and related slots.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Basic Tool",
|
||||
mod,
|
||||
args: {
|
||||
icon: "mcp",
|
||||
defaultOpen: true,
|
||||
trigger: {
|
||||
title: "Basic Tool",
|
||||
subtitle: "Example subtitle",
|
||||
args: ["--flag", "value"],
|
||||
},
|
||||
children: "Details content",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Basic Tool",
|
||||
id: "components-basic-tool",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Pending = {
|
||||
args: {
|
||||
status: "pending",
|
||||
trigger: {
|
||||
title: "Running tool",
|
||||
subtitle: "Working...",
|
||||
},
|
||||
children: "Progress details",
|
||||
},
|
||||
}
|
||||
|
||||
export const Locked = {
|
||||
args: {
|
||||
locked: true,
|
||||
trigger: {
|
||||
title: "Locked tool",
|
||||
subtitle: "Cannot close",
|
||||
},
|
||||
children: "Locked details",
|
||||
},
|
||||
}
|
||||
|
||||
export const Deferred = {
|
||||
args: {
|
||||
defer: true,
|
||||
defaultOpen: false,
|
||||
trigger: {
|
||||
title: "Deferred tool",
|
||||
subtitle: "Content mounts on open",
|
||||
},
|
||||
children: "Deferred content",
|
||||
},
|
||||
}
|
||||
|
||||
export const ForceOpen = {
|
||||
args: {
|
||||
forceOpen: true,
|
||||
trigger: {
|
||||
title: "Forced open",
|
||||
subtitle: "Cannot close",
|
||||
},
|
||||
children: "Forced content",
|
||||
},
|
||||
}
|
||||
|
||||
export const HideDetails = {
|
||||
args: {
|
||||
hideDetails: true,
|
||||
trigger: {
|
||||
title: "Summary only",
|
||||
subtitle: "Details hidden",
|
||||
},
|
||||
children: "Hidden content",
|
||||
},
|
||||
}
|
||||
|
||||
export const SubtitleAction = {
|
||||
render: () => {
|
||||
const [message, setMessage] = createSignal("Subtitle not clicked")
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{message()}</div>
|
||||
<mod.BasicTool
|
||||
icon="mcp"
|
||||
trigger={{ title: "Clickable subtitle", subtitle: "Click me" }}
|
||||
onSubtitleClick={() => setMessage("Subtitle clicked")}
|
||||
>
|
||||
Subtitle action details
|
||||
</mod.BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls } from "motion"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import type { IconProps } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
|
||||
export type TriggerTitle = {
|
||||
title: string
|
||||
titleClass?: string
|
||||
subtitle?: string
|
||||
subtitleClass?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
return (
|
||||
typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
|
||||
)
|
||||
}
|
||||
|
||||
export interface BasicToolProps {
|
||||
icon: IconProps["name"]
|
||||
trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element)
|
||||
children?: JSX.Element
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
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 }
|
||||
const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
|
||||
let deferredFrame: number | undefined
|
||||
|
||||
function flushDeferredMounts() {
|
||||
while (deferredMounts.length > 0) {
|
||||
// Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
|
||||
// Pop from the end so heavy default-open bodies near the bottom become interactive first.
|
||||
const item = deferredMounts.pop()!
|
||||
if (item.active) {
|
||||
deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
|
||||
item.fn()
|
||||
return
|
||||
}
|
||||
}
|
||||
deferredFrame = undefined
|
||||
}
|
||||
|
||||
function scheduleDeferredFlush() {
|
||||
if (deferredFrame !== undefined) return
|
||||
deferredFrame = requestAnimationFrame(() => {
|
||||
deferredFrame = requestAnimationFrame(flushDeferredMounts)
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleDeferredMount(fn: () => void) {
|
||||
const item = { active: true, fn }
|
||||
deferredMounts.push(item)
|
||||
scheduleDeferredFlush()
|
||||
return () => {
|
||||
item.active = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFrameMount(fn: () => void) {
|
||||
const frame = requestAnimationFrame(fn)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const [state, setState] = createStore({
|
||||
open: props.defaultOpen ?? false,
|
||||
ready: !props.defer && (props.defaultOpen ?? false),
|
||||
})
|
||||
const open = () => props.open ?? state.open
|
||||
const ready = () => state.ready
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const hasChildren = () => (props.defer ? "children" in props : props.children)
|
||||
const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined
|
||||
|
||||
let cancelReady: (() => void) | undefined
|
||||
|
||||
const cancel = () => {
|
||||
cancelReady?.()
|
||||
cancelReady = undefined
|
||||
}
|
||||
|
||||
const scheduleReady = (initial = false) => {
|
||||
cancel()
|
||||
cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
|
||||
cancelReady = undefined
|
||||
if (!open()) return
|
||||
setState("ready", true)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(cancel)
|
||||
|
||||
onMount(() => {
|
||||
if (props.defer && open()) scheduleReady(true)
|
||||
})
|
||||
|
||||
const setOpen = (value: boolean) => {
|
||||
if (props.open === undefined) setState("open", value)
|
||||
props.onOpenChange?.(value)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.forceOpen) return
|
||||
if (open()) return
|
||||
setOpen(true)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(value) => {
|
||||
if (!props.defer) return
|
||||
if (!value) {
|
||||
cancel()
|
||||
setState("ready", false)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleReady()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Animated height for collapsible open/close
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
const initialOpen = open()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!props.animated || !contentRef) return
|
||||
heightAnim?.stop()
|
||||
if (isOpen) {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
|
||||
void heightAnim.finished.then(() => {
|
||||
if (!contentRef || !open()) return
|
||||
contentRef.style.overflow = "visible"
|
||||
contentRef.style.height = "auto"
|
||||
})
|
||||
} else {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
heightAnim?.stop()
|
||||
})
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (pending()) return
|
||||
if (props.locked && !value) return
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
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={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
|
||||
<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-subtitle"
|
||||
classList={{
|
||||
[title().subtitleClass ?? ""]: !!title().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={title().args?.length}>
|
||||
<For each={title().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[title().argsClass ?? ""]: !!title().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</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>
|
||||
</div>
|
||||
<Show when={hasChildren() && !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 && hasChildren() && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-slot="collapsible-content"
|
||||
data-animated
|
||||
style={{
|
||||
height: initialOpen ? "auto" : "0px",
|
||||
overflow: initialOpen ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function label(input: Record<string, unknown> | undefined) {
|
||||
const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
|
||||
return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
|
||||
}
|
||||
|
||||
function args(input: Record<string, unknown> | undefined) {
|
||||
if (!input) return []
|
||||
const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skip.has(key))
|
||||
.flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [`${key}=${value}`]
|
||||
if (typeof value === "number") return [`${key}=${value}`]
|
||||
if (typeof value === "boolean") return [`${key}=${value}`]
|
||||
return []
|
||||
})
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
export function GenericTool(props: {
|
||||
tool: string
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
input?: Record<string, unknown>
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{
|
||||
title: i18n.t("ui.basicTool.called", { tool: props.tool }),
|
||||
subtitle: label(props.input),
|
||||
args: args(props.input),
|
||||
}}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import * as mod from "./dock-prompt"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Docked prompt layout for questions and permission requests.
|
||||
|
||||
Use with form controls or confirmation buttons in the footer.
|
||||
|
||||
### API
|
||||
- Required: \`kind\` (question | permission), \`header\`, \`children\`, \`footer\`.
|
||||
- Optional: \`ref\` for measuring or focus management.
|
||||
|
||||
### Variants and states
|
||||
- Question and permission layouts (data attributes).
|
||||
|
||||
### Behavior
|
||||
- Pure layout component; behavior handled by parent.
|
||||
|
||||
### Accessibility
|
||||
- Ensure header and footer content provide clear context and actions.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="dock-prompt"\` with kind data attribute.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/DockPrompt",
|
||||
mod,
|
||||
args: {
|
||||
kind: "question",
|
||||
header: "Header",
|
||||
children: "Prompt content",
|
||||
footer: "Footer",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/DockPrompt",
|
||||
id: "components-dock-prompt",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Permission = {
|
||||
args: {
|
||||
kind: "permission",
|
||||
header: "Allow access?",
|
||||
children: "This action needs permission to proceed.",
|
||||
footer: "Approve or deny",
|
||||
},
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import type { JSX } from "solid-js"
|
||||
import { DockShell, DockTray } from "./dock-surface"
|
||||
|
||||
export function DockPrompt(props: {
|
||||
kind: "question" | "permission"
|
||||
header: JSX.Element
|
||||
children: JSX.Element
|
||||
footer: JSX.Element
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
}) {
|
||||
const slot = (name: string) => `${props.kind}-${name}`
|
||||
|
||||
return (
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
||||
<DockShell data-slot={slot("body")}>
|
||||
<div data-slot={slot("header")}>{props.header}</div>
|
||||
<div data-slot={slot("content")}>{props.children}</div>
|
||||
</DockShell>
|
||||
<DockTray data-slot={slot("footer")}>{props.footer}</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import {
|
||||
dataUrlFromMediaValue,
|
||||
hasMediaValue,
|
||||
isBinaryContent,
|
||||
mediaKindFromPath,
|
||||
normalizeMimeType,
|
||||
svgTextFromValue,
|
||||
} from "../pierre/media"
|
||||
|
||||
export type FileMediaOptions = {
|
||||
mode?: "auto" | "off"
|
||||
path?: string
|
||||
current?: unknown
|
||||
before?: unknown
|
||||
after?: unknown
|
||||
deleted?: boolean
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
onLoad?: () => void
|
||||
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
|
||||
}
|
||||
|
||||
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
|
||||
if (cfg.current !== undefined) return cfg.current
|
||||
if (mode === "image") return cfg.after ?? cfg.before
|
||||
return cfg.after ?? cfg.before
|
||||
}
|
||||
|
||||
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
const cfg = () => props.media
|
||||
const kind = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return
|
||||
return mediaKindFromPath(media.path)
|
||||
})
|
||||
|
||||
const isBinary = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return false
|
||||
if (kind()) return false
|
||||
return isBinaryContent(media.current as any)
|
||||
})
|
||||
|
||||
const onLoad = () => props.media?.onLoad?.()
|
||||
|
||||
const deleted = createMemo(() => {
|
||||
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)
|
||||
})
|
||||
|
||||
const direct = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
return dataUrlFromMediaValue(mediaValue(media, k), k)
|
||||
})
|
||||
|
||||
const request = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
if (media.current !== undefined) return
|
||||
if (deleted()) return
|
||||
if (direct()) return
|
||||
if (!media.path || !media.readFile) return
|
||||
|
||||
return {
|
||||
key: `${k}:${media.path}`,
|
||||
kind: k,
|
||||
path: media.path,
|
||||
readFile: media.readFile,
|
||||
onError: media.onError,
|
||||
}
|
||||
})
|
||||
|
||||
const [loaded] = createResource(request, async (input) => {
|
||||
return input.readFile(input.path).then(
|
||||
(result) => {
|
||||
const src = dataUrlFromMediaValue(result as any, input.kind)
|
||||
if (!src) {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
}
|
||||
|
||||
return {
|
||||
key: input.key,
|
||||
src,
|
||||
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
|
||||
}
|
||||
},
|
||||
() => {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const remote = createMemo(() => {
|
||||
const input = request()
|
||||
const value = loaded()
|
||||
if (!input || !value || value.key !== input.key) return
|
||||
return value
|
||||
})
|
||||
|
||||
const src = createMemo(() => {
|
||||
const value = remote()
|
||||
return direct() ?? (value && "src" in value ? value.src : undefined)
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
if (direct()) return "ready" as const
|
||||
if (!request()) return "idle" as const
|
||||
if (loaded.loading) return "loading" as const
|
||||
if (remote()?.error) return "error" as const
|
||||
if (src()) return "ready" as const
|
||||
return "idle" as const
|
||||
})
|
||||
const audioMime = createMemo(() => {
|
||||
const value = remote()
|
||||
return value && "mime" in value ? value.mime : undefined
|
||||
})
|
||||
|
||||
const svgSource = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return svgTextFromValue(media.current as any)
|
||||
})
|
||||
const svgSrc = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return dataUrlFromMediaValue(media.current as any, "svg")
|
||||
})
|
||||
const svgInvalid = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
if (svgSource() !== undefined) return
|
||||
if (!hasMediaValue(media.current as any)) return
|
||||
return [media.path, media.current] as const
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
svgInvalid,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
cfg()?.onError?.({ kind: "svg" })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kindLabel = (value: "image" | "audio") =>
|
||||
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "audio"}>
|
||||
<Show
|
||||
when={src()}
|
||||
fallback={(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
|
||||
const label = kindLabel(k)
|
||||
|
||||
if (deleted()) {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "loading") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "error") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.error", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
>
|
||||
{(value) => {
|
||||
const k = kind()
|
||||
if (k !== "image" && k !== "audio") return props.fallback()
|
||||
if (k === "image") {
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
|
||||
<source src={value()} type={audioMime()} />
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={kind() === "svg"}>
|
||||
{(() => {
|
||||
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
|
||||
<Show when={svgSrc()}>
|
||||
{(value) => (
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={isBinary()}>
|
||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
|
||||
<div class="text-14-semibold text-text-strong">
|
||||
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{(() => {
|
||||
const path = cfg()?.path
|
||||
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
|
||||
return i18n.t("ui.fileMedia.binary.description.path", { path })
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>{props.fallback()}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { Portal } from "solid-js/web"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
export function FileSearchBar(props: {
|
||||
pos: () => { top: number; right: number }
|
||||
query: () => string
|
||||
index: () => number
|
||||
count: () => number
|
||||
setInput: (el: HTMLInputElement) => void
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onClose: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
|
||||
style={{
|
||||
top: `${props.pos().top}px`,
|
||||
right: `${props.pos().right}px`,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
|
||||
<input
|
||||
ref={props.setInput}
|
||||
placeholder={i18n.t("ui.fileSearch.placeholder")}
|
||||
value={props.query()}
|
||||
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
|
||||
/>
|
||||
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
|
||||
{props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label={i18n.t("ui.fileSearch.previousMatch")}
|
||||
onClick={props.onPrev}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" class="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label={i18n.t("ui.fileSearch.nextMatch")}
|
||||
onClick={props.onNext}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
|
||||
aria-label={i18n.t("ui.fileSearch.close")}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
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"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { fixDiffSelection } from "../pierre/diff-selection"
|
||||
import {
|
||||
applyViewerScheme,
|
||||
clearReadyWatcher,
|
||||
createReadyWatcher,
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
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: DiffPreload<T>
|
||||
}
|
||||
|
||||
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"mode",
|
||||
"media",
|
||||
"fileDiff",
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
])
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixDiffSelection(getRoot(), range ?? null)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
notifyShadowReady({
|
||||
state: ready,
|
||||
container,
|
||||
getRoot,
|
||||
isReady: (root) => root.querySelector("[data-line]") != null,
|
||||
settleFrames: 1,
|
||||
onReady: () => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
local.onRendered?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
// @ts-expect-error private field required for hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate(
|
||||
local.fileDiff
|
||||
? {
|
||||
fileDiff: local.fileDiff,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
}
|
||||
: {
|
||||
oldFile: local.before
|
||||
? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" }
|
||||
: local.before,
|
||||
newFile: local.after
|
||||
? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" }
|
||||
: local.after,
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
},
|
||||
)
|
||||
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
diff.setLineAnnotations(local.annotations ?? [])
|
||||
diff.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="file"
|
||||
data-mode="diff"
|
||||
style={styleVariables}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
ref={container}
|
||||
>
|
||||
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
|
||||
<Show when={isServer}>
|
||||
<template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileSSRProps<T = {}> = FileProps<T>
|
||||
|
||||
export function FileSSR<T>(props: FileSSRProps<T>) {
|
||||
if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
|
||||
return DiffSSRViewer(props as SSRDiffFileProps<T>)
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
[data-component="file"] {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
[data-timeline-row] [data-component="file"] {
|
||||
content-visibility: visible;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="text"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="diff"] {
|
||||
[data-slot="diff-hunk-separator-line-number"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: var(--surface-diff-hidden-strong);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="diff-hunk-separator-line-number-icon"] {
|
||||
aspect-ratio: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="diff-hunk-separator-content"] {
|
||||
position: sticky;
|
||||
background-color: var(--surface-diff-hidden-base);
|
||||
color: var(--text-base);
|
||||
width: var(--diffs-column-content-width);
|
||||
left: var(--diffs-column-number-width);
|
||||
padding-left: 8px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
|
||||
[data-slot="diff-hunk-separator-content-span"] {
|
||||
mix-blend-mode: var(--text-mix-blend-mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,581 +0,0 @@
|
|||
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
| { kind: "draft"; key: string; range: SelectedLineRange }
|
||||
|
||||
export type LineCommentAnnotation<T> = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
metadata: LineCommentAnnotationMeta<T>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsProps<T> = {
|
||||
comments: Accessor<T[]>
|
||||
getCommentId: (comment: T) => string
|
||||
getCommentSelection: (comment: T) => SelectedLineRange
|
||||
draftRange: Accessor<SelectedLineRange | null>
|
||||
draftKey: Accessor<string>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
type LineCommentStateProps<T> = {
|
||||
opened: Accessor<T | null>
|
||||
setOpened: (id: T | null) => void
|
||||
selected: Accessor<SelectedLineRange | null>
|
||||
setSelected: (range: SelectedLineRange | null) => void
|
||||
commenting: Accessor<SelectedLineRange | null>
|
||||
setCommenting: (range: SelectedLineRange | null) => void
|
||||
syncSelected?: (range: SelectedLineRange | null) => void
|
||||
hoverSelected?: (range: SelectedLineRange) => void
|
||||
}
|
||||
|
||||
type LineCommentShape = {
|
||||
id: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}
|
||||
|
||||
type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
onDelete?: (comment: T) => void
|
||||
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
|
||||
editSubmitLabel?: string
|
||||
onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
|
||||
cancelDraftOnCommentToggle?: boolean
|
||||
clearSelectionOnSelectionEndNull?: boolean
|
||||
}
|
||||
|
||||
type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type CommentProps = {
|
||||
id?: string
|
||||
open: boolean
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
editor?: DraftProps
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
}
|
||||
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{
|
||||
host: HTMLDivElement
|
||||
dispose: VoidFunction
|
||||
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
|
||||
}
|
||||
>()
|
||||
|
||||
const mount = (meta: LineCommentAnnotationMeta<T>) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const host = document.createElement("div")
|
||||
host.setAttribute("data-prevent-autofocus", "")
|
||||
const [current, setCurrent] = createSignal(meta)
|
||||
|
||||
const dispose = renderSolid(() => {
|
||||
const active = current()
|
||||
if (active.kind === "comment") {
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "comment") return props.renderComment(active.comment)
|
||||
return props.renderComment(next.comment)
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={view().editor}
|
||||
fallback={
|
||||
<LineComment
|
||||
inline
|
||||
id={view().id}
|
||||
open={view().open}
|
||||
comment={view().comment}
|
||||
selection={view().selection}
|
||||
actions={view().actions}
|
||||
onClick={view().onClick}
|
||||
onMouseEnter={view().onMouseEnter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LineCommentEditor
|
||||
inline
|
||||
id={view().id}
|
||||
value={view().editor!.value}
|
||||
selection={view().editor!.selection}
|
||||
onInput={view().editor!.onInput}
|
||||
onCancel={view().editor!.onCancel}
|
||||
onSubmit={view().editor!.onSubmit}
|
||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "draft") return props.renderDraft(active.range)
|
||||
return props.renderDraft(next.range)
|
||||
})
|
||||
return (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
value={view().value}
|
||||
selection={view().selection}
|
||||
onInput={view().onInput}
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
mention={view().mention}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
nodes.set(meta.key, node)
|
||||
return node
|
||||
}
|
||||
|
||||
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
|
||||
const meta = annotation.metadata
|
||||
const node = nodes.get(meta.key) ?? mount(meta)
|
||||
if (!node) return
|
||||
node.setMeta(meta)
|
||||
return node.host
|
||||
}
|
||||
|
||||
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
|
||||
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
|
||||
for (const [key, node] of nodes) {
|
||||
if (next.has(key)) continue
|
||||
node.dispose()
|
||||
nodes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
for (const [, node] of nodes) node.dispose()
|
||||
nodes.clear()
|
||||
}
|
||||
|
||||
return { render, reconcile, cleanup }
|
||||
}
|
||||
|
||||
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
const [state, setState] = createStore({
|
||||
draft: "",
|
||||
editing: null as T | null,
|
||||
})
|
||||
const draft = () => state.draft
|
||||
const setDraft = (value: string) => setState("draft", value)
|
||||
const editing = () => state.editing
|
||||
const setEditing = (value: T | null) => setState("editing", typeof value === "function" ? () => value : value)
|
||||
|
||||
const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
|
||||
const setSelected = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setSelected(next)
|
||||
props.syncSelected?.(toRange(next))
|
||||
return next
|
||||
}
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setCommenting(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const closeComment = () => {
|
||||
props.setOpened(null)
|
||||
}
|
||||
|
||||
const cancelDraft = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
setCommenting(null)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
props.setOpened(null)
|
||||
props.setSelected(null)
|
||||
props.setCommenting(null)
|
||||
}
|
||||
|
||||
const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
props.setOpened(id)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
const next = props.opened() === id ? null : id
|
||||
props.setOpened(next)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
closeComment()
|
||||
setSelected(next)
|
||||
setCommenting(next)
|
||||
}
|
||||
|
||||
const openEditor = (id: T, range: SelectedLineRange, value: string) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
props.setCommenting(null)
|
||||
setEditing(id)
|
||||
setDraft(value)
|
||||
}
|
||||
|
||||
const hoverComment = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
if (!next) return
|
||||
if (props.hoverSelected) {
|
||||
props.hoverSelected(next)
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
editing,
|
||||
opened: props.opened,
|
||||
selected: props.selected,
|
||||
commenting: props.commenting,
|
||||
isOpen: (id: T) => props.opened() === id,
|
||||
isEditing: (id: T) => editing() === id,
|
||||
closeComment,
|
||||
openComment,
|
||||
toggleComment,
|
||||
openDraft,
|
||||
openEditor,
|
||||
hoverComment,
|
||||
cancelDraft,
|
||||
select: setSelected,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerWithSideProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
|
||||
) {
|
||||
const i18n = useI18n()
|
||||
const note = createLineCommentState<string>(props.state)
|
||||
|
||||
const annotations =
|
||||
"getSide" in props
|
||||
? createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
getSide: props.getSide,
|
||||
})
|
||||
: createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
})
|
||||
|
||||
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
|
||||
annotations,
|
||||
renderComment: (comment) => {
|
||||
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
|
||||
const remove = () => {
|
||||
note.reset()
|
||||
props.onDelete?.(comment)
|
||||
}
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
get open() {
|
||||
return note.isOpen(comment.id) || note.isEditing(comment.id)
|
||||
},
|
||||
comment: comment.comment,
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
get actions() {
|
||||
return props.renderCommentActions?.(comment, { edit, remove })
|
||||
},
|
||||
get editor() {
|
||||
return note.isEditing(comment.id)
|
||||
? {
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
props.onUpdate?.({
|
||||
id: comment.id,
|
||||
comment: value,
|
||||
selection: cloneSelectedLineRange(comment.selection),
|
||||
})
|
||||
note.cancelDraft()
|
||||
},
|
||||
submitLabel: props.editSubmitLabel,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
onMouseEnter: () => note.hoverComment(comment.selection),
|
||||
onClick: () => {
|
||||
if (note.isEditing(comment.id)) return
|
||||
note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
|
||||
},
|
||||
}
|
||||
},
|
||||
renderDraft: (range) => ({
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
|
||||
note.cancelDraft()
|
||||
},
|
||||
onPopoverFocusOut: props.onDraftPopoverFocusOut,
|
||||
}),
|
||||
})
|
||||
|
||||
const renderGutterUtility = createLineCommentGutterRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => {
|
||||
if (note.opened()) return null
|
||||
return props.getHoverSelectedRange?.() ?? note.selected()
|
||||
},
|
||||
onOpenDraft: note.openDraft,
|
||||
})
|
||||
|
||||
const onLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.select(range)
|
||||
}
|
||||
|
||||
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
if (props.clearSelectionOnSelectionEndNull) note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
return {
|
||||
note,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderGutterUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsWithSideProps<T>,
|
||||
): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T>,
|
||||
): Accessor<LineCommentAnnotation<T>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
|
||||
) {
|
||||
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
|
||||
if ("getSide" in props) {
|
||||
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
return {
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
}
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<LineCommentAnnotation<T>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
const entry: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
},
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
const draft: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
},
|
||||
}
|
||||
|
||||
return [...list, draft]
|
||||
})
|
||||
}
|
||||
|
||||
export function createManagedLineCommentAnnotationRenderer<T>(props: {
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const renderer = createLineCommentAnnotationRenderer<T>({
|
||||
renderComment: props.renderComment,
|
||||
renderDraft: props.renderDraft,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.reconcile(props.annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
renderAnnotation: renderer.render,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentGutterRenderer(props: {
|
||||
label: string
|
||||
getSelectedRange: Accessor<SelectedLineRange | null>
|
||||
onOpenDraft: (range: SelectedLineRange) => void
|
||||
}) {
|
||||
return (getHoveredLine: () => HoverCommentLine | undefined) =>
|
||||
createHoverCommentUtility({
|
||||
label: props.label,
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const current = props.getSelectedRange()
|
||||
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
|
||||
props.onOpenDraft(cloneSelectedLineRange(current))
|
||||
return
|
||||
}
|
||||
|
||||
const range: SelectedLineRange = {
|
||||
start: hovered.lineNumber,
|
||||
end: hovered.lineNumber,
|
||||
}
|
||||
if (hovered.side) range.side = hovered.side
|
||||
props.onOpenDraft(range)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
export const lineCommentStyles = `
|
||||
[data-annotation-slot] {
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
z-index: var(--line-comment-z, 30);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] {
|
||||
position: relative;
|
||||
right: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-open] {
|
||||
z-index: var(--line-comment-open-z, 100);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--icon-interactive-base);
|
||||
box-shadow: var(--shadow-xs);
|
||||
cursor: default;
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
||||
background: var(--syntax-diff-add);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-component="icon"] {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus-visible {
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-popover"] {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: -8px;
|
||||
z-index: var(--line-comment-popover-z, 40);
|
||||
min-width: 200px;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow: var(--shadow-xxs-border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: 8px;
|
||||
flex: 1 1 0%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 380px;
|
||||
max-width: none;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-head"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-text"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-x-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-tools"] {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-label"],
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-weak);
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-textarea"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-textarea"]:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-xs-border-select);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-left: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
flex: 1 1 220px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"] {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-strong);
|
||||
border-radius: var(--radius-md);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: var(--background-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
|
||||
let installed = false
|
||||
|
||||
export function installLineCommentStyles() {
|
||||
if (installed) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const id = "opencode-line-comment-styles"
|
||||
if (document.getElementById(id)) {
|
||||
installed = true
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = id
|
||||
style.textContent = lineCommentStyles
|
||||
document.head.appendChild(style)
|
||||
installed = true
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { createSignal } from "solid-js"
|
||||
import * as mod from "./line-comment"
|
||||
|
||||
const docs = `### Overview
|
||||
Inline comment anchor and editor for code review or annotation flows.
|
||||
|
||||
Pair with \`Diff\` or \`Code\` to align comments to lines.
|
||||
|
||||
### API
|
||||
- \`LineCommentAnchor\`: position with \`top\`, control \`open\`, render custom children.
|
||||
- \`LineComment\`: convenience wrapper for displaying comment + selection label.
|
||||
- \`LineCommentEditor\`: controlled textarea with submit/cancel handlers.
|
||||
|
||||
### Variants and states
|
||||
- Default display and editor display variants.
|
||||
|
||||
### Behavior
|
||||
- Anchor positions relative to a containing element.
|
||||
- Editor submits on Enter (Shift+Enter for newline).
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm ARIA labeling for comment button and editor textarea.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="line-comment"\` and related slots.
|
||||
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/LineComment",
|
||||
id: "components-line-comment",
|
||||
component: mod.LineComment,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "160px",
|
||||
padding: "16px 16px 16px 40px",
|
||||
border: "1px solid var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
<div>12 | const total = sum(values)</div>
|
||||
<div>13 | return total / values.length</div>
|
||||
<mod.LineComment open top={18} comment="Consider guarding against empty arrays." selection="L12-L13" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Editor = {
|
||||
render: () => {
|
||||
const [value, setValue] = createSignal("Add context for this change.")
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "220px",
|
||||
padding: "16px 16px 16px 40px",
|
||||
border: "1px solid var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
<div>40 | if (values.length === 0) return 0</div>
|
||||
<mod.LineCommentEditor
|
||||
top={24}
|
||||
value={value()}
|
||||
selection="L40"
|
||||
onInput={setValue}
|
||||
onCancel={() => setValue("")}
|
||||
onSubmit={(next) => setValue(next)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AnchorOnly = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "120px",
|
||||
padding: "16px 16px 16px 40px",
|
||||
border: "1px solid var(--border-weak)",
|
||||
"border-radius": "8px",
|
||||
"font-family": "var(--font-family-mono)",
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak)",
|
||||
}}
|
||||
>
|
||||
<div>20 | const ready = true</div>
|
||||
<mod.LineCommentAnchor top={18} open={false}>
|
||||
<div data-slot="line-comment-content">Anchor content</div>
|
||||
</mod.LineCommentAnchor>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -1,439 +0,0 @@
|
|||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
installLineCommentStyles()
|
||||
|
||||
export type LineCommentVariant = "default" | "editor" | "add"
|
||||
|
||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||
return (
|
||||
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<Show
|
||||
when={props.icon === "comment"}
|
||||
fallback={
|
||||
<path
|
||||
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
|
||||
</Show>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAnchorProps = {
|
||||
id?: string
|
||||
top?: number
|
||||
inline?: boolean
|
||||
hideButton?: boolean
|
||||
open: boolean
|
||||
variant?: LineCommentVariant
|
||||
icon?: "comment" | "plus"
|
||||
buttonLabel?: string
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
class?: string
|
||||
popoverClass?: string
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
const hidden = () => !props.inline && props.top === undefined
|
||||
const variant = () => props.variant ?? "default"
|
||||
const icon = () => props.icon ?? "comment"
|
||||
const inlineBody = () => props.inline && props.hideButton
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="line-comment"
|
||||
data-prevent-autofocus=""
|
||||
data-variant={variant()}
|
||||
data-comment-id={props.id}
|
||||
data-open={props.open ? "" : undefined}
|
||||
data-inline={props.inline ? "" : undefined}
|
||||
classList={{
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
style={
|
||||
props.inline
|
||||
? undefined
|
||||
: {
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={inlineBody()}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.buttonLabel}
|
||||
data-slot="line-comment-button"
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:mouseup={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
>
|
||||
<Show
|
||||
when={props.inline}
|
||||
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
|
||||
>
|
||||
<InlineGlyph icon={icon()} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
data-inline-body=""
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineComment = (props: LineCommentProps) => {
|
||||
const i18n = useI18n()
|
||||
const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
|
||||
<div data-slot="line-comment-content">
|
||||
<div data-slot="line-comment-head">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<Show when={split.actions}>
|
||||
<div data-slot="line-comment-tools">{split.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="line-comment-label">
|
||||
{i18n.t("ui.lineComment.label.prefix")}
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.label.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
||||
const [split, rest] = splitProps(props, ["label"])
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<LineCommentAnchor
|
||||
{...rest}
|
||||
open={false}
|
||||
variant="add"
|
||||
icon="plus"
|
||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
autofocus?: boolean
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
mention?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const i18n = useI18n()
|
||||
const [split, rest] = splitProps(props, [
|
||||
"value",
|
||||
"selection",
|
||||
"onInput",
|
||||
"onCancel",
|
||||
"onSubmit",
|
||||
"placeholder",
|
||||
"rows",
|
||||
"autofocus",
|
||||
"cancelLabel",
|
||||
"submitLabel",
|
||||
"mention",
|
||||
])
|
||||
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
function selectMention(item: { path: string } | undefined) {
|
||||
if (!item) return
|
||||
|
||||
const textarea = refs.textarea
|
||||
const query = currentMention()
|
||||
if (!textarea || !query) return
|
||||
|
||||
const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
|
||||
const cursor = query.start + item.path.length + 2
|
||||
|
||||
split.onInput(value)
|
||||
closeMention()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const mention = useFilteredList<{ path: string }>({
|
||||
items: async (query) => {
|
||||
if (!split.mention) return []
|
||||
if (!query.trim()) return []
|
||||
const paths = await split.mention.items(query)
|
||||
return paths.map((path) => ({ path }))
|
||||
},
|
||||
key: (item) => item.path,
|
||||
filterKeys: ["path"],
|
||||
skipFilter: () => true,
|
||||
onSelect: selectMention,
|
||||
})
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const click =
|
||||
(fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
fn()
|
||||
}
|
||||
|
||||
const closeMention = () => {
|
||||
setOpen(false)
|
||||
mention.clear()
|
||||
}
|
||||
|
||||
const currentMention = () => {
|
||||
const textarea = refs.textarea
|
||||
if (!textarea) return
|
||||
if (!split.mention) return
|
||||
if (textarea.selectionStart !== textarea.selectionEnd) return
|
||||
|
||||
const end = textarea.selectionStart
|
||||
const match = textarea.value.slice(0, end).match(/@(\S*)$/)
|
||||
if (!match) return
|
||||
|
||||
return {
|
||||
query: match[1] ?? "",
|
||||
start: end - match[0].length,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
const syncMention = () => {
|
||||
const item = currentMention()
|
||||
if (!item) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
mention.onInput(item.query)
|
||||
}
|
||||
|
||||
const selectActiveMention = () => {
|
||||
const items = mention.flat()
|
||||
if (items.length === 0) return
|
||||
const active = mention.active()
|
||||
selectMention(items.find((item) => item.path === active) ?? items[0])
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const value = split.value.trim()
|
||||
if (!value) return
|
||||
split.onSubmit(value)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (split.autofocus === false) return
|
||||
requestAnimationFrame(focus)
|
||||
})
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
|
||||
<div data-slot="line-comment-editor">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
refs.textarea = el
|
||||
}}
|
||||
data-slot="line-comment-textarea"
|
||||
rows={split.rows ?? 3}
|
||||
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
|
||||
value={split.value}
|
||||
on:input={(e) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
split.onInput(value)
|
||||
syncMention()
|
||||
}}
|
||||
on:click={() => syncMention()}
|
||||
on:select={() => syncMention()}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
event.stopPropagation()
|
||||
if (open()) {
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
if (mention.flat().length === 0) return
|
||||
event.preventDefault()
|
||||
selectActiveMention()
|
||||
return
|
||||
}
|
||||
|
||||
const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
|
||||
const ctrlNav =
|
||||
event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
|
||||
if ((nav || ctrlNav) && mention.flat().length > 0) {
|
||||
mention.onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
<Show when={open() && mention.flat().length > 0}>
|
||||
<div data-slot="line-comment-mention-list">
|
||||
<For each={mention.flat().slice(0, 10)}>
|
||||
{(item) => {
|
||||
const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
|
||||
const name = item.path.endsWith("/") ? "" : getFilename(item.path)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-mention-item"
|
||||
data-active={mention.active() === item.path ? "" : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => mention.setActive(item.path)}
|
||||
onClick={() => selectMention(item)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div data-slot="line-comment-mention-path">
|
||||
<span data-slot="line-comment-mention-dir">{directory}</span>
|
||||
<Show when={name}>
|
||||
<span data-slot="line-comment-mention-file">{name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="line-comment-actions">
|
||||
<div data-slot="line-comment-editor-label">
|
||||
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.editorLabel.suffix")}
|
||||
</div>
|
||||
<Show
|
||||
when={!props.inline}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="ghost"
|
||||
on:mousedown={hold as any}
|
||||
on:click={click(split.onCancel) as any}
|
||||
>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="primary"
|
||||
disabled={split.value.trim().length === 0}
|
||||
on:mousedown={hold as any}
|
||||
on:click={click(submit) as any}
|
||||
>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { project } from "./markdown-stream"
|
||||
|
||||
export type MarkdownCacheEntry = {
|
||||
raw: string
|
||||
hash: string
|
||||
html: string
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, MarkdownCacheEntry>()
|
||||
const config = {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
FORBID_TAGS: ["style"],
|
||||
FORBID_CONTENTS: ["style", "script"],
|
||||
ADD_TAGS: ["svg", "path"],
|
||||
ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"],
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return
|
||||
if (node.target !== "_blank") return
|
||||
|
||||
const rel = node.getAttribute("rel") ?? ""
|
||||
const set = new Set(rel.split(/\s+/).filter(Boolean))
|
||||
set.add("noopener")
|
||||
set.add("noreferrer")
|
||||
node.setAttribute("rel", Array.from(set).join(" "))
|
||||
})
|
||||
}
|
||||
|
||||
export function sanitizeMarkdown(html: string) {
|
||||
if (!DOMPurify.isSupported) return ""
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
export function getCachedMarkdown(key: string) {
|
||||
return cache.get(key)
|
||||
}
|
||||
|
||||
export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
|
||||
cache.delete(key)
|
||||
cache.set(key, value)
|
||||
|
||||
if (cache.size <= max) return
|
||||
|
||||
const first = cache.keys().next().value
|
||||
if (!first) return
|
||||
cache.delete(first)
|
||||
}
|
||||
|
||||
export async function preloadMarkdown(
|
||||
text: string,
|
||||
cacheKey: string,
|
||||
parser: { parse(text: string): string | Promise<string> },
|
||||
) {
|
||||
await Promise.all(
|
||||
project(undefined, text, false).blocks.map(async (block, index) => {
|
||||
if (block.mode === "code") return
|
||||
const key = `${cacheKey}:${index}:${block.mode}`
|
||||
const cached = getCachedMarkdown(key)
|
||||
if (cached?.raw === block.raw) {
|
||||
touchCachedMarkdown(key, cached)
|
||||
return
|
||||
}
|
||||
const hash = checksum(block.raw)
|
||||
if (!hash) return
|
||||
touchCachedMarkdown(key, {
|
||||
raw: block.raw,
|
||||
hash,
|
||||
html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { shouldResetCodeTokens } from "./markdown-code-state"
|
||||
|
||||
const previous = {
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stableCount: 3,
|
||||
unstable: [],
|
||||
raw: "```ts\nconst x = 1\n```",
|
||||
}
|
||||
|
||||
test("resets tokens for a non-prefix replacement with the same generation and token count", () => {
|
||||
expect(
|
||||
shouldResetCodeTokens(previous, {
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stableCount: 3,
|
||||
raw: "```ts\nlet y = 2\n```",
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("retains tokens for an append-only streaming update", () => {
|
||||
expect(
|
||||
shouldResetCodeTokens(previous, {
|
||||
language: "ts",
|
||||
generation: 1,
|
||||
stableCount: 4,
|
||||
raw: `${previous.raw}\nmore`,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import type { MarkdownToken } from "./markdown-worker-protocol"
|
||||
|
||||
export type RenderedCodeState = {
|
||||
language: string
|
||||
generation: number
|
||||
stableCount: number
|
||||
unstable: MarkdownToken[]
|
||||
raw: string
|
||||
}
|
||||
|
||||
export function shouldResetCodeTokens(
|
||||
previous: RenderedCodeState | undefined,
|
||||
next: { language: string; generation: number; stableCount: number; raw: string },
|
||||
) {
|
||||
return (
|
||||
!previous ||
|
||||
previous.language !== next.language ||
|
||||
previous.generation !== next.generation ||
|
||||
next.stableCount < previous.stableCount ||
|
||||
!next.raw.startsWith(previous.raw)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { preloadMarkdown } from "./markdown-cache"
|
||||
|
||||
test("preloads completed markdown into the render cache", async () => {
|
||||
const parsed: string[] = []
|
||||
const parser = {
|
||||
parse(text: string) {
|
||||
parsed.push(text)
|
||||
return `<p>${text}</p>`
|
||||
},
|
||||
}
|
||||
const key = `markdown-preload-${crypto.randomUUID()}`
|
||||
|
||||
await preloadMarkdown("prepared response", key, parser)
|
||||
await preloadMarkdown("prepared response", key, parser)
|
||||
|
||||
expect(parsed).toEqual(["prepared response"])
|
||||
})
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
/// <reference lib="webworker" />
|
||||
|
||||
import { ShikiStreamTokenizer } from "@shikijs/stream"
|
||||
import {
|
||||
bundledLanguages,
|
||||
createHighlighter,
|
||||
getTokenStyleObject,
|
||||
stringifyTokenStyle,
|
||||
type BundledLanguage,
|
||||
type ThemedToken,
|
||||
} from "shiki"
|
||||
import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
|
||||
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
||||
|
||||
type Stream = {
|
||||
language: string
|
||||
source: string
|
||||
tokenizer: ShikiStreamTokenizer
|
||||
}
|
||||
|
||||
const streams = new Map<string, Stream>()
|
||||
let highlighter: ReturnType<typeof createHighlighter> | undefined
|
||||
const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
||||
run: highlight,
|
||||
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
|
||||
dispose: (key) => void streams.delete(key),
|
||||
})
|
||||
|
||||
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
if (event.data.type === "init") {
|
||||
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
|
||||
return
|
||||
}
|
||||
if (event.data.type === "dispose") {
|
||||
queue.dispose(event.data.key)
|
||||
return
|
||||
}
|
||||
|
||||
queue.highlight(event.data)
|
||||
}
|
||||
|
||||
async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
|
||||
try {
|
||||
const instance = await highlighter
|
||||
if (!instance) throw new Error("Shiki worker is not initialized")
|
||||
const language = request.language in bundledLanguages ? request.language : "text"
|
||||
if (!instance.getLoadedLanguages().includes(language))
|
||||
await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
|
||||
|
||||
if (request.complete) {
|
||||
const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" })
|
||||
streams.delete(request.key)
|
||||
post({
|
||||
type: "highlight",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
reset: true,
|
||||
stable: result.tokens
|
||||
.flatMap((line, index) =>
|
||||
index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }],
|
||||
)
|
||||
.map(token),
|
||||
unstable: [],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const previous = streams.get(request.key)
|
||||
const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source)
|
||||
const stream = reset
|
||||
? {
|
||||
language,
|
||||
source: "",
|
||||
tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: "OpenCode" }),
|
||||
}
|
||||
: previous
|
||||
const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length))
|
||||
stream.source = request.text
|
||||
streams.set(request.key, stream)
|
||||
post({
|
||||
type: "highlight",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
reset,
|
||||
stable: result.stable.filter((token) => token.content.length > 0).map(token),
|
||||
unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
|
||||
})
|
||||
} catch (error) {
|
||||
post({
|
||||
type: "error",
|
||||
id: request.id,
|
||||
key: request.key,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function post(response: MarkdownWorkerResponse) {
|
||||
self.postMessage(response)
|
||||
}
|
||||
|
||||
function token(value: ThemedToken): MarkdownToken {
|
||||
return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))]
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { canReusePendingBlock, project, stream } from "./markdown-stream"
|
||||
|
||||
describe("markdown stream", () => {
|
||||
test("heals incomplete emphasis while streaming", () => {
|
||||
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
|
||||
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
|
||||
})
|
||||
|
||||
test("keeps incomplete links non-clickable until they finish", () => {
|
||||
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
|
||||
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("splits an unfinished trailing code fence from stable content", () => {
|
||||
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
||||
{ raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("fully parses a code fence once it closes", () => {
|
||||
const text = "before\n\n```ts\nconst x = 1\n```"
|
||||
expect(stream(text, true)).toEqual([
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
||||
{ raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a completed code fence in worker-rendered code mode when prose follows", () => {
|
||||
expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([
|
||||
{ raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
||||
{ raw: "after", src: "after", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("freezes completed top-level blocks and only keeps the tail live", () => {
|
||||
expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([
|
||||
{ raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" },
|
||||
{ raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" },
|
||||
{ raw: "- live item", src: "- live item", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a growing table together until a later block freezes it", () => {
|
||||
expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([
|
||||
{ raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("reprojects non-prefix replacements from current content", () => {
|
||||
expect(stream("# Replacement\n\nNew body", true)).toEqual([
|
||||
{ raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" },
|
||||
{ raw: "New body", src: "New body", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("reprojects truncation without retaining removed blocks", () => {
|
||||
expect(stream("Only the restored prefix", true)).toEqual([
|
||||
{ raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("shifts later blocks when an earlier block is inserted", () => {
|
||||
expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([
|
||||
{ raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" },
|
||||
{ raw: "First body\n\n", src: "First body\n\n", mode: "full" },
|
||||
{ raw: "Second body", src: "Second body", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps reference-style markdown as one block", () => {
|
||||
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
|
||||
{
|
||||
raw: "[docs][1]\n\n[1]: https://example.com",
|
||||
src: "[docs][1]\n\n[1]: https://example.com",
|
||||
mode: "live",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps compact and indented reference definitions with their uses", () => {
|
||||
expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([
|
||||
{
|
||||
raw: "[docs]\n\n [docs]:/guide",
|
||||
src: "[docs]\n\n [docs]:/guide",
|
||||
mode: "live",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps multiline reference definitions with their uses", () => {
|
||||
expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([
|
||||
{
|
||||
raw: "[docs][id]\n\n[id]:\n /guide",
|
||||
src: "[docs][id]\n\n[id]:\n /guide",
|
||||
mode: "live",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses only the language portion of fence metadata", () => {
|
||||
expect(stream("```ts title=example\nconst x = 1", true)).toEqual([
|
||||
{
|
||||
raw: "```ts title=example\nconst x = 1",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves trailing newlines in open code fences", () => {
|
||||
expect(stream("```ts\nconst x = 1\n", true)).toEqual([
|
||||
{
|
||||
raw: "```ts\nconst x = 1\n",
|
||||
src: "const x = 1\n",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("only reuses pending blocks with compatible identity and content", () => {
|
||||
expect(
|
||||
canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "" }),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }),
|
||||
).toBe(true)
|
||||
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
|
||||
})
|
||||
|
||||
test("appends plain code deltas without reprojecting frozen blocks", () => {
|
||||
const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true)
|
||||
const next = project(previous, `${previous.text}const two = 2\n`, true)
|
||||
|
||||
expect(next.blocks[0]).toBe(previous.blocks[0])
|
||||
expect(next.blocks.at(-1)).toEqual({
|
||||
raw: "```ts\nconst one = 1\nconst two = 2\n",
|
||||
src: "const one = 1\nconst two = 2\n",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not add a blank line before the first streamed code", () => {
|
||||
const previous = project(undefined, "```ts\n", true)
|
||||
const next = project(previous, `${previous.text}const x = 1`, true)
|
||||
|
||||
expect(next.blocks.at(-1)).toEqual({
|
||||
raw: "```ts\nconst x = 1",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("closes code fences split across provider deltas", () => {
|
||||
const open = project(undefined, "```ts\nconst x = 1\n", true)
|
||||
const one = project(open, `${open.text}\``, true)
|
||||
const two = project(one, `${one.text}\``, true)
|
||||
const closed = project(two, `${two.text}\``, true)
|
||||
const prose = project(closed, `${closed.text}\nafter`, true)
|
||||
|
||||
expect(closed.blocks.at(-1)).toEqual({
|
||||
raw: "```ts\nconst x = 1\n```",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
complete: true,
|
||||
})
|
||||
expect(prose.blocks).toEqual([
|
||||
{ raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
||||
{ raw: "after", src: "after", mode: "live" },
|
||||
])
|
||||
})
|
||||
|
||||
test("closes tilde fences split across provider deltas", () => {
|
||||
const open = project(undefined, "~~~ts\nconst x = 1\n", true)
|
||||
const one = project(open, `${open.text}~`, true)
|
||||
const two = project(one, `${one.text}~`, true)
|
||||
const closed = project(two, `${two.text}~`, true)
|
||||
|
||||
expect(closed.blocks.at(-1)).toEqual({
|
||||
raw: "~~~ts\nconst x = 1\n~~~",
|
||||
src: "const x = 1",
|
||||
mode: "code",
|
||||
language: "ts",
|
||||
complete: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { marked, type Tokens } from "marked"
|
||||
import remend from "remend"
|
||||
|
||||
export type Block = {
|
||||
raw: string
|
||||
src: string
|
||||
mode: "full" | "live" | "code"
|
||||
language?: string
|
||||
complete?: boolean
|
||||
}
|
||||
|
||||
export type Projection = {
|
||||
text: string
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
function refs(text: string) {
|
||||
if (!text.includes("]:")) return false
|
||||
return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text)
|
||||
}
|
||||
|
||||
function language(value: string | undefined) {
|
||||
return value?.trim().split(/\s+/, 1)[0] || undefined
|
||||
}
|
||||
|
||||
function openCode(raw: string) {
|
||||
const newline = raw.indexOf("\n")
|
||||
return newline < 0 ? "" : raw.slice(newline + 1)
|
||||
}
|
||||
|
||||
function open(raw: string) {
|
||||
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
|
||||
if (!match) return false
|
||||
const mark = match[1]
|
||||
if (!mark) return false
|
||||
const char = mark[0]
|
||||
const size = mark.length
|
||||
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
|
||||
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
|
||||
}
|
||||
|
||||
function closesFence(raw: string, suffix: string) {
|
||||
const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1]
|
||||
if (!mark) return suffix.includes("```") || suffix.includes("~~~")
|
||||
return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark)
|
||||
}
|
||||
|
||||
function heal(text: string) {
|
||||
return remend(text, { linkMode: "text-only" })
|
||||
}
|
||||
|
||||
export function stream(text: string, live: boolean): Block[] {
|
||||
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
|
||||
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const tokens = marked.lexer(text)
|
||||
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||
if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const last = tokens[tail]
|
||||
if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
|
||||
const result: Block[] = []
|
||||
for (let index = 0; index < tail; index++) {
|
||||
const token = tokens[index]
|
||||
if (!token || token.type === "space") continue
|
||||
let raw = token.raw
|
||||
while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw
|
||||
if (token.type === "code") {
|
||||
const code = token as Tokens.Code
|
||||
result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true })
|
||||
continue
|
||||
}
|
||||
result.push({ raw, src: raw, mode: "full" })
|
||||
}
|
||||
|
||||
const raw = tokens
|
||||
.slice(tail)
|
||||
.map((token) => token.raw)
|
||||
.join("")
|
||||
if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
|
||||
|
||||
const code = last as Tokens.Code
|
||||
if (!open(code.raw))
|
||||
return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
|
||||
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
||||
}
|
||||
|
||||
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
|
||||
if (!current || current.mode !== next.mode) return false
|
||||
if (next.mode === "code") return next.raw.startsWith(current.raw)
|
||||
return current.raw === next.raw
|
||||
}
|
||||
|
||||
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
||||
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
|
||||
const tail = previous.blocks.at(-1)
|
||||
const suffix = text.slice(previous.text.length)
|
||||
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
|
||||
return { text, blocks: stream(text, live) }
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
...previous.blocks.slice(0, -1),
|
||||
{
|
||||
...tail,
|
||||
raw: tail.raw + suffix,
|
||||
src: tail.src + suffix,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
applyMarkdownWorkerResponse,
|
||||
markdownBlockKey,
|
||||
shouldReleaseMarkdownWorkerState,
|
||||
} from "./markdown-worker-protocol"
|
||||
|
||||
const token = (content: string): [string, string] => [content, ""]
|
||||
const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({
|
||||
type: "highlight" as const,
|
||||
id,
|
||||
key: "code",
|
||||
reset,
|
||||
stable,
|
||||
unstable,
|
||||
})
|
||||
|
||||
test("accumulates stable worker tokens and replaces the unstable tail", () => {
|
||||
const first = applyMarkdownWorkerResponse(undefined, {
|
||||
type: "highlight",
|
||||
id: 1,
|
||||
key: "code",
|
||||
reset: true,
|
||||
stable: [token("one\n")],
|
||||
unstable: [token("tw")],
|
||||
})
|
||||
const second = applyMarkdownWorkerResponse(first, {
|
||||
type: "highlight",
|
||||
id: 2,
|
||||
key: "code",
|
||||
reset: false,
|
||||
stable: [token("two\n")],
|
||||
unstable: [token("three")],
|
||||
})
|
||||
|
||||
expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
|
||||
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
|
||||
})
|
||||
|
||||
test("increments generation only when the worker resets token identity", () => {
|
||||
const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], []))
|
||||
const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], []))
|
||||
const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], []))
|
||||
expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2])
|
||||
})
|
||||
|
||||
test("ignores stale worker responses and resets replacement streams", () => {
|
||||
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
|
||||
expect(
|
||||
applyMarkdownWorkerResponse(current, {
|
||||
type: "highlight",
|
||||
id: 1,
|
||||
key: "code",
|
||||
reset: false,
|
||||
stable: [token("stale")],
|
||||
unstable: [],
|
||||
}),
|
||||
).toBe(current)
|
||||
|
||||
expect(
|
||||
applyMarkdownWorkerResponse(current, {
|
||||
type: "highlight",
|
||||
id: 3,
|
||||
key: "code",
|
||||
reset: true,
|
||||
stable: [token("replacement")],
|
||||
unstable: [],
|
||||
}).stable.map((item) => item[0]),
|
||||
).toEqual(["replacement"])
|
||||
})
|
||||
|
||||
test("releases only the latest completed worker state", () => {
|
||||
expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true)
|
||||
expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false)
|
||||
expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false)
|
||||
})
|
||||
|
||||
test("prefixes pending and dispatched block keys with the component owner", () => {
|
||||
expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code")
|
||||
expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2")
|
||||
})
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import type { ThemeRegistrationResolved } from "shiki"
|
||||
|
||||
export type MarkdownToken = [content: string, style: string]
|
||||
|
||||
export type MarkdownWorkerRequest =
|
||||
| { type: "init"; theme: ThemeRegistrationResolved }
|
||||
| { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
|
||||
| { type: "dispose"; key: string }
|
||||
|
||||
export type MarkdownWorkerResponse =
|
||||
| {
|
||||
type: "highlight"
|
||||
id: number
|
||||
key: string
|
||||
reset: boolean
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
| { type: "error"; id: number; key: string; message: string }
|
||||
| { type: "superseded"; id: number; key: string }
|
||||
|
||||
export type MarkdownWorkerState = {
|
||||
id: number
|
||||
generation: number
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) {
|
||||
return complete && latestID === responseID
|
||||
}
|
||||
|
||||
export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) {
|
||||
return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}`
|
||||
}
|
||||
|
||||
export function applyMarkdownWorkerResponse(
|
||||
state: MarkdownWorkerState | undefined,
|
||||
response: Extract<MarkdownWorkerResponse, { type: "highlight" }>,
|
||||
) {
|
||||
if (state && response.id <= state.id) return state
|
||||
return {
|
||||
id: response.id,
|
||||
generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
|
||||
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
|
||||
unstable: response.unstable,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
||||
|
||||
test("keeps only the latest queued request for each key", async () => {
|
||||
const processed: number[] = []
|
||||
const superseded: number[] = []
|
||||
let release = () => {}
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
|
||||
run: async (request) => {
|
||||
processed.push(request.id)
|
||||
if (request.id === 1) await blocked
|
||||
},
|
||||
supersede: (request) => superseded.push(request.id),
|
||||
dispose: () => {},
|
||||
})
|
||||
|
||||
queue.highlight({ id: 1, key: "code" })
|
||||
await Promise.resolve()
|
||||
queue.highlight({ id: 2, key: "code" })
|
||||
queue.highlight({ id: 3, key: "code" })
|
||||
queue.highlight({ id: 4, key: "code" })
|
||||
|
||||
expect(queue.pending()).toBe(1)
|
||||
expect(superseded).toEqual([2, 3])
|
||||
release()
|
||||
await queue.idle()
|
||||
expect(processed).toEqual([1, 4])
|
||||
})
|
||||
|
||||
test("serializes disposal before a later request for the same key", async () => {
|
||||
const events: string[] = []
|
||||
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
|
||||
run: async (request) => {
|
||||
events.push(`highlight:${request.id}`)
|
||||
},
|
||||
supersede: (request) => events.push(`supersede:${request.id}`),
|
||||
dispose: (key) => events.push(`dispose:${key}`),
|
||||
})
|
||||
|
||||
queue.highlight({ id: 1, key: "code" })
|
||||
queue.dispose("code")
|
||||
queue.highlight({ id: 2, key: "code" })
|
||||
await queue.idle()
|
||||
|
||||
expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"])
|
||||
})
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
export function createLatestWorkerQueue<T extends { key: string }>(input: {
|
||||
run: (request: T) => Promise<void>
|
||||
supersede: (request: T) => void
|
||||
dispose: (key: string) => void
|
||||
}) {
|
||||
type Slot = { type: "highlight"; key: string; request?: T }
|
||||
const jobs: Array<Slot | { type: "dispose"; key: string }> = []
|
||||
const slots = new Map<string, Slot>()
|
||||
let running: Promise<void> | undefined
|
||||
let cursor = 0
|
||||
|
||||
const schedule = () => {
|
||||
if (running) return
|
||||
running = Promise.resolve()
|
||||
.then(async () => {
|
||||
while (cursor < jobs.length) {
|
||||
const job = jobs[cursor++]!
|
||||
if (job.type === "dispose") {
|
||||
input.dispose(job.key)
|
||||
continue
|
||||
}
|
||||
if (slots.get(job.key) === job) slots.delete(job.key)
|
||||
const request = job.request
|
||||
job.request = undefined
|
||||
if (request) await input.run(request)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
jobs.splice(0, cursor)
|
||||
cursor = 0
|
||||
running = undefined
|
||||
if (jobs.length > 0) schedule()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
highlight(request: T) {
|
||||
const slot = slots.get(request.key)
|
||||
if (slot) {
|
||||
if (slot.request) input.supersede(slot.request)
|
||||
slot.request = request
|
||||
return
|
||||
}
|
||||
const next: Slot = { type: "highlight", key: request.key, request }
|
||||
slots.set(request.key, next)
|
||||
jobs.push(next)
|
||||
schedule()
|
||||
},
|
||||
dispose(key: string) {
|
||||
const slot = slots.get(key)
|
||||
if (slot?.request) input.supersede(slot.request)
|
||||
if (slot) {
|
||||
slot.request = undefined
|
||||
slots.delete(key)
|
||||
}
|
||||
jobs.push({ type: "dispose", key })
|
||||
schedule()
|
||||
},
|
||||
pending: () => slots.size,
|
||||
async idle() {
|
||||
while (running) await running
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { createWorkerTransport } from "./markdown-worker-transport"
|
||||
|
||||
test("posts one request and retains only the latest queued snapshot per key", () => {
|
||||
const posted: number[] = []
|
||||
const superseded: number[] = []
|
||||
const transport = createWorkerTransport<{ id: number; key: string }>({
|
||||
post: (request) => posted.push(request.id),
|
||||
supersede: (request) => superseded.push(request.id),
|
||||
})
|
||||
|
||||
transport.send({ id: 1, key: "code" })
|
||||
transport.send({ id: 2, key: "code" })
|
||||
transport.send({ id: 3, key: "code" })
|
||||
|
||||
expect(posted).toEqual([1])
|
||||
expect(superseded).toEqual([2])
|
||||
expect(transport.queued()).toBe(1)
|
||||
transport.complete("code", 1)
|
||||
expect(posted).toEqual([1, 3])
|
||||
expect(transport.queued()).toBe(0)
|
||||
})
|
||||
|
||||
test("ignores a disposed request response after the key is reused", () => {
|
||||
const posted: number[] = []
|
||||
const transport = createWorkerTransport<{ id: number; key: string }>({
|
||||
post: (request) => posted.push(request.id),
|
||||
supersede: () => {},
|
||||
})
|
||||
|
||||
transport.send({ id: 1, key: "code" })
|
||||
transport.dispose("code")
|
||||
transport.send({ id: 2, key: "code" })
|
||||
transport.send({ id: 3, key: "code" })
|
||||
transport.complete("code", 1)
|
||||
|
||||
expect(posted).toEqual([1, 2])
|
||||
expect(transport.queued()).toBe(1)
|
||||
transport.complete("code", 2)
|
||||
expect(posted).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("drops queued snapshots when a key is disposed", () => {
|
||||
const superseded: number[] = []
|
||||
const transport = createWorkerTransport<{ id: number; key: string }>({
|
||||
post: () => {},
|
||||
supersede: (request) => superseded.push(request.id),
|
||||
})
|
||||
|
||||
transport.send({ id: 1, key: "code" })
|
||||
transport.send({ id: 2, key: "code" })
|
||||
transport.dispose("code")
|
||||
|
||||
expect(superseded).toEqual([2])
|
||||
expect(transport.queued()).toBe(0)
|
||||
})
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
export function createWorkerTransport<T extends { id: number; key: string }>(input: {
|
||||
post: (request: T) => void
|
||||
supersede: (request: T) => void
|
||||
}) {
|
||||
const active = new Map<string, T>()
|
||||
const queued = new Map<string, T>()
|
||||
|
||||
return {
|
||||
send(request: T) {
|
||||
if (!active.has(request.key)) {
|
||||
active.set(request.key, request)
|
||||
input.post(request)
|
||||
return
|
||||
}
|
||||
const previous = queued.get(request.key)
|
||||
if (previous) input.supersede(previous)
|
||||
queued.set(request.key, request)
|
||||
},
|
||||
complete(key: string, id: number) {
|
||||
if (active.get(key)?.id !== id) return
|
||||
active.delete(key)
|
||||
const next = queued.get(key)
|
||||
if (!next) return
|
||||
queued.delete(key)
|
||||
active.set(key, next)
|
||||
input.post(next)
|
||||
},
|
||||
dispose(key: string) {
|
||||
active.delete(key)
|
||||
const request = queued.get(key)
|
||||
if (request) input.supersede(request)
|
||||
queued.delete(key)
|
||||
},
|
||||
reset() {
|
||||
queued.forEach(input.supersede)
|
||||
queued.clear()
|
||||
active.clear()
|
||||
},
|
||||
queued: () => queued.size,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url"
|
||||
import { OpenCodeTheme } from "../context/marked"
|
||||
import {
|
||||
applyMarkdownWorkerResponse,
|
||||
shouldReleaseMarkdownWorkerState,
|
||||
type MarkdownWorkerRequest,
|
||||
type MarkdownWorkerResponse,
|
||||
type MarkdownWorkerState,
|
||||
} from "./markdown-worker-protocol"
|
||||
import { createWorkerTransport } from "./markdown-worker-transport"
|
||||
|
||||
type Pending = {
|
||||
key: string
|
||||
complete: boolean
|
||||
resolve: (state: MarkdownWorkerState) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let worker: Worker | undefined
|
||||
let disabled: Error | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, Pending>()
|
||||
const states = new Map<string, MarkdownWorkerState>()
|
||||
const keys = new Set<string>()
|
||||
const latest = new Map<string, number>()
|
||||
const transport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
||||
post: (request) => worker!.postMessage(request),
|
||||
supersede: (request) => {
|
||||
const result = pending.get(request.id)
|
||||
if (!result) return
|
||||
pending.delete(request.id)
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
},
|
||||
})
|
||||
|
||||
export function highlightStreamingCode(key: string, text: string, language: string, complete = false) {
|
||||
const instance = getWorker()
|
||||
const id = ++nextID
|
||||
latest.set(key, id)
|
||||
keys.delete(key)
|
||||
keys.add(key)
|
||||
if (keys.size > 200) disposeStreamingCode(keys.values().next().value!)
|
||||
return new Promise<MarkdownWorkerState>((resolve, reject) => {
|
||||
pending.set(id, { key, complete, resolve, reject })
|
||||
transport.send({ type: "highlight", id, key, text, language, complete })
|
||||
})
|
||||
}
|
||||
|
||||
export function disposeStreamingCode(key: string) {
|
||||
keys.delete(key)
|
||||
latest.delete(key)
|
||||
states.delete(key)
|
||||
transport.dispose(key)
|
||||
pending.forEach((request, id) => {
|
||||
if (request.key !== key) return
|
||||
pending.delete(id)
|
||||
request.reject(new MarkdownWorkerDisposedError())
|
||||
})
|
||||
worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest)
|
||||
}
|
||||
|
||||
export class MarkdownWorkerDisposedError extends Error {}
|
||||
export class MarkdownWorkerSupersededError extends Error {}
|
||||
export class MarkdownWorkerUnavailableError extends Error {}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
try {
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" })
|
||||
} catch (error) {
|
||||
disabled = error instanceof Error ? error : new Error(String(error))
|
||||
throw new MarkdownWorkerUnavailableError(disabled.message)
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const result = pending.get(event.data.id)
|
||||
if (!result) {
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
pending.delete(event.data.id)
|
||||
if (!keys.has(event.data.key)) {
|
||||
result.reject(new MarkdownWorkerDisposedError())
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "superseded") {
|
||||
result.reject(new MarkdownWorkerSupersededError())
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
if (event.data.type === "error") {
|
||||
result.reject(new Error(event.data.message))
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
return
|
||||
}
|
||||
const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data)
|
||||
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) {
|
||||
states.delete(event.data.key)
|
||||
keys.delete(event.data.key)
|
||||
latest.delete(event.data.key)
|
||||
} else states.set(event.data.key, state)
|
||||
result.resolve(state)
|
||||
transport.complete(event.data.key, event.data.id)
|
||||
}
|
||||
const fail = (message: string) => {
|
||||
const error = new Error(message)
|
||||
disabled = error
|
||||
transport.reset()
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
states.clear()
|
||||
keys.clear()
|
||||
latest.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed")
|
||||
worker.onmessageerror = () => fail("Markdown worker response failed")
|
||||
worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest)
|
||||
return worker
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
[data-component="markdown"] {
|
||||
/* Reset & Base Typography */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: 160%;
|
||||
|
||||
/* Spacing for flow */
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
> [data-markdown-block]:first-child > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
> [data-markdown-block]:last-child > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Headings: Same size, distinguished by color and spacing */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
/* Emphasis & Strong: Neutral strong color */
|
||||
strong,
|
||||
b {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--text-interactive-base);
|
||||
text-decoration: none;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul,
|
||||
ol {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 12px;
|
||||
margin-left: 0;
|
||||
padding-left: 32px;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 2.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
li > p:first-child {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li > p + p {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
/* Nested lists spacing */
|
||||
li > ul,
|
||||
li > ol {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding-left: 1rem; /* Minimal indent for nesting only */
|
||||
}
|
||||
|
||||
li > ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
border-left: 2px solid var(--border-weak-base);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
color: var(--text-weak);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Horizontal Rule - Invisible spacing only */
|
||||
hr {
|
||||
border: none;
|
||||
height: 0;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.shiki {
|
||||
background: var(--color-background-stronger);
|
||||
color: var(--text-base);
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 0.5px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"] {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-float-base);
|
||||
color: var(--text-invert-strong);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"]:hover::after,
|
||||
[data-slot="markdown-copy-button"]:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"] [data-slot="check-icon"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 32px;
|
||||
overflow: auto;
|
||||
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
color: var(--syntax-string);
|
||||
font-weight: var(--font-weight-medium);
|
||||
/* font-size: 13px; */
|
||||
|
||||
/* padding: 2px 2px; */
|
||||
/* margin: 0 1.5px; */
|
||||
/* border-radius: 2px; */
|
||||
/* background: var(--surface-base); */
|
||||
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 24px 0;
|
||||
font-size: var(--font-size-base);
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
|
||||
border-bottom: 1px solid var(--border-weaker-base);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin: 1.5rem 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="markdown"] a.external-link:hover > code {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import * as mod from "./markdown"
|
||||
import { create } from "../storybook/scaffold"
|
||||
import { markdown } from "../storybook/fixtures"
|
||||
|
||||
const docs = `### Overview
|
||||
Render sanitized Markdown with code blocks, inline code, and safe links.
|
||||
|
||||
Pair with \`Code\` for standalone code views.
|
||||
|
||||
### API
|
||||
- Required: \`text\` Markdown string.
|
||||
- Uses the Marked context provider for parsing and sanitization.
|
||||
|
||||
### Variants and states
|
||||
- Code blocks include copy buttons when rendered.
|
||||
|
||||
### Behavior
|
||||
- Sanitizes HTML and auto-converts inline URL code to links.
|
||||
- Adds copy buttons to code blocks.
|
||||
|
||||
### Accessibility
|
||||
- Copy buttons include aria-labels from i18n.
|
||||
- TODO: confirm link target behavior in sanitized output.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="markdown"\` and related slots for styling.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Markdown",
|
||||
mod,
|
||||
args: {
|
||||
text: markdown,
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Markdown",
|
||||
id: "components-markdown",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
|
@ -1,584 +0,0 @@
|
|||
import { useMarked } from "../context/marked"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
onCleanup,
|
||||
splitProps,
|
||||
} from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { bundledLanguages } from "shiki"
|
||||
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
||||
import {
|
||||
disposeStreamingCode,
|
||||
highlightStreamingCode,
|
||||
MarkdownWorkerDisposedError,
|
||||
MarkdownWorkerSupersededError,
|
||||
MarkdownWorkerUnavailableError,
|
||||
} from "./markdown-worker"
|
||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||
|
||||
type RenderedBlock =
|
||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
| {
|
||||
key: string
|
||||
mode: "code"
|
||||
raw: string
|
||||
hash: string
|
||||
language: string
|
||||
complete: boolean
|
||||
generation: number
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
type RenderResult = {
|
||||
text: string
|
||||
blocks: RenderedBlock[]
|
||||
}
|
||||
|
||||
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
||||
|
||||
const iconPaths = {
|
||||
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
||||
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
||||
const name = language && language in bundledLanguages ? language : "text"
|
||||
try {
|
||||
const result = await highlightStreamingCode(key, text, name, complete)
|
||||
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof MarkdownWorkerDisposedError) &&
|
||||
!(error instanceof MarkdownWorkerSupersededError) &&
|
||||
!(error instanceof MarkdownWorkerUnavailableError)
|
||||
)
|
||||
console.error("Markdown highlighting worker failed", error)
|
||||
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
||||
}
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
}
|
||||
|
||||
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
|
||||
|
||||
function codeUrl(text: string) {
|
||||
const href = text.trim().replace(/[),.;!?]+$/, "")
|
||||
if (!urlPattern.test(href)) return
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return url.toString()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function createIcon(path: string, slot: string) {
|
||||
const icon = document.createElement("div")
|
||||
icon.setAttribute("data-component", "icon")
|
||||
icon.setAttribute("data-size", "small")
|
||||
icon.setAttribute("data-slot", slot)
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
||||
svg.setAttribute("data-slot", "icon-svg")
|
||||
svg.setAttribute("fill", "none")
|
||||
svg.setAttribute("viewBox", "0 0 20 20")
|
||||
svg.setAttribute("aria-hidden", "true")
|
||||
svg.innerHTML = path
|
||||
icon.appendChild(svg)
|
||||
return icon
|
||||
}
|
||||
|
||||
function createCopyButton(labels: CopyLabels) {
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.setAttribute("data-component", "icon-button")
|
||||
button.setAttribute("data-variant", "secondary")
|
||||
button.setAttribute("data-size", "small")
|
||||
button.setAttribute("data-slot", "markdown-copy-button")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
||||
button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
||||
return button
|
||||
}
|
||||
|
||||
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
|
||||
if (copied) {
|
||||
button.setAttribute("data-copied", "true")
|
||||
button.setAttribute("aria-label", labels.copied)
|
||||
button.setAttribute("data-tooltip", labels.copied)
|
||||
return
|
||||
}
|
||||
button.removeAttribute("data-copied")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
}
|
||||
|
||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
const parent = block.parentElement
|
||||
if (!parent) return
|
||||
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
||||
if (!wrapped) {
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
parent.replaceChild(wrapper, block)
|
||||
wrapper.appendChild(block)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
||||
)
|
||||
|
||||
if (buttons.length === 0) {
|
||||
parent.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
for (const button of buttons.slice(1)) {
|
||||
button.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function markCodeLinks(root: HTMLDivElement) {
|
||||
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
||||
for (const code of codeNodes) {
|
||||
const href = codeUrl(code.textContent ?? "")
|
||||
const parentLink =
|
||||
code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
|
||||
? code.parentElement
|
||||
: null
|
||||
|
||||
if (!href) {
|
||||
if (parentLink) parentLink.replaceWith(code)
|
||||
continue
|
||||
}
|
||||
|
||||
if (parentLink) {
|
||||
parentLink.href = href
|
||||
continue
|
||||
}
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.href = href
|
||||
link.className = "external-link"
|
||||
link.target = "_blank"
|
||||
link.rel = "noopener noreferrer"
|
||||
code.parentNode?.replaceChild(link, code)
|
||||
link.appendChild(code)
|
||||
}
|
||||
}
|
||||
|
||||
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||
for (const block of blocks) {
|
||||
ensureCodeWrapper(block, labels)
|
||||
}
|
||||
markCodeLinks(root)
|
||||
}
|
||||
|
||||
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
||||
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const updateLabel = (button: HTMLButtonElement) => {
|
||||
const labels = getLabels()
|
||||
const copied = button.getAttribute("data-copied") === "true"
|
||||
setCopyState(button, labels, copied)
|
||||
}
|
||||
|
||||
const handleClick = async (event: MouseEvent) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Element)) return
|
||||
|
||||
const button = target.closest('[data-slot="markdown-copy-button"]')
|
||||
if (!(button instanceof HTMLButtonElement)) return
|
||||
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
||||
const content = code?.textContent ?? ""
|
||||
if (!content) return
|
||||
const clipboard = navigator?.clipboard
|
||||
if (!clipboard) return
|
||||
await clipboard.writeText(content)
|
||||
const labels = getLabels()
|
||||
setCopyState(button, labels, true)
|
||||
const existing = timeouts.get(button)
|
||||
if (existing) clearTimeout(existing)
|
||||
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
|
||||
timeouts.set(button, timeout)
|
||||
}
|
||||
|
||||
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
||||
for (const button of buttons) {
|
||||
if (button instanceof HTMLButtonElement) updateLabel(button)
|
||||
}
|
||||
|
||||
root.addEventListener("click", handleClick)
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("click", handleClick)
|
||||
for (const timeout of timeouts.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
|
||||
if (!text) return { text, blocks: [] }
|
||||
const base = key ?? checksum(text)
|
||||
if (base) {
|
||||
const blocks = projection.blocks.flatMap((block, index) => {
|
||||
if (block.mode === "code") return []
|
||||
const cacheKey = `${base}:${index}:${block.mode}`
|
||||
const cached = getCachedMarkdown(cacheKey)
|
||||
if (cached?.raw !== block.raw) return []
|
||||
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
||||
})
|
||||
if (blocks.length === projection.blocks.length) return { text, blocks }
|
||||
}
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
{
|
||||
key: "initial",
|
||||
mode: "full",
|
||||
raw: text,
|
||||
hash: checksum(text) ?? "",
|
||||
html: fallback(text),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown(
|
||||
props: ComponentProps<"div"> & {
|
||||
text: string
|
||||
cacheKey?: string
|
||||
streaming?: boolean
|
||||
class?: string
|
||||
classList?: Record<string, boolean>
|
||||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
|
||||
const marked = useMarked()
|
||||
const i18n = useI18n()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const owner = createUniqueId()
|
||||
const activeCodeKeys = new Set<string>()
|
||||
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
|
||||
const projection = createMemo((previous: Projection | undefined) =>
|
||||
project(previous, local.text, local.streaming ?? false),
|
||||
)
|
||||
const [html] = createResource(
|
||||
() => {
|
||||
return {
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
projection: projection(),
|
||||
}
|
||||
},
|
||||
async (src) => {
|
||||
if (isServer)
|
||||
return {
|
||||
text: src.text,
|
||||
blocks: [
|
||||
{
|
||||
key: "server",
|
||||
mode: "full" as const,
|
||||
raw: src.text,
|
||||
hash: checksum(src.text) ?? "",
|
||||
html: fallback(src.text),
|
||||
},
|
||||
],
|
||||
} satisfies RenderResult
|
||||
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
|
||||
|
||||
const base = src.key ?? checksum(src.text)
|
||||
return Promise.all(
|
||||
src.projection.blocks.map(async (block, index) => {
|
||||
const key = base ? `${base}:${index}:${block.mode}` : undefined
|
||||
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
||||
|
||||
if (block.mode === "code") {
|
||||
const cached = completedCode.get(blockKey)
|
||||
if (block.complete && cached?.raw === block.raw) return cached
|
||||
const result = await code(block.src, block.language, blockKey, block.complete)
|
||||
const rendered = {
|
||||
key: blockKey,
|
||||
mode: block.mode,
|
||||
raw: block.raw,
|
||||
hash: String(block.raw.length),
|
||||
complete: !!block.complete,
|
||||
...result,
|
||||
}
|
||||
if (block.complete) completedCode.set(blockKey, rendered)
|
||||
return rendered
|
||||
}
|
||||
|
||||
if (key) {
|
||||
const cached = getCachedMarkdown(key)
|
||||
if (cached?.raw === block.raw) {
|
||||
touchCachedMarkdown(key, cached)
|
||||
return { key: blockKey, mode: block.mode, ...cached }
|
||||
}
|
||||
}
|
||||
|
||||
const hash = checksum(block.raw)
|
||||
const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
|
||||
if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
|
||||
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
|
||||
}),
|
||||
)
|
||||
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
text: src.text,
|
||||
blocks: [
|
||||
{
|
||||
key: base ?? "fallback",
|
||||
mode: "full" as const,
|
||||
raw: src.text,
|
||||
hash: checksum(src.text) ?? "",
|
||||
html: fallback(src.text),
|
||||
},
|
||||
],
|
||||
}) satisfies RenderResult,
|
||||
)
|
||||
},
|
||||
{
|
||||
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
|
||||
},
|
||||
)
|
||||
|
||||
let copyCleanup: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const container = root()
|
||||
const result = html.latest ?? html()
|
||||
const projected = projection()
|
||||
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
if (content.length === 0) {
|
||||
container.innerHTML = ""
|
||||
return
|
||||
}
|
||||
|
||||
const labels = {
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}
|
||||
const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
|
||||
activeCodeKeys.forEach((key) => {
|
||||
if (!nextCodeKeys.has(key)) disposeCode(key)
|
||||
})
|
||||
activeCodeKeys.clear()
|
||||
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
||||
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
||||
while (container.children.length > content.length) container.lastElementChild?.remove()
|
||||
container
|
||||
.querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]')
|
||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}))
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
completedCode.clear()
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="markdown"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
ref={setRoot}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function pendingBlocks(
|
||||
result: RenderResult | undefined,
|
||||
projection: Projection | undefined,
|
||||
cacheKey: string | undefined,
|
||||
owner: string,
|
||||
) {
|
||||
if (!result) return []
|
||||
if (!projection || result.text === projection.text) return result.blocks
|
||||
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
|
||||
return projection.blocks.map((block, index) => {
|
||||
const current = initial ? undefined : result.blocks[index]
|
||||
if (current && canReusePendingBlock(current, block)) return current
|
||||
const key = markdownBlockKey(owner, cacheKey, index, block.mode)
|
||||
if (block.mode !== "code")
|
||||
return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
|
||||
return {
|
||||
key,
|
||||
mode: block.mode,
|
||||
raw: block.raw,
|
||||
hash: String(block.raw.length),
|
||||
language: block.language ?? "text",
|
||||
complete: !!block.complete,
|
||||
stable: [],
|
||||
generation: 0,
|
||||
unstable: [[block.src, ""] as MarkdownToken],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function disposeCode(key: string) {
|
||||
disposeStreamingCode(key)
|
||||
}
|
||||
|
||||
function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
|
||||
const current = container.children[index]
|
||||
if (block.mode === "code") {
|
||||
updateCodeBlock(container, current, block, labels)
|
||||
return
|
||||
}
|
||||
if (
|
||||
current instanceof HTMLDivElement &&
|
||||
current.dataset.markdownKey === block.key &&
|
||||
current.dataset.markdownHash === block.hash
|
||||
)
|
||||
return
|
||||
|
||||
const next = document.createElement("div")
|
||||
next.dataset.markdownBlock = ""
|
||||
next.dataset.markdownKey = block.key
|
||||
next.dataset.markdownHash = block.hash
|
||||
next.style.display = "contents"
|
||||
next.innerHTML = block.html
|
||||
decorate(next, labels)
|
||||
|
||||
if (!(current instanceof HTMLDivElement)) {
|
||||
container.appendChild(next)
|
||||
return
|
||||
}
|
||||
|
||||
morphdom(current, next, {
|
||||
onBeforeElUpdated: (fromEl, toEl) => {
|
||||
if (
|
||||
fromEl instanceof HTMLButtonElement &&
|
||||
toEl instanceof HTMLButtonElement &&
|
||||
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
||||
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (fromEl.isEqualNode(toEl)) return false
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function updateCodeBlock(
|
||||
container: HTMLDivElement,
|
||||
current: Element | undefined,
|
||||
block: Extract<RenderedBlock, { mode: "code" }>,
|
||||
labels: CopyLabels,
|
||||
) {
|
||||
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
|
||||
const next = existing ?? document.createElement("div")
|
||||
next.dataset.markdownBlock = ""
|
||||
next.dataset.markdownKey = block.key
|
||||
next.dataset.markdownHash = block.hash
|
||||
next.dataset.markdownComplete = block.complete ? "true" : "false"
|
||||
next.style.display = "contents"
|
||||
|
||||
const code = existing?.querySelector("code")
|
||||
if (code instanceof HTMLElement) {
|
||||
code.className = `language-${block.language}`
|
||||
const previous = renderedCodeTokens.get(next)
|
||||
const reset = shouldResetCodeTokens(previous, {
|
||||
language: block.language,
|
||||
generation: block.generation,
|
||||
stableCount: block.stable.length,
|
||||
raw: block.raw,
|
||||
})
|
||||
const stableCount = reset ? 0 : previous!.stableCount
|
||||
const tail = [...block.stable.slice(stableCount), ...block.unstable]
|
||||
const prior = reset ? [] : previous!.unstable
|
||||
const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
|
||||
const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
|
||||
while (code.children.length > keep) code.lastElementChild?.remove()
|
||||
tail
|
||||
.slice(keep - stableCount)
|
||||
.map(createTokenSpan)
|
||||
.forEach((span) => code.appendChild(span))
|
||||
renderedCodeTokens.set(next, {
|
||||
language: block.language,
|
||||
generation: block.generation,
|
||||
stableCount: block.stable.length,
|
||||
unstable: block.unstable,
|
||||
raw: block.raw,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
const pre = document.createElement("pre")
|
||||
pre.className = "shiki OpenCode"
|
||||
const codeElement = document.createElement("code")
|
||||
codeElement.className = `language-${block.language}`
|
||||
;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
|
||||
pre.appendChild(codeElement)
|
||||
wrapper.appendChild(pre)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
next.appendChild(wrapper)
|
||||
renderedCodeTokens.set(next, {
|
||||
language: block.language,
|
||||
generation: block.generation,
|
||||
stableCount: block.stable.length,
|
||||
unstable: block.unstable,
|
||||
raw: block.raw,
|
||||
})
|
||||
if (current) current.replaceWith(next)
|
||||
else container.appendChild(next)
|
||||
}
|
||||
|
||||
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
|
||||
return !!right && left[0] === right[0] && left[1] === right[1]
|
||||
}
|
||||
|
||||
function createTokenSpan(token: MarkdownToken) {
|
||||
const span = document.createElement("span")
|
||||
span.setAttribute("style", token[1])
|
||||
span.textContent = token[0]
|
||||
return span
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
import { attached, inline, kind } from "./message-file"
|
||||
|
||||
function file(part: Partial<FilePart> = {}): FilePart {
|
||||
return {
|
||||
id: "part_1",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_1",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: "file:///repo/README.txt",
|
||||
filename: "README.txt",
|
||||
...part,
|
||||
}
|
||||
}
|
||||
|
||||
describe("message-file", () => {
|
||||
test("treats data URLs as attachments", () => {
|
||||
expect(attached(file({ url: "data:text/plain;base64,SGVsbG8=" }))).toBe(true)
|
||||
expect(attached(file())).toBe(false)
|
||||
})
|
||||
|
||||
test("treats only non-attachment source ranges as inline references", () => {
|
||||
expect(
|
||||
inline(
|
||||
file({
|
||||
source: {
|
||||
type: "file",
|
||||
path: "/repo/README.txt",
|
||||
text: { value: "@README.txt", start: 0, end: 11 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
inline(
|
||||
file({
|
||||
url: "data:text/plain;base64,SGVsbG8=",
|
||||
source: {
|
||||
type: "file",
|
||||
path: "/repo/README.txt",
|
||||
text: { value: "@README.txt", start: 0, end: 11 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("separates image and file attachment kinds", () => {
|
||||
expect(kind(file({ mime: "image/png" }))).toBe("image")
|
||||
expect(kind(file({ mime: "application/pdf" }))).toBe("file")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export function attached(part: FilePart) {
|
||||
return part.url.startsWith("data:")
|
||||
}
|
||||
|
||||
export function inline(part: FilePart) {
|
||||
if (attached(part)) return false
|
||||
return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
|
||||
}
|
||||
|
||||
export function kind(part: FilePart) {
|
||||
return part.mime.startsWith("image/") ? "image" : "file"
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
[data-component="message-nav"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
|
||||
&[data-size="normal"] {
|
||||
width: 200px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&[data-size="compact"] {
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
|
||||
[data-component="message-nav"][data-size="normal"] & {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 12px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
&[data-active] [data-slot="message-nav-tick-line"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-line"] {
|
||||
height: 1px;
|
||||
width: 16px;
|
||||
background-color: var(--icon-base);
|
||||
transition:
|
||||
width 0.2s,
|
||||
background-color 0.2s;
|
||||
}
|
||||
|
||||
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
|
||||
width: 100%;
|
||||
background-color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-message-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
column-gap: 10px;
|
||||
cursor: default;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-title-preview"] {
|
||||
font-size: 14px; /* text-14-regular */
|
||||
color: var(--text-base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
|
||||
&[data-active] {
|
||||
color: var(--text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:has([data-slot="message-nav-title-preview"][data-active])
|
||||
[data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-nav-hovercard-content"] {
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
padding: 4px 4px 6px 4px;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
max-height: min(480px, calc(100vh - 6rem));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
/* border/shadow-xs/base */
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.04),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
|
||||
* {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import * as mod from "./message-nav"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({ title: "UI/MessageNav", mod })
|
||||
export default { title: "UI/MessageNav", id: "components-message-nav", component: story.meta.component }
|
||||
export const Basic = story.Basic
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { HoverCard } from "@kobalte/core/hover-card"
|
||||
import { ComponentProps, For, Match, Show, createSignal, splitProps, Switch } from "solid-js"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
export function MessageNav(
|
||||
props: ComponentProps<"ul"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
getLabel?: (message: UserMessage) => string | undefined
|
||||
},
|
||||
) {
|
||||
const i18n = useI18n()
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel", "class"])
|
||||
const [hovercardOpen, setHovercardOpen] = createSignal(false)
|
||||
|
||||
const selectMessage = (message: UserMessage) => {
|
||||
setHovercardOpen(false)
|
||||
local.onMessageSelect(message)
|
||||
}
|
||||
|
||||
const content = (className?: string) => (
|
||||
<ul role="list" data-component="message-nav" data-size={local.size} class={className} {...others}>
|
||||
<For each={local.messages}>
|
||||
{(message) => {
|
||||
const handleClick = () => selectMessage(message)
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
selectMessage(message)
|
||||
}
|
||||
|
||||
return (
|
||||
<li data-slot="message-nav-item">
|
||||
<Switch>
|
||||
<Match when={local.size === "compact"}>
|
||||
<div
|
||||
data-slot="message-nav-tick-button"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyPress}
|
||||
>
|
||||
<div data-slot="message-nav-tick-line" />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>
|
||||
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
|
||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
|
||||
<div
|
||||
data-slot="message-nav-title-preview"
|
||||
data-active={message.id === local.current?.id || undefined}
|
||||
>
|
||||
<Show
|
||||
when={local.getLabel?.(message) ?? message.summary?.title}
|
||||
fallback={i18n.t("ui.messageNav.newMessage")}
|
||||
>
|
||||
{local.getLabel?.(message) ?? message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={local.size === "compact"}>
|
||||
<HoverCard
|
||||
open={hovercardOpen()}
|
||||
onOpenChange={setHovercardOpen}
|
||||
openDelay={0}
|
||||
closeDelay={120}
|
||||
placement="right-start"
|
||||
gutter={8}
|
||||
overflowPadding={24}
|
||||
fitViewport
|
||||
>
|
||||
<HoverCard.Trigger as="div" data-component="message-nav-hovercard" class={local.class}>
|
||||
{content()}
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content data-slot="message-nav-hovercard-content">
|
||||
<MessageNav {...props} size="normal" class="" onMessageSelect={selectMessage} />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard>
|
||||
</Match>
|
||||
<Match when={local.size === "normal"}>{content(local.class)}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export function readPartText(accum: Record<string, string> | undefined, part: { id: string; text?: string }): string {
|
||||
return (accum?.[part.id] ?? part.text ?? "").trim()
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import * as mod from "./message-part"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({ title: "UI/MessagePart", mod })
|
||||
export default { title: "UI/MessagePart", id: "components-message-part", component: story.meta.component }
|
||||
export const Basic = story.Basic
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { readPartText } from "./message-part-text"
|
||||
|
||||
describe("readPartText", () => {
|
||||
test("returns empty string when accum is undefined and part text is undefined", () => {
|
||||
expect(readPartText(undefined, { id: "part_1" })).toBe("")
|
||||
})
|
||||
|
||||
test("returns trimmed part text when accum is undefined", () => {
|
||||
expect(readPartText(undefined, { id: "part_1", text: " hello " })).toBe("hello")
|
||||
})
|
||||
|
||||
test("prefers accum value over part text when accum has a hit", () => {
|
||||
expect(readPartText({ part_1: " from accum " }, { id: "part_1", text: "from part" })).toBe("from accum")
|
||||
})
|
||||
|
||||
test("falls back to part text when accum misses", () => {
|
||||
expect(readPartText({ other_part: "ignored" }, { id: "part_1", text: " from part " })).toBe("from part")
|
||||
})
|
||||
|
||||
test("returns empty string for whitespace-only text", () => {
|
||||
expect(readPartText(undefined, { id: "part_1", text: " \n\t " })).toBe("")
|
||||
})
|
||||
|
||||
test("trims leading and trailing whitespace", () => {
|
||||
expect(readPartText(undefined, { id: "part_1", text: "\n body \n" })).toBe("body")
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,135 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { normalize, resolveFileDiff, text } from "./session-diff"
|
||||
|
||||
describe("session diff", () => {
|
||||
test("renders whole-file unified patches as complete diffs", () => {
|
||||
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.fileDiff.name).toBe("a.ts")
|
||||
expect(view.fileDiff.isPartial).toBe(false)
|
||||
expect(text(view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
|
||||
test("keeps missing final newlines from unified patches", () => {
|
||||
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\\ No newline at end of file\n+three\n\\ No newline at end of file\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(text(view, "deletions")).toBe("one\ntwo")
|
||||
expect(text(view, "additions")).toBe("one\nthree")
|
||||
})
|
||||
|
||||
test("renders whole-file VCS patches as complete diffs", () => {
|
||||
const fileDiff = resolveFileDiff({
|
||||
file: "a.ts",
|
||||
patch:
|
||||
"diff --git a/a.ts b/a.ts\nindex 1a2b3c4..5d6e7f8 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,2 +1,2 @@\n one\n-old\n+new\n",
|
||||
})
|
||||
|
||||
expect(fileDiff.isPartial).toBe(false)
|
||||
expect(fileDiff.additionLines).toEqual(["one\n", "new\n"])
|
||||
})
|
||||
|
||||
test("keeps ordinary leading tool patches partial", () => {
|
||||
const fileDiff = resolveFileDiff({
|
||||
file: "a.ts",
|
||||
patch:
|
||||
"Index: a.ts\n===================================================================\n--- a.ts\n+++ a.ts\n@@ -1,5 +1,5 @@\n-old\n+new\n two\n three\n four\n five\n",
|
||||
})
|
||||
|
||||
expect(fileDiff.isPartial).toBe(true)
|
||||
expect(fileDiff.additionLines).toEqual(["new\n", "two\n", "three\n", "four\n", "five\n"])
|
||||
})
|
||||
|
||||
test("keeps separated patch hunks partial without complete file contents", () => {
|
||||
const fileDiff = resolveFileDiff({
|
||||
file: "project.ts",
|
||||
patch:
|
||||
'Index: project.ts\n===================================================================\n--- project.ts\t\n+++ project.ts\t\n@@ -1,3 +1,2 @@\n import { and } from "drizzle-orm"\n-import { sql } from "drizzle-orm"\n import { ProjectTable } from "./project.sql"\n@@ -346,3 +345,3 @@\n import { Database } from "@/storage/db"\n-import { ProjectTable } from "./project.sql"\n+import { ProjectTable } from "../project/project.sql"\n import { SessionTable } from "../session/session.sql"\n',
|
||||
})
|
||||
|
||||
expect(fileDiff.isPartial).toBe(true)
|
||||
expect(fileDiff.hunks).toHaveLength(2)
|
||||
expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("renders headerless persisted patches", () => {
|
||||
const view = normalize({
|
||||
file: "a.ts",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
})
|
||||
|
||||
expect(view.fileDiff.name).toBe("a.ts")
|
||||
expect(view.fileDiff.isPartial).toBe(true)
|
||||
expect(text(view, "deletions")).toBe("old\n")
|
||||
expect(text(view, "additions")).toBe("new\n")
|
||||
})
|
||||
|
||||
test("does not share headerless patch metadata between files", () => {
|
||||
const patch = "@@ -1 +1 @@\n-old\n+new\n"
|
||||
|
||||
expect(resolveFileDiff({ file: "a.ts", patch }).name).toBe("a.ts")
|
||||
expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
|
||||
})
|
||||
|
||||
test("keeps capped header-only patches partial", () => {
|
||||
const fileDiff = resolveFileDiff({
|
||||
file: "a.ts",
|
||||
patch:
|
||||
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n",
|
||||
})
|
||||
|
||||
expect(fileDiff.name).toBe("a.ts")
|
||||
expect(fileDiff.isPartial).toBe(true)
|
||||
expect(fileDiff.hunks).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps full legacy content as a complete diff", () => {
|
||||
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.fileDiff.isPartial).toBe(false)
|
||||
expect(text(view, "deletions")).toBe("one\n")
|
||||
expect(text(view, "additions")).toBe("two\n")
|
||||
})
|
||||
|
||||
test("ignores malformed persisted patches", () => {
|
||||
const diff = {
|
||||
file: "a.ts",
|
||||
patch:
|
||||
"diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(text(view, "deletions")).toBe("")
|
||||
expect(text(view, "additions")).toBe("")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
|
||||
import { parsePatch } 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 SnapshotDiff = SnapshotFileDiff & { file: string }
|
||||
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
|
||||
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
|
||||
|
||||
export type ViewDiff = {
|
||||
file: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
fileDiff: FileDiffMetadata
|
||||
}
|
||||
|
||||
const diffCacheLimit = 16
|
||||
const patchFileDiffCache = new Map<string, FileDiffMetadata>()
|
||||
|
||||
export function resolveFileDiff(diff: DiffSource) {
|
||||
if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
|
||||
return fileDiffFromContent(
|
||||
diff.file,
|
||||
typeof diff.before === "string" ? diff.before : "",
|
||||
typeof diff.after === "string" ? diff.after : "",
|
||||
)
|
||||
}
|
||||
|
||||
export function normalize(diff: ReviewDiff): ViewDiff {
|
||||
return {
|
||||
file: diff.file,
|
||||
additions: diff.additions,
|
||||
deletions: diff.deletions,
|
||||
status: diff.status,
|
||||
fileDiff: resolveFileDiff(diff),
|
||||
}
|
||||
}
|
||||
|
||||
export function text(diff: ViewDiff, side: "deletions" | "additions") {
|
||||
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
|
||||
return diff.fileDiff.additionLines.join("")
|
||||
}
|
||||
|
||||
function fileDiffFromPatch(file: string, patch: string) {
|
||||
const key = `${file}\0${patch}`
|
||||
const hit = patchFileDiffCache.get(key)
|
||||
if (hit) {
|
||||
patchFileDiffCache.delete(key)
|
||||
patchFileDiffCache.set(key, hit)
|
||||
return hit
|
||||
}
|
||||
|
||||
const contents = completePatchContents(patch)
|
||||
const input = contents ? undefined : patchInput(file, patch)
|
||||
const value = contents
|
||||
? fileDiffFromContent(file, contents.before, contents.after)
|
||||
: ((input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file))
|
||||
patchFileDiffCache.set(key, value)
|
||||
while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
|
||||
return value
|
||||
}
|
||||
|
||||
function completePatchContents(patch: string) {
|
||||
try {
|
||||
const parsed = parsePatch(patch)[0]
|
||||
if (!parsed || (!parsed.index && !parsed.oldFileName && !parsed.newFileName)) return
|
||||
// Snapshot and VCS producers request full context. Tool patches use jsdiff's shorter default context.
|
||||
if (!patch.startsWith("diff --git ") && !/^--- [^\n]*\t\r?\n\+\+\+ [^\n]*\t(?:\r?\n|$)/m.test(patch)) return
|
||||
// Full patches collapse into one leading hunk. Separated hunks omit ranges and must stay partial.
|
||||
if (parsed.hunks.length !== 1) return
|
||||
|
||||
const hunk = parsed.hunks[0]
|
||||
if (!hunk || hunk.oldStart > 1 || hunk.newStart > 1) return
|
||||
|
||||
const before: Array<{ text: string; newline: boolean }> = []
|
||||
const after: Array<{ text: string; newline: boolean }> = []
|
||||
let previous: "-" | "+" | " " | undefined
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
if (line.startsWith("\\")) {
|
||||
if (previous === "-" || previous === " ") {
|
||||
const value = before.at(-1)
|
||||
if (value) value.newline = false
|
||||
}
|
||||
if (previous === "+" || previous === " ") {
|
||||
const value = after.at(-1)
|
||||
if (value) value.newline = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("-")) {
|
||||
before.push({ text: line.slice(1), newline: true })
|
||||
previous = "-"
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("+")) {
|
||||
after.push({ text: line.slice(1), newline: true })
|
||||
previous = "+"
|
||||
continue
|
||||
}
|
||||
if (!line.startsWith(" ")) return
|
||||
before.push({ text: line.slice(1), newline: true })
|
||||
after.push({ text: line.slice(1), newline: true })
|
||||
previous = " "
|
||||
}
|
||||
|
||||
const text = (lines: Array<{ text: string; newline: boolean }>) =>
|
||||
lines.map((line) => line.text + (line.newline ? "\n" : "")).join("")
|
||||
return { before: text(before), after: text(after) }
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function patchInput(file: string, patch: string) {
|
||||
try {
|
||||
const parsed = parsePatch(patch)[0]
|
||||
if (!parsed) return
|
||||
if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
|
||||
if (!parsed.hunks.length) return
|
||||
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiffFromContent(file: string, before: string, after: string) {
|
||||
if (!before && !after) return emptyFileDiff(file)
|
||||
return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
|
||||
}
|
||||
|
||||
function emptyFileDiff(file: string) {
|
||||
return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
|
||||
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { Card } from "./card"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function SessionRetry(props: { status: SessionStatus; show?: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const retry = createMemo(() => {
|
||||
if (props.status.type !== "retry") return
|
||||
return props.status
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
createEffect(
|
||||
on(retry, (current) => {
|
||||
if (!current) return
|
||||
const update = () => {
|
||||
const next = retry()?.next
|
||||
if (!next) return
|
||||
setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}
|
||||
update()
|
||||
const timer = setInterval(update, 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
}),
|
||||
)
|
||||
const message = createMemo(() => {
|
||||
const current = retry()
|
||||
if (!current) return ""
|
||||
if (current.message.includes("exceeded your current quota") && current.message.includes("gemini")) {
|
||||
return i18n.t("ui.sessionTurn.retry.geminiHot")
|
||||
}
|
||||
if (current.message.length > 80) return current.message.slice(0, 80) + "..."
|
||||
return current.message
|
||||
})
|
||||
const truncated = createMemo(() => {
|
||||
const current = retry()
|
||||
if (!current) return false
|
||||
return current.message.length > 80
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const current = retry()
|
||||
if (!current) return ""
|
||||
const count = Math.max(0, seconds())
|
||||
const delay = count > 0 ? i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: count }) : ""
|
||||
const retrying = i18n.t("ui.sessionTurn.retry.retrying")
|
||||
const line = [retrying, delay].filter(Boolean).join(" ")
|
||||
if (!line) return i18n.t("ui.sessionTurn.retry.attempt", { attempt: current.attempt })
|
||||
return i18n.t("ui.sessionTurn.retry.attemptLine", { line, attempt: current.attempt })
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={retry() && (props.show ?? true)}>
|
||||
<div data-slot="session-turn-retry">
|
||||
<Card variant="error" class="error-card">
|
||||
<div class="flex items-start gap-2">
|
||||
<Spinner class="size-4 mt-0.5" />
|
||||
<div class="min-w-0">
|
||||
<Show when={truncated()} fallback={<div data-slot="session-turn-retry-message">{message()}</div>}>
|
||||
<Tooltip value={retry()?.message ?? ""} placement="top">
|
||||
<div data-slot="session-turn-retry-message" class="cursor-help truncate">
|
||||
{message()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={info()}>{(line) => <div data-slot="session-turn-retry-info">{line()}</div>}</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
[data-component="session-review"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0px;
|
||||
height: 100%;
|
||||
|
||||
[data-slot="session-review-scroll"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scroll-view__viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="session-review-container"] {
|
||||
flex: 1 1 auto;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-header"] {
|
||||
z-index: 120;
|
||||
background-color: var(--background-stronger);
|
||||
height: 40px;
|
||||
padding-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="session-review-title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="session-review-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-actions"] [data-component="radio-group"] {
|
||||
[data-slot="radio-group-wrapper"],
|
||||
[data-slot="radio-group-indicator"],
|
||||
[data-slot="radio-group-item-control"] {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
|
||||
+ [data-slot="radio-group-item-label"]:hover
|
||||
[data-slot="radio-group-item-control"] {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="sticky-accordion-header"] {
|
||||
--sticky-accordion-top: 0px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-accordion-item"][data-selected]
|
||||
[data-slot="accordion-header"]
|
||||
[data-slot="accordion-trigger"] {
|
||||
background-color: var(--surface-base-active);
|
||||
}
|
||||
|
||||
[data-slot="accordion-item"] {
|
||||
[data-slot="accordion-content"] {
|
||||
display: none;
|
||||
}
|
||||
&[data-expanded] {
|
||||
[data-slot="accordion-content"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
[data-slot="session-review-accordion-content"] {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="session-review-trigger-content"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-file-info"] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-file-name-container"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-directory"] {
|
||||
color: var(--text-base);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="session-review-filename"] {
|
||||
color: var(--text-strong);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="session-review-view-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
margin-left: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-base);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transform: translateZ(0);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-strong);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-trigger"]:hover [data-slot="session-review-view-button"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="session-review-trigger-actions"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-slot="session-review-diff-chevron"] {
|
||||
display: inline-flex;
|
||||
color: var(--icon-weaker);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="accordion-item"][data-expanded] [data-slot="session-review-diff-chevron"] {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-slot="session-review-change-group"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-change"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-slot="session-review-change"][data-type="added"] {
|
||||
color: var(--icon-diff-add-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-change"][data-type="removed"] {
|
||||
color: var(--icon-diff-delete-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-change"][data-type="modified"] {
|
||||
color: var(--icon-diff-modified-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-diff-wrapper"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
--line-comment-z: 5;
|
||||
--line-comment-popover-z: 30;
|
||||
--line-comment-open-z: 6;
|
||||
}
|
||||
|
||||
[data-slot="session-review-large-diff"] {
|
||||
padding: 12px;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="session-review-large-diff-title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-strong);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-large-diff-meta"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-slot="session-review-large-diff-actions"] {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import * as mod from "./session-review"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({ title: "UI/SessionReview", mod })
|
||||
export default { title: "UI/SessionReview", id: "components-session-review", component: story.meta.component }
|
||||
export const Basic = story.Basic
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
import { Accordion } from "./accordion"
|
||||
import { Button } from "./button"
|
||||
import { DropdownMenu } from "./dropdown-menu"
|
||||
import { RadioGroup } from "./radio-group"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { ScrollView } from "./scroll-view"
|
||||
import { useFileComponent } from "../context/file"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { checksum } from "@opencode-ai/core/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 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"
|
||||
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
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
export type SessionReviewComment = {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}
|
||||
|
||||
export type SessionReviewLineComment = {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type SessionReviewCommentUpdate = SessionReviewLineComment & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SessionReviewCommentDelete = {
|
||||
id: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export type SessionReviewCommentActions = {
|
||||
moreLabel: string
|
||||
editLabel: string
|
||||
deleteLabel: string
|
||||
saveLabel: string
|
||||
}
|
||||
|
||||
export type SessionReviewFocus = { file: string; id: string }
|
||||
|
||||
type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & {
|
||||
preloaded?: PreloadMultiFileDiffResult<any>
|
||||
}
|
||||
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & {
|
||||
preloaded?: PreloadMultiFileDiffResult<any>
|
||||
}
|
||||
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||
|
||||
function diff(value: unknown): value is ReviewDiff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
if (!("file" in value) || typeof value.file !== "string") return false
|
||||
if (!("additions" in value) || typeof value.additions !== "number") return false
|
||||
if (!("deletions" in value) || typeof value.deletions !== "number") return false
|
||||
if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false
|
||||
if ("before" in value && value.before !== undefined && typeof value.before !== "string") return false
|
||||
if ("after" in value && value.after !== undefined && typeof value.after !== "string") return false
|
||||
if (!("status" in value) || value.status === undefined) return true
|
||||
return value.status === "added" || value.status === "deleted" || value.status === "modified"
|
||||
}
|
||||
|
||||
function list(value: unknown): ReviewDiff[] {
|
||||
if (Array.isArray(value) && value.every(diff)) return value
|
||||
if (Array.isArray(value)) return value.filter(diff)
|
||||
if (diff(value)) return [value]
|
||||
if (!value || typeof value !== "object") return []
|
||||
return Object.values(value).filter(diff)
|
||||
}
|
||||
|
||||
export interface SessionReviewProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
split?: boolean
|
||||
diffStyle?: SessionReviewDiffStyle
|
||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||
onDiffRendered?: VoidFunction
|
||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||
lineCommentActions?: SessionReviewCommentActions
|
||||
comments?: SessionReviewComment[]
|
||||
focusedComment?: SessionReviewFocus | null
|
||||
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
|
||||
focusedFile?: string
|
||||
open?: string[]
|
||||
onOpenChange?: (open: string[]) => void
|
||||
scrollRef?: (el: HTMLDivElement) => void
|
||||
onScroll?: JSX.EventHandlerUnion<HTMLDivElement, Event>
|
||||
class?: string
|
||||
classList?: Record<string, boolean | undefined>
|
||||
classes?: { root?: string; header?: string; container?: string }
|
||||
actions?: JSX.Element
|
||||
diffs: RawReviewDiff[]
|
||||
onViewFile?: (file: string) => void
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
lineCommentMention?: LineCommentEditorProps["mention"]
|
||||
}
|
||||
|
||||
function ReviewCommentMenu(props: {
|
||||
labels: SessionReviewCommentActions
|
||||
onEdit: VoidFunction
|
||||
onDelete: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenu gutter={4} placement="bottom-end">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={props.labels.moreLabel}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={props.onEdit}>
|
||||
<DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={props.onDelete}>
|
||||
<DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function diffId(file: string): string | undefined {
|
||||
const sum = checksum(file)
|
||||
if (!sum) return
|
||||
return `session-review-diff-${sum}`
|
||||
}
|
||||
|
||||
type SessionReviewSelection = {
|
||||
file: string
|
||||
range: SelectedLineRange
|
||||
}
|
||||
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
opened: null as SessionReviewFocus | null,
|
||||
})
|
||||
const selection = () => store.selection
|
||||
const commenting = () => store.commenting
|
||||
const opened = () => store.opened
|
||||
|
||||
const open = () => props.open ?? store.open
|
||||
const itemsMap = createMemo(() =>
|
||||
Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])),
|
||||
)
|
||||
const files = createMemo(() => props.diffs.map((diff) => diff.file!))
|
||||
const grouped = createMemo(() => {
|
||||
const next = new Map<string, SessionReviewComment[]>()
|
||||
for (const comment of props.comments ?? []) {
|
||||
const list = next.get(comment.file)
|
||||
if (list) {
|
||||
list.push(comment)
|
||||
continue
|
||||
}
|
||||
next.set(comment.file, [comment])
|
||||
}
|
||||
return next
|
||||
})
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
|
||||
fn(data, event)
|
||||
return
|
||||
}
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
const next = open().length > 0 ? [] : files()
|
||||
handleChange(next)
|
||||
}
|
||||
|
||||
const openFileLabel = () => i18n.t("ui.sessionReview.openFile")
|
||||
|
||||
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||
|
||||
const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
|
||||
const side = selectionSide(range)
|
||||
const contents = text(diff, side)
|
||||
if (contents.length === 0) return undefined
|
||||
|
||||
return previewSelectedLines(contents, range)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const focus = props.focusedComment
|
||||
if (!focus) return
|
||||
|
||||
untrack(() => {
|
||||
focusToken++
|
||||
const token = focusToken
|
||||
|
||||
setStore("opened", focus)
|
||||
|
||||
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
||||
if (comment) setStore("selection", { file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
||||
|
||||
const current = open()
|
||||
if (!current.includes(focus.file)) {
|
||||
handleChange([...current, focus.file])
|
||||
}
|
||||
|
||||
const scrollTo = (attempt: number) => {
|
||||
if (token !== focusToken) return
|
||||
|
||||
const root = scroll
|
||||
if (!root) return
|
||||
|
||||
const wrapper = anchors.get(focus.file)
|
||||
const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||
const ready =
|
||||
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
|
||||
|
||||
const target = ready ? anchor : wrapper
|
||||
if (!target) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
const offset = targetRect.top - rootRect.top
|
||||
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
||||
root.scrollTop = Math.max(0, next)
|
||||
|
||||
if (ready) return
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => scrollTo(0))
|
||||
|
||||
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="session-review" class={props.class} classList={props.classList}>
|
||||
<div data-slot="session-review-header" class={props.classes?.header}>
|
||||
<div data-slot="session-review-title">
|
||||
{props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title}
|
||||
</div>
|
||||
<div data-slot="session-review-actions">
|
||||
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
||||
<RadioGroup
|
||||
options={["unified", "split"] as const}
|
||||
current={diffStyle()}
|
||||
size="small"
|
||||
value={(style) => style}
|
||||
label={(style) =>
|
||||
i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split")
|
||||
}
|
||||
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={hasDiffs()}>
|
||||
<Button
|
||||
size="small"
|
||||
icon="chevron-grabber-vertical"
|
||||
class="w-[106px] justify-start"
|
||||
onClick={handleExpandOrCollapseAll}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
|
||||
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
</Show>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollView
|
||||
data-slot="session-review-scroll"
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
||||
}}
|
||||
>
|
||||
<div data-slot="session-review-container" class={props.classes?.container}>
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<div data-slot="session-review-list" class="pb-6">
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const diff = () => itemsMap()[file]
|
||||
|
||||
// binary files have empty diffs that we can't render
|
||||
const diffCanRender = () => diff().additions !== 0 || diff().deletions !== 0
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => text(diff(), "deletions")
|
||||
const afterText = () => text(diff(), "additions")
|
||||
const changedLines = () => diff().additions + diff().deletions
|
||||
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
||||
|
||||
const tooLarge = createMemo(() => {
|
||||
if (!expanded()) return false
|
||||
if (force()) return false
|
||||
if (mediaKind()) return false
|
||||
return changedLines() > MAX_DIFF_CHANGED_LINES
|
||||
})
|
||||
|
||||
const isAdded = () =>
|
||||
diff().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
const isDeleted = () =>
|
||||
diff().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
if (!current || current.file !== file) return null
|
||||
return current.range
|
||||
})
|
||||
|
||||
const draftRange = createMemo(() => {
|
||||
const current = commenting()
|
||||
if (!current || current.file !== file) return null
|
||||
return current.range
|
||||
})
|
||||
|
||||
const commentsUi = createLineCommentController<SessionReviewComment>({
|
||||
comments,
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
draftKey: () => file,
|
||||
mention: props.lineCommentMention,
|
||||
state: {
|
||||
opened: () => {
|
||||
const current = opened()
|
||||
if (!current || current.file !== file) return null
|
||||
return current.id
|
||||
},
|
||||
setOpened: (id) => setStore("opened", id ? { file, id } : null),
|
||||
selected: selectedLines,
|
||||
setSelected: (range) => setStore("selection", range ? { file, range } : null),
|
||||
commenting: draftRange,
|
||||
setCommenting: (range) => setStore("commenting", range ? { file, range } : null),
|
||||
},
|
||||
getSide: selectionSide,
|
||||
clearSelectionOnSelectionEndNull: false,
|
||||
onSubmit: ({ comment, selection }) => {
|
||||
props.onLineComment?.({
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(diff(), selection),
|
||||
})
|
||||
},
|
||||
onUpdate: ({ id, comment, selection }) => {
|
||||
props.onLineCommentUpdate?.({
|
||||
id,
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(diff(), selection),
|
||||
})
|
||||
},
|
||||
onDelete: (comment) => {
|
||||
props.onLineCommentDelete?.({
|
||||
id: comment.id,
|
||||
file,
|
||||
})
|
||||
},
|
||||
editSubmitLabel: props.lineCommentActions?.saveLabel,
|
||||
renderCommentActions: props.lineCommentActions
|
||||
? (comment, controls) => (
|
||||
<ReviewCommentMenu
|
||||
labels={props.lineCommentActions!}
|
||||
onEdit={controls.edit}
|
||||
onDelete={controls.remove}
|
||||
/>
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
commentsUi.onLineSelected(range)
|
||||
}
|
||||
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item
|
||||
value={diffCanRender() ? file : null!}
|
||||
id={diffId(file)}
|
||||
data-file={file}
|
||||
data-slot="session-review-accordion-item"
|
||||
data-selected={props.focusedFile === file ? "" : undefined}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger disabled={!diffCanRender()} class="cursor-default">
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div data-slot="session-review-file-info">
|
||||
<FileIcon node={{ path: file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(file)}</span>
|
||||
<Show when={props.onViewFile && diffCanRender()}>
|
||||
<Tooltip value={openFileLabel()} placement="top" gutter={4}>
|
||||
<button
|
||||
data-slot="session-review-view-button"
|
||||
type="button"
|
||||
aria-label={openFileLabel()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onViewFile?.(file)
|
||||
}}
|
||||
>
|
||||
<Icon name="open-file" size="small" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={isAdded()}>
|
||||
<div data-slot="session-review-change-group" data-type="added">
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
<DiffChanges changes={diff()} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{i18n.t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={!!mediaKind()}>
|
||||
<span data-slot="session-review-change" data-type="modified">
|
||||
{i18n.t("ui.sessionReview.change.modified")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={diff()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={diffCanRender()}>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-review-accordion-content">
|
||||
<div
|
||||
data-slot="session-review-diff-wrapper"
|
||||
ref={(el) => {
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
{i18n.t("ui.sessionReview.largeDiff.title")}
|
||||
</div>
|
||||
<div data-slot="session-review-large-diff-meta">
|
||||
{i18n.t("ui.sessionReview.largeDiff.meta", {
|
||||
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
|
||||
current: changedLines().toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
<div data-slot="session-review-large-diff-actions">
|
||||
<Button
|
||||
size="normal"
|
||||
variant="secondary"
|
||||
onClick={() => setStore("force", file, true)}
|
||||
>
|
||||
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
fileDiff={diff().fileDiff}
|
||||
preloadedDiff={diff().preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
}}
|
||||
enableLineSelection={props.onLineComment != null}
|
||||
enableGutterUtility={props.onLineComment != null}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionEnd={handleLineSelectionEnd}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
renderGutterUtility={
|
||||
props.onLineComment ? commentsUi.renderGutterUtility : undefined
|
||||
}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: file,
|
||||
deleted: diff().status === "deleted",
|
||||
readFile: diff().status === "deleted" ? undefined : props.readFile,
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
[data-component="session-turn"] {
|
||||
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
||||
[data-slot="session-turn-content"] {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-content"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-container"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
gap: 0px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-content"] {
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-compaction"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
min-height: 20px;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="text-reveal"].session-turn-thinking-heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-weaker);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.error-card {
|
||||
color: var(--text-on-critical-base);
|
||||
max-height: 240px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-assistant-content"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 12px;
|
||||
position: sticky;
|
||||
top: var(--sticky-accordion-top, 0px);
|
||||
z-index: 20;
|
||||
background-color: var(--background-stronger);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
[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);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-toggle"] {
|
||||
color: var(--text-interactive-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
[data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="session-turn-diffs-group"][data-show-all] [data-slot="session-turn-diffs-toggle"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-turn-diffs-content"] {
|
||||
padding-top: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-path"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-directory"] {
|
||||
color: var(--text-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-filename"] {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-meta"] {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-chevron"] {
|
||||
display: inline-flex;
|
||||
color: var(--icon-weaker);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="accordion-item"][data-expanded] [data-slot="session-turn-diff-chevron"] {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-view"] {
|
||||
background-color: var(--surface-inset-base);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-view"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-list"] {
|
||||
gap: 24px;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import * as mod from "./session-turn"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({ title: "UI/SessionTurn", mod })
|
||||
export default { title: "UI/SessionTurn", id: "components-session-turn", component: story.meta.component }
|
||||
export const Basic = story.Basic
|
||||
|
|
@ -1,540 +0,0 @@
|
|||
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"
|
||||
|
||||
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 { 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"
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function unwrap(message: string) {
|
||||
const text = message.replace(/^Error:\s*/, "").trim()
|
||||
|
||||
const parse = (value: string) => {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const read = (value: string) => {
|
||||
const first = parse(value)
|
||||
if (typeof first !== "string") return first
|
||||
return parse(first.trim())
|
||||
}
|
||||
|
||||
let json = read(text)
|
||||
|
||||
if (json === undefined) {
|
||||
const start = text.indexOf("{")
|
||||
const end = text.lastIndexOf("}")
|
||||
if (start !== -1 && end > start) {
|
||||
json = read(text.slice(start, end + 1))
|
||||
}
|
||||
}
|
||||
|
||||
if (!record(json)) return message
|
||||
|
||||
const err = record(json.error) ? json.error : undefined
|
||||
if (err) {
|
||||
const type = typeof err.type === "string" ? err.type : undefined
|
||||
const msg = typeof err.message === "string" ? err.message : undefined
|
||||
if (type && msg) return `${type}: ${msg}`
|
||||
if (msg) return msg
|
||||
if (type) return type
|
||||
const code = typeof err.code === "string" ? err.code : undefined
|
||||
if (code) return code
|
||||
}
|
||||
|
||||
const msg = typeof json.message === "string" ? json.message : undefined
|
||||
if (msg) return msg
|
||||
|
||||
const reason = typeof json.error === "string" ? json.error : undefined
|
||||
if (reason) return reason
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
if (Array.isArray(value)) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
const hidden = new Set(["todowrite"])
|
||||
|
||||
function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||
if (part.type === "tool") {
|
||||
if (hidden.has(part.tool)) return
|
||||
if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
|
||||
return "visible" as const
|
||||
}
|
||||
if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
|
||||
if (part.type === "reasoning") {
|
||||
if (showReasoningSummaries && part.text?.trim()) return "visible" as const
|
||||
return
|
||||
}
|
||||
if (PART_MAPPING[part.type]) return "visible" as const
|
||||
return
|
||||
}
|
||||
|
||||
function clean(value: string) {
|
||||
return value
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_~]+/g, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function heading(text: string) {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
if (html?.[1]) {
|
||||
const value = clean(html[1].replace(/<[^>]+>/g, " "))
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
|
||||
if (atx?.[1]) {
|
||||
const value = clean(atx[1])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
|
||||
if (setext?.[1]) {
|
||||
const value = clean(setext[1])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
|
||||
if (strong?.[1]) {
|
||||
const value = clean(strong[1])
|
||||
if (value) return value
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
messageID: string
|
||||
messages?: MessageType[]
|
||||
actions?: UserActions
|
||||
showReasoningSummaries?: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
active?: boolean
|
||||
status?: SessionStatus
|
||||
onUserInteracted?: () => void
|
||||
classes?: {
|
||||
root?: string
|
||||
content?: string
|
||||
container?: string
|
||||
}
|
||||
}>,
|
||||
) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyDiffs: SummaryDiff[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
|
||||
|
||||
const messageIndex = createMemo(() => {
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||
|
||||
const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID)
|
||||
if (index < 0) return -1
|
||||
|
||||
const msg = messages[index]
|
||||
if (!msg || msg.role !== "user") return -1
|
||||
|
||||
return index
|
||||
})
|
||||
|
||||
const message = createMemo(() => {
|
||||
const index = messageIndex()
|
||||
if (index < 0) return undefined
|
||||
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
const msg = messages[index]
|
||||
if (!msg || msg.role !== "user") return undefined
|
||||
|
||||
return msg
|
||||
})
|
||||
|
||||
const pending = createMemo(() => {
|
||||
if (typeof props.active === "boolean") return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
return messages.findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
})
|
||||
|
||||
const pendingUser = createMemo(() => {
|
||||
const item = pending()
|
||||
if (!item?.parentID) return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
const result = Binary.search(messages, item.parentID, (m) => m.id)
|
||||
const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
|
||||
if (!msg || msg.role !== "user") return
|
||||
return msg
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (typeof props.active === "boolean") return props.active
|
||||
const msg = message()
|
||||
const parent = pendingUser()
|
||||
if (!msg || !parent) return false
|
||||
return parent.id === msg.id
|
||||
})
|
||||
|
||||
const parts = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return emptyParts
|
||||
return list(data.store.part?.[msg.id], emptyParts)
|
||||
})
|
||||
|
||||
const compaction = createMemo(() => parts().find((part) => part.type === "compaction"))
|
||||
|
||||
const diffs = createMemo(() => {
|
||||
const files = message()?.summary?.diffs
|
||||
if (!files?.length) return emptyDiffs
|
||||
|
||||
const seen = new Set<string>()
|
||||
return files
|
||||
.reduceRight<SummaryDiff[]>((result, diff) => {
|
||||
if (!summaryDiff(diff)) return result
|
||||
if (seen.has(diff.file)) return result
|
||||
seen.add(diff.file)
|
||||
result.push(diff)
|
||||
return result
|
||||
}, [])
|
||||
.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(
|
||||
() => {
|
||||
const msg = message()
|
||||
if (!msg) return emptyAssistant
|
||||
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
if (messageIndex() < 0) return emptyAssistant
|
||||
|
||||
const result: AssistantMessage[] = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (!item) continue
|
||||
if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
|
||||
}
|
||||
return result
|
||||
},
|
||||
emptyAssistant,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
|
||||
const divider = createMemo(() => {
|
||||
if (compaction()) return i18n.t("ui.messagePart.compaction")
|
||||
if (interrupted()) return i18n.t("ui.message.interrupted")
|
||||
return ""
|
||||
})
|
||||
const error = createMemo(
|
||||
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
|
||||
)
|
||||
const showAssistantCopyPartID = createMemo(() => {
|
||||
const messages = assistantMessages()
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (!message) continue
|
||||
|
||||
const parts = list(data.store.part?.[message.id], emptyParts)
|
||||
for (let j = parts.length - 1; j >= 0; j--) {
|
||||
const part = parts[j]
|
||||
if (!part || part.type !== "text" || !part.text?.trim()) continue
|
||||
return part.id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const errorText = createMemo(() => {
|
||||
const msg = error()?.data?.message
|
||||
if (typeof msg === "string") return unwrap(msg)
|
||||
if (msg === undefined || msg === null) return ""
|
||||
// oxlint-disable-next-line no-base-to-string -- msg is unknown from error data, coercion is intentional
|
||||
return unwrap(String(msg))
|
||||
})
|
||||
|
||||
const status = createMemo(() => {
|
||||
if (props.status !== undefined) return props.status
|
||||
if (typeof props.active === "boolean" && !props.active) return idle
|
||||
return data.store.session_status[props.sessionID] ?? idle
|
||||
})
|
||||
const working = createMemo(() => status().type !== "idle" && active())
|
||||
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
||||
|
||||
const assistantCopyPartID = createMemo(() => {
|
||||
if (working()) return null
|
||||
return showAssistantCopyPartID() ?? null
|
||||
})
|
||||
const turnDurationMs = createMemo(() => {
|
||||
const start = message()?.time.created
|
||||
if (typeof start !== "number") return undefined
|
||||
|
||||
const end = assistantMessages().reduce<number | undefined>((max, item) => {
|
||||
const completed = item.time.completed
|
||||
if (typeof completed !== "number") return max
|
||||
if (max === undefined) return completed
|
||||
return Math.max(max, completed)
|
||||
}, undefined)
|
||||
|
||||
if (typeof end !== "number") return undefined
|
||||
if (end < start) return undefined
|
||||
return end - start
|
||||
})
|
||||
const assistantDerived = createMemo(() => {
|
||||
let visible = 0
|
||||
let reason: string | undefined
|
||||
const show = showReasoningSummaries()
|
||||
for (const message of assistantMessages()) {
|
||||
for (const part of list(data.store.part?.[message.id], emptyParts)) {
|
||||
if (partState(part, show) === "visible") {
|
||||
visible++
|
||||
}
|
||||
if (part.type === "reasoning" && part.text) {
|
||||
const h = heading(part.text)
|
||||
if (h) reason = h
|
||||
}
|
||||
}
|
||||
}
|
||||
return { visible, reason }
|
||||
})
|
||||
const assistantVisible = createMemo(() => assistantDerived().visible)
|
||||
const reasoningHeading = createMemo(() => assistantDerived().reason)
|
||||
const showThinking = createMemo(() => {
|
||||
if (!working() || !!error()) return false
|
||||
if (status().type === "retry") return false
|
||||
if (showReasoningSummaries()) return assistantVisible() === 0
|
||||
return true
|
||||
})
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working,
|
||||
onUserInteracted: props.onUserInteracted,
|
||||
overflowAnchor: "dynamic",
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div
|
||||
ref={autoScroll.scrollRef}
|
||||
onScroll={autoScroll.handleScroll}
|
||||
data-slot="session-turn-content"
|
||||
class={props.classes?.content}
|
||||
>
|
||||
<div onClick={autoScroll.handleInteraction}>
|
||||
<Show when={message()}>
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={message()!.id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={message()!} parts={parts()} actions={props.actions} />
|
||||
</div>
|
||||
<Show when={divider()}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider label={divider()} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
turnDurationMs={turnDurationMs()}
|
||||
working={working()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries()}>
|
||||
<TextReveal
|
||||
text={reasoningHeading()}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={700}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={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" 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()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import { animate } from "motion"
|
||||
|
||||
export default {
|
||||
title: "UI/Shell Submessage Motion",
|
||||
id: "components-shell-submessage-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Interactive playground for animating the Shell tool subtitle ("submessage") in the timeline trigger row.
|
||||
|
||||
### Production component path
|
||||
- Trigger layout: \`packages/ui/src/components/basic-tool.tsx\`
|
||||
- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`)
|
||||
|
||||
### What this playground tunes
|
||||
- Width reveal (spring-driven pixel width via \`useSpring\`)
|
||||
- Opacity fade
|
||||
- Blur settle`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const sliderLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
"min-width": "84px",
|
||||
"flex-shrink": "0",
|
||||
"text-align": "right",
|
||||
}
|
||||
|
||||
const sliderValue = {
|
||||
"font-family": "monospace",
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #aaa)",
|
||||
"min-width": "76px",
|
||||
}
|
||||
|
||||
const shellCss = `
|
||||
[data-component="shell-submessage-scene"] [data-component="tool-trigger"] [data-slot="basic-tool-tool-info-main"] {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
filter: blur(var(--shell-sub-blur, 2px));
|
||||
transition-property: opacity, filter;
|
||||
transition-duration: var(--shell-sub-fade-ms, 320ms);
|
||||
transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
}
|
||||
`
|
||||
|
||||
const ease = {
|
||||
smooth: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
snappy: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
standard: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||
linear: "linear",
|
||||
}
|
||||
|
||||
function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!widthRef) return
|
||||
if (props.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
ref?.setAttribute("data-visible", "")
|
||||
animate(
|
||||
widthRef!,
|
||||
{ width: "auto" },
|
||||
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
|
||||
)
|
||||
})
|
||||
} else {
|
||||
ref?.removeAttribute("data-visible")
|
||||
animate(
|
||||
widthRef,
|
||||
{ width: "0px" },
|
||||
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<span ref={ref} data-component="shell-submessage">
|
||||
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: "0px" }}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<span data-slot="shell-submessage-value">{props.text || "\u00A0"}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [state, setState] = createStore({
|
||||
text: "Prints five topic blocks between timed commands",
|
||||
show: true,
|
||||
visualDuration: 0.35,
|
||||
bounce: 0,
|
||||
fadeMs: 320,
|
||||
blur: 2,
|
||||
fadeEase: "snappy",
|
||||
auto: false,
|
||||
})
|
||||
const text = () => state.text
|
||||
const show = () => state.show
|
||||
const visualDuration = () => state.visualDuration
|
||||
const bounce = () => state.bounce
|
||||
const fadeMs = () => state.fadeMs
|
||||
const blur = () => state.blur
|
||||
const fadeEase = () => state.fadeEase
|
||||
const auto = () => state.auto
|
||||
let replayTimer
|
||||
let autoTimer
|
||||
|
||||
const replay = () => {
|
||||
setState("show", false)
|
||||
if (replayTimer) clearTimeout(replayTimer)
|
||||
replayTimer = setTimeout(() => {
|
||||
setState("show", true)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const stopAuto = () => {
|
||||
if (autoTimer) clearInterval(autoTimer)
|
||||
autoTimer = undefined
|
||||
setState("auto", false)
|
||||
}
|
||||
|
||||
const toggleAuto = () => {
|
||||
if (auto()) {
|
||||
stopAuto()
|
||||
return
|
||||
}
|
||||
setState("auto", true)
|
||||
autoTimer = setInterval(replay, 2200)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (replayTimer) clearTimeout(replayTimer)
|
||||
if (autoTimer) clearInterval(autoTimer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="shell-submessage-scene"
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "20px",
|
||||
padding: "20px",
|
||||
"max-width": "860px",
|
||||
"--shell-sub-fade-ms": `${fadeMs()}ms`,
|
||||
"--shell-sub-blur": `${blur()}px`,
|
||||
"--shell-sub-fade-ease": ease[fadeEase()],
|
||||
}}
|
||||
>
|
||||
<style>{shellCss}</style>
|
||||
|
||||
<BasicTool
|
||||
icon="console"
|
||||
defaultOpen
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">Shell</span>
|
||||
<SpringSubmessage text={text()} visible={show()} visualDuration={visualDuration()} bounce={bounce()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
"border-radius": "8px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-secondary, #161616)",
|
||||
padding: "14px 16px",
|
||||
"font-family": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
"font-size": "18px",
|
||||
color: "var(--color-text, #eee)",
|
||||
"white-space": "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{"$ cat <<'TOPIC1'"}
|
||||
</div>
|
||||
</BasicTool>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={replay} style={btn()}>
|
||||
Replay entry
|
||||
</button>
|
||||
<button onClick={() => setState("show", (value) => !value)} style={btn(show())}>
|
||||
{show() ? "Hide subtitle" : "Show subtitle"}
|
||||
</button>
|
||||
<button onClick={toggleAuto} style={btn(auto())}>
|
||||
{auto() ? "Stop auto replay" : "Auto replay"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
"border-top": "1px solid var(--color-divider, #333)",
|
||||
"padding-top": "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>subtitle</span>
|
||||
<input
|
||||
value={text()}
|
||||
onInput={(e) => setState("text", e.currentTarget.value)}
|
||||
style={{
|
||||
width: "420px",
|
||||
"max-width": "100%",
|
||||
padding: "6px 8px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>visualDuration</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
value={visualDuration()}
|
||||
onInput={(e) => setState("visualDuration", Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{visualDuration().toFixed(2)}s</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
value={bounce()}
|
||||
onInput={(e) => setState("bounce", Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{bounce().toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>fade ease</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setState("fadeEase", (value) =>
|
||||
value === "snappy"
|
||||
? "smooth"
|
||||
: value === "smooth"
|
||||
? "standard"
|
||||
: value === "standard"
|
||||
? "linear"
|
||||
: "snappy",
|
||||
)
|
||||
}
|
||||
style={btn()}
|
||||
>
|
||||
{fadeEase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>fade</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1400}
|
||||
step={10}
|
||||
value={fadeMs()}
|
||||
onInput={(e) => setState("fadeMs", Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{fadeMs()}ms</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>blur</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={14}
|
||||
step={0.5}
|
||||
value={blur()}
|
||||
onInput={(e) => setState("blur", Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{blur()}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,605 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
|
||||
export default {
|
||||
title: "UI/Todo Panel Motion",
|
||||
id: "components-todo-panel-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
This playground renders the real session composer region from app code.
|
||||
|
||||
### Source path
|
||||
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
|
||||
|
||||
### Includes
|
||||
- \`SessionTodoDock\` (real)
|
||||
- \`PromptInput\` (real)
|
||||
|
||||
No visual reimplementation layer is used for the dock/input stack.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pool = [
|
||||
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
|
||||
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
|
||||
"Run typechecks/tests and report what changed",
|
||||
"Verify reduced-motion behavior in timeline",
|
||||
"Review diff for animation edge cases",
|
||||
"Document rollout notes in PR description",
|
||||
"Check keyboard and screen reader semantics",
|
||||
"Add storybook controls for iteration speed",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const css = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-component="todo-preview"] {
|
||||
height: 560px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-component="todo-session-root"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
[data-component="todo-session-frame"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component="todo-session-panel"] {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-content"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-scroll"] {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-spacer"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"] {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-weak);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"][data-strong="true"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
`
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useServerSync()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
step: 1,
|
||||
dockOpenDuration: 0.3,
|
||||
dockOpenBounce: 0,
|
||||
dockCloseDuration: 0.3,
|
||||
dockCloseBounce: 0,
|
||||
drawerExpandDuration: 0.3,
|
||||
drawerExpandBounce: 0,
|
||||
drawerCollapseDuration: 0.3,
|
||||
drawerCollapseBounce: 0,
|
||||
subtitleDuration: 600,
|
||||
subtitleAuto: true,
|
||||
subtitleTravel: 25,
|
||||
subtitleEdge: 17,
|
||||
countDuration: 600,
|
||||
countMask: 18,
|
||||
countMaskHeight: 0,
|
||||
countWidthDuration: 560,
|
||||
})
|
||||
const open = () => cfg.open
|
||||
const step = () => cfg.step
|
||||
const dockOpenDuration = () => cfg.dockOpenDuration
|
||||
const dockOpenBounce = () => cfg.dockOpenBounce
|
||||
const dockCloseDuration = () => cfg.dockCloseDuration
|
||||
const dockCloseBounce = () => cfg.dockCloseBounce
|
||||
const drawerExpandDuration = () => cfg.drawerExpandDuration
|
||||
const drawerExpandBounce = () => cfg.drawerExpandBounce
|
||||
const drawerCollapseDuration = () => cfg.drawerCollapseDuration
|
||||
const drawerCollapseBounce = () => cfg.drawerCollapseBounce
|
||||
const subtitleDuration = () => cfg.subtitleDuration
|
||||
const subtitleAuto = () => cfg.subtitleAuto
|
||||
const subtitleTravel = () => cfg.subtitleTravel
|
||||
const subtitleEdge = () => cfg.subtitleEdge
|
||||
const countDuration = () => cfg.countDuration
|
||||
const countMask = () => cfg.countMask
|
||||
const countMaskHeight = () => cfg.countMaskHeight
|
||||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let composerRef
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
const done = Math.max(0, Math.min(3, step()))
|
||||
return pool.slice(0, 3).map((content, i) => ({
|
||||
id: `todo-${i + 1}`,
|
||||
content,
|
||||
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
|
||||
}))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
global.todo.set("story-session", todos())
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
if (!scrollRef) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () =>
|
||||
!!composerRef?.querySelector('[data-action="session-todo-toggle-button"][data-collapsed="true"]')
|
||||
|
||||
const setCollapsed = (value: boolean) => {
|
||||
const button = composerRef?.querySelector('[data-action="session-todo-toggle-button"]')
|
||||
if (!(button instanceof HTMLButtonElement)) return
|
||||
if (collapsed() === value) return
|
||||
button.click()
|
||||
}
|
||||
|
||||
const openDock = () => {
|
||||
clear()
|
||||
setCfg("open", true)
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
frame = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const closeDock = () => {
|
||||
clear()
|
||||
setCfg("open", false)
|
||||
}
|
||||
|
||||
const dockOpen = () => open()
|
||||
|
||||
const toggleDock = () => {
|
||||
if (dockOpen()) {
|
||||
closeDock()
|
||||
return
|
||||
}
|
||||
openDock()
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
if (!dockOpen()) {
|
||||
openDock()
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
setCollapsed(true)
|
||||
frame = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setCollapsed(!collapsed())
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
setCfg("step", (value) => (value + 1) % 4)
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
return (
|
||||
<div data-component="todo-stage">
|
||||
<style>{css}</style>
|
||||
|
||||
<div data-component="todo-preview">
|
||||
<div data-component="todo-session-root">
|
||||
<div data-component="todo-session-frame">
|
||||
<div data-component="todo-session-panel">
|
||||
<div data-slot="todo-preview-content">
|
||||
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
|
||||
<div data-slot="todo-preview-spacer" />
|
||||
<div data-slot="todo-preview-msg" data-strong="true">
|
||||
Thinking Checking type safety
|
||||
</div>
|
||||
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={composerRef}>
|
||||
<SessionComposerRegion
|
||||
state={state}
|
||||
centered={false}
|
||||
inputRef={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
onSubmit={() => {}}
|
||||
onResponseSubmit={pin}
|
||||
setPromptDockRef={() => {}}
|
||||
dockOpenVisualDuration={dockOpenDuration()}
|
||||
dockOpenBounce={dockOpenBounce()}
|
||||
dockCloseVisualDuration={dockCloseDuration()}
|
||||
dockCloseBounce={dockCloseBounce()}
|
||||
drawerExpandVisualDuration={drawerExpandDuration()}
|
||||
drawerExpandBounce={drawerExpandBounce()}
|
||||
drawerCollapseVisualDuration={drawerCollapseDuration()}
|
||||
drawerCollapseBounce={drawerCollapseBounce()}
|
||||
subtitleDuration={subtitleDuration()}
|
||||
subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
|
||||
subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
|
||||
countDuration={countDuration()}
|
||||
countMask={countMask()}
|
||||
countMaskHeight={countMaskHeight()}
|
||||
countWidthDuration={countWidthDuration()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={toggleDock} style={btn(dockOpen())}>
|
||||
{dockOpen() ? "Animate close" : "Animate open"}
|
||||
</button>
|
||||
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
|
||||
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
|
||||
</button>
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
{[0, 1, 2, 3].map((value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenDuration()}
|
||||
onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockOpenDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenBounce()}
|
||||
onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockOpenBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Dock close
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseDuration()}
|
||||
onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockCloseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseBounce()}
|
||||
onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockCloseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer expand
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandDuration()}
|
||||
onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerExpandDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandBounce()}
|
||||
onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerExpandBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer collapse
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseDuration()}
|
||||
onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerCollapseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseBounce()}
|
||||
onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerCollapseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Subtitle odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={subtitleDuration()}
|
||||
onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(subtitleDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
auto fit
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleAuto()}
|
||||
onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{subtitleAuto() ? "on" : "off"}
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
travel
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleTravel()}
|
||||
onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
edge
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleEdge()}
|
||||
onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Count odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={countDuration()}
|
||||
onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="40"
|
||||
step="1"
|
||||
value={countMask()}
|
||||
onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask height
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="14"
|
||||
step="1"
|
||||
value={countMaskHeight()}
|
||||
onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
width spring
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1200"
|
||||
step="10"
|
||||
value={countWidthDuration()}
|
||||
onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countWidthDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
[data-component="tool-count-label"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
gap: 0;
|
||||
|
||||
[data-slot="tool-count-label-before"] {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-word"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: pre;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-stem"] {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-suffix"] {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 0fr;
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
|
||||
overflow: hidden;
|
||||
transform: translateX(-0.04em);
|
||||
transition-property: grid-template-columns, opacity, filter, transform;
|
||||
transition-duration: 250ms, 250ms, 250ms, 250ms;
|
||||
transition-timing-function:
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-suffix"][data-active="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
[data-slot="tool-count-label-suffix-inner"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { createMemo } from "solid-js"
|
||||
import { AnimatedNumber } from "./animated-number"
|
||||
|
||||
function split(text: string) {
|
||||
const match = /{{\s*count\s*}}/.exec(text)
|
||||
if (!match) return { before: "", after: text }
|
||||
if (match.index === undefined) return { before: "", after: text }
|
||||
return {
|
||||
before: text.slice(0, match.index),
|
||||
after: text.slice(match.index + match[0].length),
|
||||
}
|
||||
}
|
||||
|
||||
function common(one: string, other: string) {
|
||||
const a = Array.from(one)
|
||||
const b = Array.from(other)
|
||||
let i = 0
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
||||
return {
|
||||
stem: a.slice(0, i).join(""),
|
||||
one: a.slice(i).join(""),
|
||||
other: b.slice(i).join(""),
|
||||
}
|
||||
}
|
||||
|
||||
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
|
||||
const one = createMemo(() => split(props.one))
|
||||
const other = createMemo(() => split(props.other))
|
||||
const singular = createMemo(() => Math.round(props.count) === 1)
|
||||
const active = createMemo(() => (singular() ? one() : other()))
|
||||
const suffix = createMemo(() => common(one().after, other().after))
|
||||
const splitSuffix = createMemo(
|
||||
() =>
|
||||
one().before === other().before &&
|
||||
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
|
||||
)
|
||||
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
|
||||
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
|
||||
const tail = createMemo(() => {
|
||||
if (!splitSuffix()) return ""
|
||||
if (singular()) return suffix().one
|
||||
return suffix().other
|
||||
})
|
||||
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
|
||||
|
||||
return (
|
||||
<span data-component="tool-count-label" class={props.class}>
|
||||
<span data-slot="tool-count-label-before">{before()}</span>
|
||||
<AnimatedNumber value={props.count} />
|
||||
<span data-slot="tool-count-label-word">
|
||||
<span data-slot="tool-count-label-stem">{stem()}</span>
|
||||
<span data-slot="tool-count-label-suffix" data-active={showTail() ? "true" : "false"}>
|
||||
<span data-slot="tool-count-label-suffix-inner">{tail()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
[data-component="tool-count-summary"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
|
||||
[data-slot="tool-count-summary-empty"] {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: baseline;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
overflow: hidden;
|
||||
transform-origin: left center;
|
||||
transition-property: grid-template-columns, opacity, filter, transform;
|
||||
transition-duration:
|
||||
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
|
||||
var(--tool-motion-spring-ms, 480ms);
|
||||
transition-timing-function:
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-empty"][data-active="false"] {
|
||||
grid-template-columns: 0fr;
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.72));
|
||||
transform: translateY(0.05em) scale(0.985);
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-item"] {
|
||||
display: inline-grid;
|
||||
grid-template-columns: 0fr;
|
||||
align-items: baseline;
|
||||
opacity: 0;
|
||||
filter: blur(var(--tool-motion-blur, 2px));
|
||||
transform: translateY(0.06em) scale(0.985);
|
||||
overflow: hidden;
|
||||
transform-origin: left center;
|
||||
transition-property: grid-template-columns, opacity, filter, transform;
|
||||
transition-duration:
|
||||
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
|
||||
var(--tool-motion-spring-ms, 480ms);
|
||||
transition-timing-function:
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
|
||||
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-item"][data-active="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-empty-inner"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-item-inner"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-prefix"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
max-width: 0;
|
||||
margin-right: 0;
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
|
||||
overflow: hidden;
|
||||
transform: translateX(-0.08em);
|
||||
transition-property: opacity, filter, transform;
|
||||
transition-duration:
|
||||
calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
|
||||
calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
|
||||
transition-timing-function: ease-out, ease-out, ease-out;
|
||||
}
|
||||
|
||||
[data-slot="tool-count-summary-prefix"][data-active="true"] {
|
||||
max-width: 1ch;
|
||||
margin-right: 0.45ch;
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-count-summary"] [data-slot="tool-count-summary-empty"],
|
||||
[data-component="tool-count-summary"] [data-slot="tool-count-summary-item"],
|
||||
[data-component="tool-count-summary"] [data-slot="tool-count-summary-prefix"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { AnimatedCountList, type CountItem } from "./tool-count-summary"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
|
||||
export default {
|
||||
title: "UI/AnimatedCountList",
|
||||
id: "components-animated-count-list",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Animated count list that smoothly transitions items in/out as counts change.
|
||||
|
||||
Uses \`grid-template-columns: 0fr → 1fr\` for width animations and the odometer
|
||||
digit roller for count transitions. Shown here with \`ToolStatusTitle\` exactly
|
||||
as it appears in the context tool group on the session page.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
active: "Exploring",
|
||||
done: "Explored",
|
||||
read: { one: "{{count}} read", other: "{{count}} reads" },
|
||||
search: { one: "{{count}} search", other: "{{count}} searches" },
|
||||
list: { one: "{{count}} list", other: "{{count}} lists" },
|
||||
} as const
|
||||
|
||||
function rand(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-danger-fill, #c33)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const smallBtn = (active?: boolean) =>
|
||||
({
|
||||
padding: "4px 12px",
|
||||
"border-radius": "6px",
|
||||
border: active ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
|
||||
background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "12px",
|
||||
}) as const
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [state, setState] = createStore({
|
||||
reads: 0,
|
||||
searches: 0,
|
||||
lists: 0,
|
||||
active: false,
|
||||
reducedMotion: false,
|
||||
})
|
||||
const reads = () => state.reads
|
||||
const searches = () => state.searches
|
||||
const lists = () => state.lists
|
||||
const active = () => state.active
|
||||
const reducedMotion = () => state.reducedMotion
|
||||
|
||||
let timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const clearAll = () => {
|
||||
for (const t of timeouts) clearTimeout(t)
|
||||
timeouts = []
|
||||
}
|
||||
|
||||
onCleanup(clearAll)
|
||||
|
||||
const startSim = () => {
|
||||
clearAll()
|
||||
setState("reads", 0)
|
||||
setState("searches", 0)
|
||||
setState("lists", 0)
|
||||
setState("active", true)
|
||||
const steps = rand(3, 10)
|
||||
let elapsed = 0
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const delay = rand(300, 800)
|
||||
elapsed += delay
|
||||
const t = setTimeout(() => {
|
||||
const pick = rand(0, 2)
|
||||
if (pick === 0) setState("reads", (value) => value + 1)
|
||||
else if (pick === 1) setState("searches", (value) => value + 1)
|
||||
else setState("lists", (value) => value + 1)
|
||||
}, elapsed)
|
||||
timeouts.push(t)
|
||||
}
|
||||
|
||||
const end = setTimeout(() => setState("active", false), elapsed + 100)
|
||||
timeouts.push(end)
|
||||
}
|
||||
|
||||
const stopSim = () => {
|
||||
clearAll()
|
||||
setState("active", false)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
stopSim()
|
||||
setState("reads", 0)
|
||||
setState("searches", 0)
|
||||
setState("lists", 0)
|
||||
}
|
||||
|
||||
const items = (): CountItem[] => [
|
||||
{ key: "read", count: reads(), one: TEXT.read.one, other: TEXT.read.other },
|
||||
{ key: "search", count: searches(), one: TEXT.search.one, other: TEXT.search.other },
|
||||
{ key: "list", count: lists(), one: TEXT.list.one, other: TEXT.list.other },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "520px" }}>
|
||||
{reducedMotion() && (
|
||||
<style>
|
||||
{`[data-reduced-motion="true"] *,
|
||||
[data-reduced-motion="true"] *::before,
|
||||
[data-reduced-motion="true"] *::after {
|
||||
transition-duration: 0ms !important;
|
||||
}`}
|
||||
</style>
|
||||
)}
|
||||
|
||||
{/* Matches context-tool-group-trigger layout from message-part.tsx */}
|
||||
<span
|
||||
data-reduced-motion={reducedMotion()}
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
color: "var(--text-strong, #eee)",
|
||||
"min-width": "0",
|
||||
}}
|
||||
>
|
||||
<span style={{ "flex-shrink": "0" }}>
|
||||
<ToolStatusTitle active={active()} activeText={TEXT.active} doneText={TEXT.done} split={false} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
"min-width": "0",
|
||||
overflow: "hidden",
|
||||
"text-overflow": "ellipsis",
|
||||
"white-space": "nowrap",
|
||||
"font-weight": "400",
|
||||
color: "var(--text-base, #ccc)",
|
||||
}}
|
||||
>
|
||||
<AnimatedCountList items={items()} fallback="" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={() => (active() ? stopSim() : startSim())} style={btn(active())}>
|
||||
{active() ? "Stop" : "Simulate"}
|
||||
</button>
|
||||
<button onClick={reset} style={btn()}>
|
||||
Reset
|
||||
</button>
|
||||
<button onClick={() => setState("reducedMotion", (value) => !value)} style={smallBtn(reducedMotion())}>
|
||||
{reducedMotion() ? "Motion: reduced" : "Motion: normal"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={() => setState("reads", (value) => value + 1)} style={smallBtn()}>
|
||||
+ read
|
||||
</button>
|
||||
<button onClick={() => setState("searches", (value) => value + 1)} style={smallBtn()}>
|
||||
+ search
|
||||
</button>
|
||||
<button onClick={() => setState("lists", (value) => value + 1)} style={smallBtn()}>
|
||||
+ list
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #888)",
|
||||
"font-family": "monospace",
|
||||
}}
|
||||
>
|
||||
motion: {reducedMotion() ? "reduced" : "normal"} · active: {active() ? "true" : "false"} · reads: {reads()} ·
|
||||
searches: {searches()} · lists: {lists()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty = {
|
||||
render: () => (
|
||||
<span style={{ display: "flex", "align-items": "center", gap: "8px", "font-size": "14px", "font-weight": "500" }}>
|
||||
<ToolStatusTitle active activeText="Exploring" doneText="Explored" split={false} />
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{ key: "read", count: 0, one: "{{count}} read", other: "{{count}} reads" },
|
||||
{ key: "search", count: 0, one: "{{count}} search", other: "{{count}} searches" },
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
|
||||
export const Done = {
|
||||
render: () => (
|
||||
<span style={{ display: "flex", "align-items": "center", gap: "8px", "font-size": "14px", "font-weight": "500" }}>
|
||||
<ToolStatusTitle active={false} activeText="Exploring" doneText="Explored" split={false} />
|
||||
<span style={{ "font-weight": "400", color: "var(--text-base, #ccc)" }}>
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{ key: "read", count: 5, one: "{{count}} read", other: "{{count}} reads" },
|
||||
{ key: "search", count: 3, one: "{{count}} search", other: "{{count}} searches" },
|
||||
{ key: "list", count: 1, one: "{{count}} list", other: "{{count}} lists" },
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Index, createMemo } from "solid-js"
|
||||
import { AnimatedCountLabel } from "./tool-count-label"
|
||||
|
||||
export type CountItem = {
|
||||
key: string
|
||||
count: number
|
||||
one: string
|
||||
other: string
|
||||
}
|
||||
|
||||
export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) {
|
||||
const visible = createMemo(() => props.items.filter((item) => item.count > 0))
|
||||
const fallback = createMemo(() => props.fallback ?? "")
|
||||
const showEmpty = createMemo(() => visible().length === 0 && fallback().length > 0)
|
||||
|
||||
return (
|
||||
<span data-component="tool-count-summary" class={props.class}>
|
||||
<span data-slot="tool-count-summary-empty" data-active={showEmpty() ? "true" : "false"}>
|
||||
<span data-slot="tool-count-summary-empty-inner">{fallback()}</span>
|
||||
</span>
|
||||
|
||||
<Index each={props.items}>
|
||||
{(item, index) => {
|
||||
const active = createMemo(() => item().count > 0)
|
||||
const hasPrev = createMemo(() => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
if (props.items[i].count > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<span data-slot="tool-count-summary-prefix" data-active={active() && hasPrev() ? "true" : "false"}>
|
||||
,
|
||||
</span>
|
||||
<span data-slot="tool-count-summary-item" data-active={active() ? "true" : "false"}>
|
||||
<span data-slot="tool-count-summary-item-inner">
|
||||
<AnimatedCountLabel
|
||||
one={item().one}
|
||||
other={item().other}
|
||||
count={Math.max(0, Math.round(item().count))}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
[data-component="card"][data-kind="tool-error-card"] {
|
||||
--card-pad-y: 8px;
|
||||
--card-line-pad: 12px;
|
||||
|
||||
> [data-component="collapsible"].tool-collapsible {
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-component="tool-error-card-icon"] [data-component="icon"] {
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-content"] {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
margin-bottom: 8px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
> [data-component="collapsible"].tool-collapsible[data-open="true"] [data-slot="tool-error-card-content"] {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-copy"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&:hover [data-slot="tool-error-card-copy"],
|
||||
&:focus-within [data-slot="tool-error-card-copy"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-content"] :where(*)::selection {
|
||||
background: var(--surface-critical-base);
|
||||
color: var(--text-on-critical-base);
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
|
||||
background: var(--surface-critical-base);
|
||||
color: var(--text-on-critical-base);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { ToolErrorCard } from "./tool-error-card"
|
||||
|
||||
const docs = `### Overview
|
||||
Tool call failure summary styled like a tool trigger.
|
||||
|
||||
### API
|
||||
- Required: \`tool\` (tool id, e.g. apply_patch, bash)
|
||||
- Required: \`error\` (error string)
|
||||
|
||||
### Behavior
|
||||
- Collapsible; click header to expand/collapse.
|
||||
`
|
||||
|
||||
const samples = [
|
||||
{
|
||||
tool: "apply_patch",
|
||||
error:
|
||||
"apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
|
||||
},
|
||||
{
|
||||
tool: "bash",
|
||||
error: "bash Command failed: exit code 1: bun test --watch",
|
||||
},
|
||||
{
|
||||
tool: "read",
|
||||
error:
|
||||
"read File not found: /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/does-not-exist.tsx",
|
||||
},
|
||||
{
|
||||
tool: "glob",
|
||||
error: "glob Pattern error: Invalid glob pattern: **/*[",
|
||||
},
|
||||
{
|
||||
tool: "grep",
|
||||
error: "grep Regex error: Invalid regular expression: (unterminated group",
|
||||
},
|
||||
{
|
||||
tool: "webfetch",
|
||||
error: "webfetch Request failed: 502 Bad Gateway",
|
||||
},
|
||||
{
|
||||
tool: "websearch",
|
||||
error: "websearch Rate limited: Please try again in 30 seconds",
|
||||
},
|
||||
{
|
||||
tool: "question",
|
||||
error: "question Dismissed: user dismissed this question",
|
||||
},
|
||||
]
|
||||
|
||||
export default {
|
||||
title: "UI/ToolErrorCard",
|
||||
id: "components-tool-error-card",
|
||||
component: ToolErrorCard,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
tool: "apply_patch",
|
||||
error: samples[0].error,
|
||||
},
|
||||
argTypes: {
|
||||
tool: {
|
||||
control: "select",
|
||||
options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"],
|
||||
},
|
||||
error: {
|
||||
control: "text",
|
||||
},
|
||||
},
|
||||
render: (props: { tool: string; error: string }) => {
|
||||
return <ToolErrorCard tool={props.tool} error={props.error} />
|
||||
},
|
||||
}
|
||||
|
||||
export const All = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; max-width: 720px;">
|
||||
{samples.map((item) => (
|
||||
<ToolErrorCard tool={item.tool} error={item.error} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import { type ComponentProps, createMemo, Show, splitProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Card, CardDescription } from "./card"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
|
||||
tool: string
|
||||
error: string
|
||||
title?: string
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
subtitle?: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
const i18n = useI18n()
|
||||
const [state, setState] = createStore({
|
||||
open: props.defaultOpen ?? false,
|
||||
copied: false,
|
||||
})
|
||||
const open = () => props.open ?? state.open
|
||||
const copied = () => state.copied
|
||||
const [split, rest] = splitProps(props, [
|
||||
"tool",
|
||||
"error",
|
||||
"title",
|
||||
"defaultOpen",
|
||||
"open",
|
||||
"onOpenChange",
|
||||
"subtitle",
|
||||
"href",
|
||||
])
|
||||
const setOpen = (value: boolean) => {
|
||||
if (props.open === undefined) setState("open", value)
|
||||
props.onOpenChange?.(value)
|
||||
}
|
||||
const name = createMemo(() => {
|
||||
if (split.title) return split.title
|
||||
const map: Record<string, string> = {
|
||||
read: "ui.tool.read",
|
||||
list: "ui.tool.list",
|
||||
glob: "ui.tool.glob",
|
||||
grep: "ui.tool.grep",
|
||||
task: "ui.tool.task",
|
||||
webfetch: "ui.tool.webfetch",
|
||||
websearch: "ui.tool.websearch",
|
||||
bash: "ui.tool.shell",
|
||||
apply_patch: "ui.tool.patch",
|
||||
question: "ui.tool.questions",
|
||||
}
|
||||
const key = map[split.tool]
|
||||
if (!key) return split.tool
|
||||
if (!key.includes(".")) return key
|
||||
return i18n.t(key)
|
||||
})
|
||||
const cleaned = createMemo(() => split.error.replace(/^Error:\s*/, "").trim())
|
||||
const tail = createMemo(() => {
|
||||
const value = cleaned()
|
||||
const prefix = `${split.tool} `
|
||||
if (value.startsWith(prefix)) return value.slice(prefix.length)
|
||||
return value
|
||||
})
|
||||
|
||||
const subtitle = createMemo(() => {
|
||||
if (split.subtitle) return split.subtitle
|
||||
const parts = tail().split(": ")
|
||||
if (parts.length <= 1) return i18n.t("ui.toolErrorCard.failed")
|
||||
const head = (parts[0] ?? "").trim()
|
||||
if (!head) return i18n.t("ui.toolErrorCard.failed")
|
||||
return head[0] ? head[0].toUpperCase() + head.slice(1) : i18n.t("ui.toolErrorCard.failed")
|
||||
})
|
||||
|
||||
const body = createMemo(() => {
|
||||
const parts = tail().split(": ")
|
||||
if (parts.length <= 1) return cleaned()
|
||||
return parts.slice(1).join(": ").trim() || cleaned()
|
||||
})
|
||||
|
||||
const copy = async () => {
|
||||
const text = cleaned()
|
||||
if (!text) return
|
||||
await navigator.clipboard.writeText(text)
|
||||
setState("copied", true)
|
||||
setTimeout(() => setState("copied", false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card {...rest} data-kind="tool-error-card" data-open={open() ? "true" : "false"} variant="error">
|
||||
<Collapsible class="tool-collapsible" data-open={open() ? "true" : "false"} open={open()} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
|
||||
<Icon name="circle-ban-sign" size="small" style={{ "stroke-width": 1.5 }} />
|
||||
</span>
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">{name()}</span>
|
||||
<Show
|
||||
when={split.href && split.subtitle}
|
||||
fallback={<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
>
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="clickable subagent-link"
|
||||
href={split.href!}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{subtitle()}
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div data-slot="tool-error-card-content">
|
||||
<Show when={open()}>
|
||||
<div data-slot="tool-error-card-copy">
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void copy()
|
||||
}}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.toolErrorCard.copyError")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={body()}>{(value) => <CardDescription>{value()}</CardDescription>}</Show>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
[data-component="tool-status-title"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
|
||||
[data-slot="tool-status-suffix"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="tool-status-prefix"] {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-status-swap"],
|
||||
[data-slot="tool-status-tail"] {
|
||||
display: inline-grid;
|
||||
overflow: hidden;
|
||||
justify-items: start;
|
||||
transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="tool-status-active"],
|
||||
[data-slot="tool-status-done"] {
|
||||
grid-area: 1 / 1;
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
text-align: start;
|
||||
transition-property: opacity, filter, transform;
|
||||
transition-duration:
|
||||
var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
|
||||
calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
|
||||
transition-timing-function: ease-out, ease-out, ease-out;
|
||||
}
|
||||
|
||||
&[data-ready="false"] {
|
||||
[data-slot="tool-status-swap"],
|
||||
[data-slot="tool-status-tail"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-slot="tool-status-active"],
|
||||
[data-slot="tool-status-done"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tool-status-active"] {
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45));
|
||||
transform: translateY(0.03em);
|
||||
}
|
||||
|
||||
[data-slot="tool-status-done"] {
|
||||
color: var(--text-strong);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&[data-active="true"] {
|
||||
[data-slot="tool-status-active"] {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-slot="tool-status-done"] {
|
||||
opacity: 0;
|
||||
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.45));
|
||||
transform: translateY(0.03em);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-swap"],
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-tail"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-active"],
|
||||
[data-component="tool-status-title"] [data-slot="tool-status-done"] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
|
||||
function common(active: string, done: string) {
|
||||
const a = Array.from(active)
|
||||
const b = Array.from(done)
|
||||
let i = 0
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++
|
||||
return {
|
||||
prefix: a.slice(0, i).join(""),
|
||||
active: a.slice(i).join(""),
|
||||
done: b.slice(i).join(""),
|
||||
}
|
||||
}
|
||||
|
||||
function contentWidth(el: HTMLSpanElement | undefined) {
|
||||
if (!el) return
|
||||
return `${Math.ceil(el.getBoundingClientRect().width)}px`
|
||||
}
|
||||
|
||||
export function ToolStatusTitle(props: {
|
||||
active: boolean
|
||||
activeText: string
|
||||
doneText: string
|
||||
class?: string
|
||||
split?: boolean
|
||||
}) {
|
||||
const split = createMemo(() => common(props.activeText, props.doneText))
|
||||
const suffix = createMemo(
|
||||
() => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
|
||||
)
|
||||
const prefixLen = createMemo(() => Array.from(split().prefix).length)
|
||||
const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
|
||||
const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
|
||||
|
||||
const [state, setState] = createStore({
|
||||
active: props.active,
|
||||
animating: false,
|
||||
width: undefined as string | undefined,
|
||||
})
|
||||
const width = () => state.width
|
||||
const active = () => state.active
|
||||
const animating = () => state.animating
|
||||
let activeRef: HTMLSpanElement | undefined
|
||||
let doneRef: HTMLSpanElement | undefined
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
let frame: number | undefined
|
||||
let finishTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const finish = () => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
if (finishTimer !== undefined) clearTimeout(finishTimer)
|
||||
frame = undefined
|
||||
finishTimer = undefined
|
||||
setState("animating", false)
|
||||
setState("width", undefined)
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
const first = contentWidth(widthRef)
|
||||
finish()
|
||||
setState("animating", true)
|
||||
setState("active", props.active)
|
||||
const last = contentWidth(props.active ? activeRef : doneRef)
|
||||
if (!first || !last) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setState("width", first)
|
||||
if (first === last) {
|
||||
finishTimer = setTimeout(finish, 600)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
setState("width", last)
|
||||
finishTimer = setTimeout(finish, 600)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true }))
|
||||
|
||||
onCleanup(() => {
|
||||
finish()
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
data-component="tool-status-title"
|
||||
data-active={active() ? "true" : "false"}
|
||||
data-ready={animating() ? "true" : "false"}
|
||||
data-mode={suffix() ? "suffix" : "swap"}
|
||||
class={props.class}
|
||||
aria-label={active() ? props.activeText : props.doneText}
|
||||
>
|
||||
<Show
|
||||
when={suffix()}
|
||||
fallback={
|
||||
<span data-slot="tool-status-swap" ref={widthRef} style={{ width: width() }}>
|
||||
<Show when={animating() || active()}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={active()} offset={0} />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={animating() || !active()}>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={0} />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span data-slot="tool-status-suffix">
|
||||
<span data-slot="tool-status-prefix">
|
||||
<TextShimmer text={split().prefix} active={active()} offset={0} />
|
||||
</span>
|
||||
<span data-slot="tool-status-tail" ref={widthRef} style={{ width: width() }}>
|
||||
<Show when={animating() || active()}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={active()} offset={prefixLen()} />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={animating() || !active()}>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={prefixLen()} />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue