refactor(ui): isolate session components (#33670)
This commit is contained in:
parent
1ef0fd5d01
commit
04c1730c90
126 changed files with 294 additions and 142 deletions
|
|
@ -6,13 +6,12 @@
|
|||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./*": "./src/components/*.tsx",
|
||||
"./session-diff": "./src/components/session-diff.ts",
|
||||
"./i18n/*": "./src/i18n/*.ts",
|
||||
"./pierre": "./src/pierre/index.ts",
|
||||
"./pierre/*": "./src/pierre/*.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./context": "./src/context/index.ts",
|
||||
"./context/*": "./src/context/*.tsx",
|
||||
"./storybook/scaffold": "./src/storybook/scaffold.tsx",
|
||||
"./storybook/fixtures": "./src/storybook/fixtures.ts",
|
||||
"./styles": "./src/styles/index.css",
|
||||
"./styles/tailwind": "./src/styles/tailwind/index.css",
|
||||
"./theme": "./src/theme/index.ts",
|
||||
|
|
@ -49,8 +48,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
|
||||
export type NormalizedProviderListResponse = {
|
||||
all: Map<string, Provider>
|
||||
default: {
|
||||
[key: string]: string
|
||||
}
|
||||
connected: Array<string>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
agent?: {
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
provider?: NormalizedProviderListResponse
|
||||
session: Session[]
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_diff: {
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
session_diff_preload?: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
part_text_accum_delta?: {
|
||||
[partID: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type NavigateToSessionFn = (sessionID: string) => void
|
||||
|
||||
export type SessionHrefFn = (sessionID: string) => string
|
||||
|
||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
name: "Data",
|
||||
init: (props: {
|
||||
data: Data
|
||||
directory: string
|
||||
onNavigateToSession?: NavigateToSessionFn
|
||||
onSessionHref?: SessionHrefFn
|
||||
}) => {
|
||||
return {
|
||||
get store() {
|
||||
return props.data
|
||||
},
|
||||
get directory() {
|
||||
return props.directory
|
||||
},
|
||||
navigateToSession: props.onNavigateToSession,
|
||||
sessionHref: props.onSessionHref,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
export * from "./helper"
|
||||
export * from "./data"
|
||||
export * from "./file"
|
||||
export * from "./dialog"
|
||||
export * from "./i18n"
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
export type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
onSelect: (line: HoverCommentLine) => void
|
||||
}) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--icon-interactive-base)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "30px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
|
||||
const sync = () => {
|
||||
const next = props.getHoveredLine()
|
||||
if (!next) return
|
||||
line = next
|
||||
}
|
||||
|
||||
const loop = () => {
|
||||
if (!button.isConnected) return
|
||||
sync()
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const next = props.getHoveredLine() ?? line
|
||||
if (!next) return
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
button.addEventListener("mouseenter", sync)
|
||||
button.addEventListener("mousemove", sync)
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
})
|
||||
|
||||
return button
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { diffLineIndex, diffRowIndex } from "./diff-selection"
|
||||
|
||||
export type CommentSide = "additions" | "deletions"
|
||||
|
||||
function annotationIndex(node: HTMLElement) {
|
||||
const value = node.dataset.lineAnnotation?.split(",")[1]
|
||||
if (!value) return
|
||||
const line = parseInt(value, 10)
|
||||
if (Number.isNaN(line)) return
|
||||
return line
|
||||
}
|
||||
|
||||
function clear(root: ShadowRoot) {
|
||||
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of marked) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = diffLineIndex(split, row)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = annotationIndex(annotation)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
||||
for (let line = start; line <= end; line++) {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const line = annotationIndex(annotation)
|
||||
if (line === undefined || line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
export type DiffSelectionSide = "additions" | "deletions"
|
||||
|
||||
export function findDiffSide(node: HTMLElement): DiffSelectionSide {
|
||||
const line = node.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = node.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
export function diffLineIndex(split: boolean, node: HTMLElement) {
|
||||
const raw = node.dataset.lineIndex
|
||||
if (!raw) return
|
||||
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((x) => parseInt(x, 10))
|
||||
.filter((x) => !Number.isNaN(x))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
|
||||
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const target = side ?? "additions"
|
||||
for (const row of rows) {
|
||||
if (findDiffSide(row) === target) return diffLineIndex(split, row)
|
||||
if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
|
||||
}
|
||||
}
|
||||
|
||||
export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
|
||||
if (!range) return range
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const start = diffRowIndex(root, split, range.start, range.side)
|
||||
const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
return swapped
|
||||
}
|
||||
|
|
@ -1,485 +0,0 @@
|
|||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type FindHost = {
|
||||
element: () => HTMLElement | undefined
|
||||
open: () => void
|
||||
close: () => void
|
||||
next: (dir: 1 | -1) => void
|
||||
isOpen: () => boolean
|
||||
}
|
||||
|
||||
const hosts = new Set<FindHost>()
|
||||
let target: FindHost | undefined
|
||||
let current: FindHost | undefined
|
||||
let installed = false
|
||||
|
||||
function isEditable(node: unknown): boolean {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
if (node.closest("[data-prevent-autofocus]")) return true
|
||||
if (node.isContentEditable) return true
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
|
||||
}
|
||||
|
||||
function hostForNode(node: unknown) {
|
||||
if (!(node instanceof Node)) return
|
||||
for (const host of hosts) {
|
||||
const el = host.element()
|
||||
if (el && el.isConnected && el.contains(node)) return host
|
||||
}
|
||||
}
|
||||
|
||||
function installShortcuts() {
|
||||
if (installed) return
|
||||
if (typeof window === "undefined") return
|
||||
installed = true
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isEditable(event.target)) return
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "g") {
|
||||
const host = current
|
||||
if (!host || !host.isOpen()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.next(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (key !== "f") return
|
||||
|
||||
const active = current
|
||||
if (active && active.isOpen()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
active.open()
|
||||
return
|
||||
}
|
||||
|
||||
const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
|
||||
if (!host) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.open()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
|
||||
function clearHighlightFind() {
|
||||
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
|
||||
if (!api) return
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
}
|
||||
|
||||
function supportsHighlights() {
|
||||
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
|
||||
return typeof g.Highlight === "function" && g.CSS?.highlights != null
|
||||
}
|
||||
|
||||
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
let parent = el.parentElement
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent)
|
||||
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
type CreateFileFindOptions = {
|
||||
wrapper: () => HTMLElement | undefined
|
||||
overlay: () => HTMLDivElement | undefined
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
}
|
||||
|
||||
export function createFileFind(opts: CreateFileFindOptions) {
|
||||
let input: HTMLInputElement | undefined
|
||||
let overlayFrame: number | undefined
|
||||
let mode: "highlights" | "overlay" = "overlay"
|
||||
let hits: Range[] = []
|
||||
const [overlayScroll, setOverlayScroll] = createSignal<HTMLElement[]>([])
|
||||
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
query: "",
|
||||
index: 0,
|
||||
count: 0,
|
||||
pos: { top: 8, right: 8 },
|
||||
})
|
||||
const open = () => state.open
|
||||
const query = () => state.query
|
||||
const index = () => state.index
|
||||
const count = () => state.count
|
||||
const pos = () => state.pos
|
||||
|
||||
const clearOverlayScroll = () => {
|
||||
setOverlayScroll([])
|
||||
}
|
||||
|
||||
const clearOverlay = () => {
|
||||
const el = opts.overlay()
|
||||
if (!el) return
|
||||
if (overlayFrame !== undefined) {
|
||||
cancelAnimationFrame(overlayFrame)
|
||||
overlayFrame = undefined
|
||||
}
|
||||
el.innerHTML = ""
|
||||
}
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (mode !== "overlay") {
|
||||
clearOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
const overlay = opts.overlay()
|
||||
if (!wrapper || !overlay) return
|
||||
|
||||
clearOverlay()
|
||||
if (hits.length === 0) return
|
||||
|
||||
const base = wrapper.getBoundingClientRect()
|
||||
const currentIndex = index()
|
||||
const frag = document.createDocumentFragment()
|
||||
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const range = hits[i]
|
||||
const active = i === currentIndex
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (!rect.width || !rect.height) continue
|
||||
|
||||
const mark = document.createElement("div")
|
||||
mark.style.position = "absolute"
|
||||
mark.style.left = `${Math.round(rect.left - base.left)}px`
|
||||
mark.style.top = `${Math.round(rect.top - base.top)}px`
|
||||
mark.style.width = `${Math.round(rect.width)}px`
|
||||
mark.style.height = `${Math.round(rect.height)}px`
|
||||
mark.style.borderRadius = "2px"
|
||||
mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
|
||||
mark.style.opacity = active ? "0.55" : "0.35"
|
||||
if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
|
||||
frag.appendChild(mark)
|
||||
}
|
||||
}
|
||||
|
||||
overlay.appendChild(frag)
|
||||
}
|
||||
|
||||
function scheduleOverlay() {
|
||||
if (mode !== "overlay") return
|
||||
if (!open()) return
|
||||
if (overlayFrame !== undefined) return
|
||||
|
||||
overlayFrame = requestAnimationFrame(() => {
|
||||
overlayFrame = undefined
|
||||
renderOverlay()
|
||||
})
|
||||
}
|
||||
|
||||
const syncOverlayScroll = () => {
|
||||
if (mode !== "overlay") return
|
||||
const root = opts.getRoot()
|
||||
|
||||
const next = root
|
||||
? Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
: []
|
||||
const current = overlayScroll()
|
||||
if (next.length === current.length && next.every((el, i) => el === current[i])) return
|
||||
|
||||
clearOverlayScroll()
|
||||
setOverlayScroll(next)
|
||||
}
|
||||
|
||||
const clearFind = () => {
|
||||
clearHighlightFind()
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
hits = []
|
||||
setState("count", 0)
|
||||
setState("index", 0)
|
||||
}
|
||||
|
||||
const positionBar = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
const rect = root.getBoundingClientRect()
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const header = Number.isNaN(title) ? 0 : title
|
||||
|
||||
setState("pos", {
|
||||
top: Math.round(rect.top) + header - 4,
|
||||
right: Math.round(window.innerWidth - rect.right) + 8,
|
||||
})
|
||||
}
|
||||
|
||||
const scan = (root: ShadowRoot, value: string) => {
|
||||
const needle = value.toLowerCase()
|
||||
const ranges: Range[] = []
|
||||
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const col of cols) {
|
||||
const text = col.textContent
|
||||
if (!text) continue
|
||||
|
||||
const hay = text.toLowerCase()
|
||||
let at = hay.indexOf(needle)
|
||||
if (at === -1) continue
|
||||
|
||||
const nodes: Text[] = []
|
||||
const ends: number[] = []
|
||||
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
let pos = 0
|
||||
while (node) {
|
||||
if (node instanceof Text) {
|
||||
pos += node.data.length
|
||||
nodes.push(node)
|
||||
ends.push(pos)
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const locate = (offset: number) => {
|
||||
let lo = 0
|
||||
let hi = ends.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ends[mid] >= offset) hi = mid
|
||||
else lo = mid + 1
|
||||
}
|
||||
const prev = lo === 0 ? 0 : ends[lo - 1]
|
||||
return { node: nodes[lo], offset: offset - prev }
|
||||
}
|
||||
|
||||
while (at !== -1) {
|
||||
const start = locate(at)
|
||||
const end = locate(at + value.length)
|
||||
const range = document.createRange()
|
||||
range.setStart(start.node, start.offset)
|
||||
range.setEnd(end.node, end.offset)
|
||||
ranges.push(range)
|
||||
at = hay.indexOf(needle, at + value.length)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const scrollToRange = (range: Range) => {
|
||||
const start = range.startContainer
|
||||
const el = start instanceof Element ? start : start.parentElement
|
||||
el?.scrollIntoView({ block: "center", inline: "center" })
|
||||
}
|
||||
|
||||
const setHighlights = (ranges: Range[], currentIndex: number) => {
|
||||
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
||||
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
||||
if (!api || typeof Highlight !== "function") return false
|
||||
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
|
||||
const active = ranges[currentIndex]
|
||||
if (active) api.set("opencode-find-current", new Highlight(active))
|
||||
|
||||
const rest = ranges.filter((_, i) => i !== currentIndex)
|
||||
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
||||
return true
|
||||
}
|
||||
|
||||
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
|
||||
if (!open()) return
|
||||
|
||||
const value = query().trim()
|
||||
if (!value) {
|
||||
clearFind()
|
||||
return
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) return
|
||||
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
|
||||
const ranges = scan(root, value)
|
||||
const total = ranges.length
|
||||
const desired = args?.reset ? 0 : index()
|
||||
const currentIndex = total ? Math.min(desired, total - 1) : 0
|
||||
|
||||
hits = ranges
|
||||
setState("count", total)
|
||||
setState("index", currentIndex)
|
||||
|
||||
const active = ranges[currentIndex]
|
||||
if (mode === "highlights") {
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
if (!setHighlights(ranges, currentIndex)) {
|
||||
mode = "overlay"
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scheduleOverlay()
|
||||
}
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setState("open", false)
|
||||
setState("query", "")
|
||||
clearFind()
|
||||
if (current === host) current = undefined
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (current && current !== host) current.close()
|
||||
current = host
|
||||
target = host
|
||||
if (!open()) setState("open", true)
|
||||
requestAnimationFrame(() => {
|
||||
apply({ scroll: true })
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const next = (dir: 1 | -1) => {
|
||||
if (!open()) return
|
||||
const total = count()
|
||||
if (total <= 0) return
|
||||
|
||||
const currentIndex = (index() + dir + total) % total
|
||||
setState("index", currentIndex)
|
||||
|
||||
const active = hits[currentIndex]
|
||||
if (!active) return
|
||||
|
||||
if (mode === "highlights") {
|
||||
if (!setHighlights(hits, currentIndex)) {
|
||||
mode = "overlay"
|
||||
apply({ reset: true, scroll: true })
|
||||
return
|
||||
}
|
||||
scrollToRange(active)
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const host: FindHost = {
|
||||
element: opts.wrapper,
|
||||
isOpen: () => open(),
|
||||
next,
|
||||
open: focus,
|
||||
close,
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true })
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
installShortcuts()
|
||||
hosts.add(host)
|
||||
if (!target) target = host
|
||||
|
||||
onCleanup(() => {
|
||||
hosts.delete(host)
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
if (target === host) target = undefined
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) return
|
||||
|
||||
const update = () => positionBar()
|
||||
requestAnimationFrame(update)
|
||||
makeEventListener(window, "resize", update, { passive: true })
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
createResizeObserver(root, update)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearOverlayScroll()
|
||||
clearOverlay()
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
open,
|
||||
query,
|
||||
count,
|
||||
index,
|
||||
pos,
|
||||
setInput: (el: HTMLInputElement) => {
|
||||
input = el
|
||||
},
|
||||
setQuery: (value: string) => {
|
||||
setState("query", value)
|
||||
setState("index", 0)
|
||||
apply({ reset: true, scroll: true })
|
||||
},
|
||||
focus,
|
||||
close,
|
||||
next,
|
||||
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
|
||||
onPointerDown: () => {
|
||||
target = host
|
||||
opts.wrapper()?.focus({ preventScroll: true })
|
||||
},
|
||||
onFocus: () => {
|
||||
target = host
|
||||
},
|
||||
onInputKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
next(event.shiftKey ? -1 : 1)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
type ReadyWatcher = {
|
||||
observer?: MutationObserver
|
||||
token: number
|
||||
}
|
||||
|
||||
export function createReadyWatcher(): ReadyWatcher {
|
||||
return { token: 0 }
|
||||
}
|
||||
|
||||
export function clearReadyWatcher(state: ReadyWatcher) {
|
||||
state.observer?.disconnect()
|
||||
state.observer = undefined
|
||||
}
|
||||
|
||||
export function getViewerHost(container: HTMLElement | undefined) {
|
||||
if (!container) return
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host
|
||||
}
|
||||
|
||||
export function getViewerRoot(container: HTMLElement | undefined) {
|
||||
return getViewerHost(container)?.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
export function applyViewerScheme(host: HTMLElement | undefined) {
|
||||
if (!host) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
|
||||
if (typeof document === "undefined") return () => {}
|
||||
|
||||
applyViewerScheme(getHost())
|
||||
if (typeof MutationObserver === "undefined") return () => {}
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
return () => monitor.disconnect()
|
||||
}
|
||||
|
||||
export function notifyShadowReady(opts: {
|
||||
state: ReadyWatcher
|
||||
container: HTMLElement
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
isReady: (root: ShadowRoot) => boolean
|
||||
onReady: () => void
|
||||
settleFrames?: number
|
||||
}) {
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.token += 1
|
||||
|
||||
const token = opts.state.token
|
||||
const settle = Math.max(0, opts.settleFrames ?? 0)
|
||||
|
||||
const runReady = () => {
|
||||
const step = (left: number) => {
|
||||
if (token !== opts.state.token) return
|
||||
if (left <= 0) {
|
||||
opts.onReady()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => step(left - 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => step(settle))
|
||||
}
|
||||
|
||||
const observeRoot = (root: ShadowRoot) => {
|
||||
if (opts.isReady(root)) {
|
||||
runReady()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
if (!opts.isReady(root)) return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
runReady()
|
||||
})
|
||||
opts.state.observer.observe(root, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) {
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
|
||||
const next = opts.getRoot()
|
||||
if (!next) return
|
||||
|
||||
observeRoot(next)
|
||||
})
|
||||
opts.state.observer.observe(opts.container, { childList: true, subtree: true })
|
||||
return
|
||||
}
|
||||
|
||||
observeRoot(root)
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { toRange } from "./selection-bridge"
|
||||
|
||||
export function findElement(node: Node | null): HTMLElement | undefined {
|
||||
if (!node) return
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement ?? undefined
|
||||
}
|
||||
|
||||
export function findFileLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const value = parseInt(line.dataset.line ?? "", 10)
|
||||
if (Number.isNaN(value)) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function findDiffLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line], [data-alt-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const primary = parseInt(line.dataset.line ?? "", 10)
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
|
||||
const alt = parseInt(line.dataset.altLine ?? "", 10)
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
}
|
||||
|
||||
export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const code = el.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function readShadowLineSelection(opts: {
|
||||
root: ShadowRoot
|
||||
lineForNode: (node: Node | null) => number | undefined
|
||||
sideForNode?: (node: Node | null) => SelectedLineRange["side"]
|
||||
preserveTextSelection?: boolean
|
||||
}) {
|
||||
const selection =
|
||||
(opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
const domRange =
|
||||
(
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
|
||||
}
|
||||
).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
|
||||
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
|
||||
|
||||
const startNode = domRange?.startContainer ?? selection.anchorNode
|
||||
const endNode = domRange?.endContainer ?? selection.focusNode
|
||||
if (!startNode || !endNode) return
|
||||
if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
|
||||
|
||||
const start = opts.lineForNode(startNode)
|
||||
const end = opts.lineForNode(endNode)
|
||||
if (start === undefined || end === undefined) return
|
||||
|
||||
const startSide = opts.sideForNode?.(startNode)
|
||||
const endSide = opts.sideForNode?.(endNode)
|
||||
const side = startSide ?? endSide
|
||||
|
||||
const range: SelectedLineRange = { start, end }
|
||||
if (side) range.side = side
|
||||
if (endSide && side && endSide !== side) range.endSide = endSide
|
||||
|
||||
return {
|
||||
range,
|
||||
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
before: FileContents
|
||||
after: FileContents
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
const unsafeCSS = `
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
|
||||
--diffs-bg-deletion-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
|
||||
);
|
||||
--diffs-bg-addition-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
|
||||
);
|
||||
--diffs-selection-base: var(--surface-warning-strong);
|
||||
--diffs-selection-border: var(--border-warning-base);
|
||||
--diffs-selection-number-fg: #1c1917;
|
||||
/* Use explicit alpha instead of color-mix(..., transparent) to avoid Safari's non-premultiplied interpolation bugs. */
|
||||
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--surface-warning-base) r g b / 0.65));
|
||||
--diffs-bg-selection-number: var(
|
||||
--diffs-bg-selection-number-override,
|
||||
rgb(from var(--surface-warning-base) r g b / 0.85)
|
||||
);
|
||||
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
|
||||
}
|
||||
|
||||
:host([data-color-scheme='dark']) [data-diff],
|
||||
:host([data-color-scheme='dark']) [data-file] {
|
||||
--diffs-selection-number-fg: #fdfbfb;
|
||||
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
|
||||
--diffs-bg-selection-number: var(
|
||||
--diffs-bg-selection-number-override,
|
||||
rgb(from var(--solaris-dark-6) r g b / 0.85)
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff] ::selection,
|
||||
[data-file] ::selection {
|
||||
background-color: var(--diffs-bg-selection-text);
|
||||
}
|
||||
|
||||
::highlight(opencode-find) {
|
||||
background-color: rgb(from var(--surface-warning-base) r g b / 0.35);
|
||||
}
|
||||
|
||||
::highlight(opencode-find-current) {
|
||||
background-color: rgb(from var(--surface-warning-strong) r g b / 0.55);
|
||||
}
|
||||
|
||||
[data-diff] [data-line][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] {
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
/* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */
|
||||
[data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] {
|
||||
--diffs-bg-deletion-emphasis: light-dark(
|
||||
rgb(from var(--diffs-deletion-base) r g b / 0.07),
|
||||
rgb(from var(--diffs-deletion-base) r g b / 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff-header],
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
[data-separator] {
|
||||
height: 24px;
|
||||
}
|
||||
[data-column-number] {
|
||||
background-color: var(--background-stronger);
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
&[data-interactive-line-numbers] [data-column-number] {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
&[data-interactive-lines] [data-line] {
|
||||
cursor: auto !important;
|
||||
}
|
||||
[data-code] {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: clip !important;
|
||||
}
|
||||
}
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
theme: "OpenCode",
|
||||
themeType: "system",
|
||||
disableLineNumbers: false,
|
||||
overflow: "wrap",
|
||||
diffStyle: style ?? "unified",
|
||||
diffIndicators: "bars",
|
||||
lineHoverHighlight: "both",
|
||||
disableBackground: false,
|
||||
expansionLineCount: 20,
|
||||
hunkSeparators: "line-info-basic",
|
||||
lineDiffType: style === "split" ? "word-alt" : "none",
|
||||
maxLineDiffLength: 1000,
|
||||
maxLineLengthForHighlighting: 1000,
|
||||
disableFileHeader: true,
|
||||
unsafeCSS,
|
||||
} as const
|
||||
}
|
||||
|
||||
export const styleVariables = {
|
||||
"--diffs-font-family": "var(--font-family-mono)",
|
||||
"--diffs-font-size": "var(--font-size-small)",
|
||||
"--diffs-line-height": "24px",
|
||||
"--diffs-tab-size": 2,
|
||||
"--diffs-font-features": "var(--font-family-mono--font-feature-settings)",
|
||||
"--diffs-header-font-family": "var(--font-family-sans)",
|
||||
"--diffs-gap-block": 0,
|
||||
"--diffs-min-number-column-width": "4ch",
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type MediaKind = "image" | "audio" | "svg"
|
||||
|
||||
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
|
||||
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
|
||||
|
||||
type MediaValue = unknown
|
||||
|
||||
function mediaRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Partial<FileContent> & {
|
||||
content?: unknown
|
||||
encoding?: unknown
|
||||
mimeType?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMimeType(type: string | undefined) {
|
||||
if (!type) return
|
||||
const mime = type.split(";", 1)[0]?.trim().toLowerCase()
|
||||
if (!mime) return
|
||||
if (mime === "audio/x-aac") return "audio/aac"
|
||||
if (mime === "audio/x-m4a") return "audio/mp4"
|
||||
return mime
|
||||
}
|
||||
|
||||
export function fileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const idx = path.lastIndexOf(".")
|
||||
if (idx === -1) return ""
|
||||
return path.slice(idx + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
|
||||
const ext = fileExtension(path)
|
||||
if (ext === "svg") return "svg"
|
||||
if (imageExtensions.has(ext)) return "image"
|
||||
if (audioExtensions.has(ext)) return "audio"
|
||||
}
|
||||
|
||||
export function isBinaryContent(value: MediaValue) {
|
||||
return mediaRecord(value)?.type === "binary"
|
||||
}
|
||||
|
||||
function validDataUrl(value: string, kind: MediaKind) {
|
||||
if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
|
||||
if (kind === "image") return value.startsWith("data:image/") ? value : undefined
|
||||
if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
|
||||
if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
|
||||
if (value.startsWith("data:audio/")) return value
|
||||
}
|
||||
|
||||
export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
|
||||
if (!value) return
|
||||
|
||||
if (typeof value === "string") {
|
||||
return validDataUrl(value, kind)
|
||||
}
|
||||
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (!mime) return
|
||||
|
||||
if (kind === "svg") {
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
|
||||
}
|
||||
|
||||
if (kind === "image" && !mime.startsWith("image/")) return
|
||||
if (kind === "audio" && !mime.startsWith("audio/")) return
|
||||
if (record.encoding !== "base64") return
|
||||
|
||||
return `data:${mime};base64,${record.content}`
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(value: string) {
|
||||
if (typeof atob !== "function") return
|
||||
|
||||
try {
|
||||
const raw = atob(value)
|
||||
const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
|
||||
if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
|
||||
return raw
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function svgTextFromValue(value: MediaValue) {
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return decodeBase64Utf8(record.content)
|
||||
return record.content
|
||||
}
|
||||
|
||||
export function hasMediaValue(value: MediaValue) {
|
||||
if (typeof value === "string") return value.length > 0
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return false
|
||||
return typeof record.content === "string" && record.content.length > 0
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
type SelectionKey = "ui.sessionReview.selection.line" | "ui.sessionReview.selection.lines"
|
||||
type SelectionVars = Record<string, string | number>
|
||||
|
||||
type PointerMode = "none" | "text" | "numbers"
|
||||
type Side = SelectedLineRange["side"]
|
||||
type LineSpan = Pick<SelectedLineRange, "start" | "end">
|
||||
|
||||
export function formatSelectedLineLabel(range: LineSpan, t: (key: SelectionKey, params: SelectionVars) => string) {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return t("ui.sessionReview.selection.line", { line: start })
|
||||
return t("ui.sessionReview.selection.lines", { start, end })
|
||||
}
|
||||
|
||||
export function previewSelectedLines(source: string, range: LineSpan) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
const lines = source.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}
|
||||
|
||||
if (range.side) next.side = range.side
|
||||
if (range.endSide) next.endSide = range.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
|
||||
if (!range) return false
|
||||
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (line < start || line > end) return false
|
||||
if (!side) return true
|
||||
|
||||
const first = range.side
|
||||
const last = range.endSide ?? first
|
||||
if (!first && !last) return true
|
||||
if (!first || !last) return (first ?? last) === side
|
||||
if (first === last) return first === side
|
||||
if (line === start) return first === side
|
||||
if (line === end) return last === side
|
||||
return true
|
||||
}
|
||||
|
||||
export function isSingleLineSelection(range: SelectedLineRange | null) {
|
||||
if (!range) return false
|
||||
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
|
||||
}
|
||||
|
||||
export function toRange(source: Range | StaticRange): Range {
|
||||
if (source instanceof Range) return source
|
||||
const range = new Range()
|
||||
range.setStart(source.startContainer, source.startOffset)
|
||||
range.setEnd(source.endContainer, source.endOffset)
|
||||
return range
|
||||
}
|
||||
|
||||
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
|
||||
if (!root || !range) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
try {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
export function createLineNumberSelectionBridge() {
|
||||
let mode: PointerMode = "none"
|
||||
let line: number | undefined
|
||||
let moved = false
|
||||
let pending = false
|
||||
|
||||
const clear = () => {
|
||||
mode = "none"
|
||||
line = undefined
|
||||
moved = false
|
||||
}
|
||||
|
||||
return {
|
||||
begin(numberColumn: boolean, next: number | undefined) {
|
||||
if (!numberColumn) {
|
||||
mode = "text"
|
||||
return
|
||||
}
|
||||
|
||||
mode = "numbers"
|
||||
line = next
|
||||
moved = false
|
||||
},
|
||||
track(buttons: number, next: number | undefined) {
|
||||
if (mode !== "numbers") return false
|
||||
|
||||
if ((buttons & 1) === 0) {
|
||||
clear()
|
||||
return true
|
||||
}
|
||||
|
||||
if (next !== undefined && line !== undefined && next !== line) moved = true
|
||||
return true
|
||||
},
|
||||
finish() {
|
||||
const current = mode
|
||||
pending = current === "numbers" && moved
|
||||
clear()
|
||||
return current
|
||||
},
|
||||
consume(range: SelectedLineRange | null) {
|
||||
const result = pending && !isSingleLineSelection(range)
|
||||
pending = false
|
||||
return result
|
||||
},
|
||||
reset() {
|
||||
pending = false
|
||||
clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
|
||||
|
||||
type Target = {
|
||||
key: Document | HTMLElement
|
||||
root: Document | HTMLElement
|
||||
content: HTMLElement | undefined
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
virtualizer: Virtualizer
|
||||
refs: number
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Document | HTMLElement, Entry>()
|
||||
|
||||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
spacing: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
return value === "auto" || value === "scroll" || value === "overlay"
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement) {
|
||||
let node = container.parentElement
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
if (scrollable(style.overflowY)) return node
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function target(container: HTMLElement): Target | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const review = container.closest("[data-component='session-review']")
|
||||
if (review instanceof HTMLElement) {
|
||||
const root = scrollRoot(container) ?? review
|
||||
const content = review.querySelector("[data-slot='session-review-container']")
|
||||
return {
|
||||
key: review,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const root = scrollRoot(container)
|
||||
if (root) {
|
||||
const content = root.querySelector("[role='log']")
|
||||
return {
|
||||
key: root,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: document,
|
||||
root: document,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireVirtualizer(container: HTMLElement) {
|
||||
const resolved = target(container)
|
||||
if (!resolved) return
|
||||
|
||||
let entry = cache.get(resolved.key)
|
||||
if (!entry) {
|
||||
const virtualizer = new Virtualizer()
|
||||
virtualizer.setup(resolved.root, resolved.content)
|
||||
entry = {
|
||||
virtualizer,
|
||||
refs: 0,
|
||||
}
|
||||
cache.set(resolved.key, entry)
|
||||
}
|
||||
|
||||
entry.refs += 1
|
||||
let done = false
|
||||
|
||||
return {
|
||||
virtualizer: entry.virtualizer,
|
||||
release() {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
const current = cache.get(resolved.key)
|
||||
if (!current) return
|
||||
|
||||
current.refs -= 1
|
||||
if (current.refs > 0) return
|
||||
|
||||
current.virtualizer.cleanUp()
|
||||
cache.delete(resolved.key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { WorkerPoolManager } from "@pierre/diffs/worker"
|
||||
import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
|
||||
|
||||
export type WorkerPoolStyle = "unified" | "split"
|
||||
|
||||
export function workerFactory(): Worker {
|
||||
return new Worker(ShikiWorkerUrl, { type: "module" })
|
||||
}
|
||||
|
||||
function createPool(lineDiffType: "none" | "word-alt") {
|
||||
const pool = new WorkerPoolManager(
|
||||
{
|
||||
workerFactory,
|
||||
// poolSize defaults to 8. More workers = more parallelism but
|
||||
// also more memory. Too many can actually slow things down.
|
||||
// NOTE: 2 is probably better for OpenCode, as I think 8 might be
|
||||
// a bit overkill, especially because Safari has a significantly slower
|
||||
// boot up time for workers
|
||||
poolSize: 2,
|
||||
},
|
||||
{
|
||||
theme: "OpenCode",
|
||||
lineDiffType,
|
||||
preferredHighlighter: "shiki-wasm",
|
||||
},
|
||||
)
|
||||
|
||||
void pool.initialize()
|
||||
return pool
|
||||
}
|
||||
|
||||
let unified: WorkerPoolManager | undefined
|
||||
let split: WorkerPoolManager | undefined
|
||||
|
||||
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
if (style === "split") {
|
||||
if (!split) split = createPool("word-alt")
|
||||
return split
|
||||
}
|
||||
|
||||
if (!unified) unified = createPool("none")
|
||||
return unified
|
||||
}
|
||||
|
||||
export function getWorkerPools() {
|
||||
return {
|
||||
unified: getWorkerPool("unified"),
|
||||
split: getWorkerPool("split"),
|
||||
}
|
||||
}
|
||||
|
|
@ -10,12 +10,9 @@
|
|||
@import "../components/animated-number.css" layer(components);
|
||||
@import "../components/app-icon.css" layer(components);
|
||||
@import "../components/avatar.css" layer(components);
|
||||
@import "../components/basic-tool.css" layer(components);
|
||||
@import "../components/button.css" layer(components);
|
||||
@import "../components/card.css" layer(components);
|
||||
@import "../components/tool-error-card.css" layer(components);
|
||||
@import "../components/checkbox.css" layer(components);
|
||||
@import "../components/file.css" layer(components);
|
||||
@import "../components/collapsible.css" layer(components);
|
||||
@import "../components/diff-changes.css" layer(components);
|
||||
@import "../components/context-menu.css" layer(components);
|
||||
|
|
@ -33,9 +30,6 @@
|
|||
@import "../components/inline-input.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
@import "../components/logo.css" layer(components);
|
||||
@import "../components/markdown.css" layer(components);
|
||||
@import "../components/message-part.css" layer(components);
|
||||
@import "../components/message-nav.css" layer(components);
|
||||
@import "../components/popover.css" layer(components);
|
||||
@import "../components/progress.css" layer(components);
|
||||
@import "../components/progress-circle.css" layer(components);
|
||||
|
|
@ -45,18 +39,12 @@
|
|||
@import "../components/spinner.css" layer(components);
|
||||
@import "../components/switch.css" layer(components);
|
||||
@import "../components/scroll-view.css" layer(components);
|
||||
@import "../components/session-review.css" layer(components);
|
||||
@import "../components/session-turn.css" layer(components);
|
||||
@import "../components/shell-submessage.css" layer(components);
|
||||
@import "../components/sticky-accordion-header.css" layer(components);
|
||||
@import "../components/tabs.css" layer(components);
|
||||
@import "../components/tag.css" layer(components);
|
||||
@import "../components/text-reveal.css" layer(components);
|
||||
@import "../components/text-strikethrough.css" layer(components);
|
||||
@import "../components/text-shimmer.css" layer(components);
|
||||
@import "../components/tool-count-label.css" layer(components);
|
||||
@import "../components/tool-count-summary.css" layer(components);
|
||||
@import "../components/tool-status-title.css" layer(components);
|
||||
@import "../components/toast.css" layer(components);
|
||||
@import "../components/tooltip.css" layer(components);
|
||||
@import "../components/typewriter.css" layer(components);
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
[data-component="basic-tool-v2"] {
|
||||
--bt-title: var(--v2-text-text-base);
|
||||
--bt-sep: var(--v2-text-text-muted);
|
||||
--bt-subtitle: var(--v2-text-text-muted);
|
||||
--bt-args: var(--v2-text-text-muted);
|
||||
--bt-chevron: var(--v2-text-text-faint);
|
||||
--bt-content: var(--v2-text-text-muted);
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
[data-slot="basic-tool-v2-trigger"] {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-trigger"]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-labels"] {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--bt-title);
|
||||
font-variation-settings: "slnt" 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-sep"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--bt-sep);
|
||||
font-variation-settings: "slnt" 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-subtitle"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--bt-subtitle);
|
||||
font-variation-settings: "slnt" 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-arg"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--bt-args);
|
||||
font-variation-settings: "slnt" 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-diff"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-diff"] [data-component="diff-changes"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-chevron-wrap"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
color: var(--bt-chevron);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-chevron"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] [data-slot="basic-tool-v2-chevron"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-content"] {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-v2-content-inner"] {
|
||||
font-size: 15px;
|
||||
font-weight: 440;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0;
|
||||
color: var(--bt-content);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { BasicToolV2 } from "./basic-tool-v2"
|
||||
|
||||
const docs = `### Overview
|
||||
Compact collapsible tool row showing title, subtitle, args, and diff changes, with an expand/collapse chevron.
|
||||
|
||||
### API
|
||||
- \`BasicToolV2\` wraps Kobalte \`Collapsible\`. Pass \`open\`, \`defaultOpen\`, and \`onOpenChange\` for controlled/uncontrolled disclosure.
|
||||
- \`trigger\` accepts either a \`BasicToolV2TriggerTitle\` object (title, subtitle, args, changes) or arbitrary JSX.
|
||||
- When \`status\` is \`"pending"\` or \`"running"\`, subtitle/args/chevron hide and the title shows a shimmer animation.
|
||||
- Pass \`children\` for expandable detail content.
|
||||
|
||||
### Theming
|
||||
- Uses \`data-component="basic-tool-v2"\` and slot attributes; colors via \`--bt-*\` CSS variables.
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI V2/BasicTool",
|
||||
id: "components-basic-tool-v2",
|
||||
component: BasicToolV2,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
frameBackground: "#fff",
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
render: () => (
|
||||
<BasicToolV2
|
||||
trigger={{
|
||||
title: "Read",
|
||||
subtitle: "src/index.ts",
|
||||
args: ["lines=1-50"],
|
||||
changes: { additions: 12, deletions: 3 },
|
||||
}}
|
||||
defaultOpen={false}
|
||||
>
|
||||
File content appears here.
|
||||
</BasicToolV2>
|
||||
),
|
||||
}
|
||||
|
||||
export const Expanded = {
|
||||
render: () => (
|
||||
<BasicToolV2
|
||||
trigger={{
|
||||
title: "Read",
|
||||
subtitle: "src/index.ts",
|
||||
args: ["lines=1-50"],
|
||||
changes: { additions: 12, deletions: 3 },
|
||||
}}
|
||||
defaultOpen={true}
|
||||
>
|
||||
File content appears here.
|
||||
</BasicToolV2>
|
||||
),
|
||||
}
|
||||
|
||||
export const Pending = {
|
||||
render: () => (
|
||||
<BasicToolV2
|
||||
trigger={{
|
||||
title: "Read",
|
||||
subtitle: "src/index.ts",
|
||||
args: ["lines=1-50"],
|
||||
changes: { additions: 12, deletions: 3 },
|
||||
}}
|
||||
status="pending"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const NoChildren = {
|
||||
render: () => (
|
||||
<BasicToolV2
|
||||
trigger={{
|
||||
title: "Grep",
|
||||
subtitle: "pattern=TODO",
|
||||
args: ["recursive=true"],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomTrigger = {
|
||||
render: () => (
|
||||
<BasicToolV2
|
||||
trigger={
|
||||
<span style={{ color: "#161616", "font-size": "13px", "font-weight": "440" }}>Custom trigger content</span>
|
||||
}
|
||||
>
|
||||
Expandable detail for custom trigger.
|
||||
</BasicToolV2>
|
||||
),
|
||||
}
|
||||
|
||||
export const Controlled = {
|
||||
render: () => {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
return (
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px", "max-width": "420px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
"font-size": "12px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid rgba(0,0,0,0.15)",
|
||||
background: "#fff",
|
||||
color: "#161616",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Toggle from outside: {open() ? "Open" : "Closed"}
|
||||
</button>
|
||||
<BasicToolV2
|
||||
trigger={{
|
||||
title: "Write",
|
||||
subtitle: "src/utils.ts",
|
||||
changes: { additions: 8, deletions: 2 },
|
||||
}}
|
||||
open={open()}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
Controlled content.
|
||||
</BasicToolV2>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import { Collapsible } from "@kobalte/core/collapsible"
|
||||
import { type ComponentProps, type JSX, For, Show, createMemo, splitProps } from "solid-js"
|
||||
import { DiffChanges } from "./diff-changes-v2"
|
||||
import { TextShimmerV2 } from "./text-shimmer-v2"
|
||||
import "./basic-tool-v2.css"
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg
|
||||
data-slot="basic-tool-v2-chevron"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6.75194 10.6243C6.41861 10.8187 6 10.5783 6 10.1924V5.80837C6 5.42247 6.41861 5.18204 6.75194 5.37648L10.5096 7.56846C10.8404 7.7614 10.8404 8.2393 10.5096 8.43224L6.75194 10.6243Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface BasicToolV2TriggerTitle {
|
||||
title: string
|
||||
subtitle?: string
|
||||
args?: string[]
|
||||
changes?: { additions: number; deletions: number } | { additions: number; deletions: number }[]
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const isTriggerTitle = (val: unknown): val is BasicToolV2TriggerTitle =>
|
||||
typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
|
||||
|
||||
export interface BasicToolV2Props extends Omit<ComponentProps<"div">, "children" | "title"> {
|
||||
trigger: BasicToolV2TriggerTitle | JSX.Element
|
||||
children?: JSX.Element
|
||||
status?: string
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSubtitleClick?: () => void
|
||||
}
|
||||
|
||||
export function BasicToolV2(props: BasicToolV2Props) {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"trigger",
|
||||
"children",
|
||||
"status",
|
||||
"open",
|
||||
"defaultOpen",
|
||||
"onOpenChange",
|
||||
"onSubtitleClick",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
|
||||
const pending = createMemo(() => local.status === "pending" || local.status === "running")
|
||||
|
||||
const hasChildren = createMemo(() => {
|
||||
const c = local.children
|
||||
if (c == null) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const canExpand = createMemo(() => hasChildren() && !pending())
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (pending()) return
|
||||
local.onOpenChange?.(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
{...rest}
|
||||
data-component="basic-tool-v2"
|
||||
open={local.open}
|
||||
defaultOpen={local.defaultOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
disabled={!canExpand()}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<Collapsible.Trigger as="div" role="button" data-slot="basic-tool-v2-trigger">
|
||||
<div data-slot="basic-tool-v2-labels">
|
||||
<Show when={isTriggerTitle(local.trigger) && local.trigger} fallback={local.trigger as JSX.Element}>
|
||||
{(title) => (
|
||||
<>
|
||||
<span data-slot="basic-tool-v2-title">
|
||||
<TextShimmerV2 text={title().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() && title().subtitle}>
|
||||
<span data-slot="basic-tool-v2-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
data-slot="basic-tool-v2-subtitle"
|
||||
style={local.onSubtitleClick ? { cursor: "pointer" } : undefined}
|
||||
onClick={(e) => {
|
||||
if (local.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
local.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!pending() && title().args?.length}>
|
||||
<For each={title().args}>{(arg) => <span data-slot="basic-tool-v2-arg">{arg}</span>}</For>
|
||||
</Show>
|
||||
<Show when={!pending() && title().changes}>
|
||||
<span data-slot="basic-tool-v2-diff">
|
||||
<DiffChanges changes={title().changes!} />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!pending() && title().action}>{(action) => action()}</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={canExpand()}>
|
||||
<span data-slot="basic-tool-v2-chevron-wrap">
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={canExpand()}>
|
||||
<Collapsible.Content data-slot="basic-tool-v2-content">
|
||||
<div data-slot="basic-tool-v2-content-inner">{local.children}</div>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,875 +0,0 @@
|
|||
[data-component="session-progress-indicator-v2"] {
|
||||
--_duration: 1200ms;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted, #808080);
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot] {
|
||||
fill: currentColor;
|
||||
opacity: 0.2;
|
||||
animation-duration: var(--_duration);
|
||||
animation-timing-function: ease-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-0 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="0"] {
|
||||
animation-name: session-progress-indicator-v2-dot-0;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-5 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="5"] {
|
||||
animation-name: session-progress-indicator-v2-dot-5;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-10 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="10"] {
|
||||
animation-name: session-progress-indicator-v2-dot-10;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-15 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="15"] {
|
||||
animation-name: session-progress-indicator-v2-dot-15;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-20 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="20"] {
|
||||
animation-name: session-progress-indicator-v2-dot-20;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-1 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="1"] {
|
||||
animation-name: session-progress-indicator-v2-dot-1;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-6 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="6"] {
|
||||
animation-name: session-progress-indicator-v2-dot-6;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-11 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="11"] {
|
||||
animation-name: session-progress-indicator-v2-dot-11;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-16 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="16"] {
|
||||
animation-name: session-progress-indicator-v2-dot-16;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-21 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="21"] {
|
||||
animation-name: session-progress-indicator-v2-dot-21;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-2 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="2"] {
|
||||
animation-name: session-progress-indicator-v2-dot-2;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-7 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="7"] {
|
||||
animation-name: session-progress-indicator-v2-dot-7;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-12 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="12"] {
|
||||
animation-name: session-progress-indicator-v2-dot-12;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-17 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="17"] {
|
||||
animation-name: session-progress-indicator-v2-dot-17;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-22 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="22"] {
|
||||
animation-name: session-progress-indicator-v2-dot-22;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-3 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="3"] {
|
||||
animation-name: session-progress-indicator-v2-dot-3;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-8 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="8"] {
|
||||
animation-name: session-progress-indicator-v2-dot-8;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-13 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="13"] {
|
||||
animation-name: session-progress-indicator-v2-dot-13;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-18 {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="18"] {
|
||||
animation-name: session-progress-indicator-v2-dot-18;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-23 {
|
||||
0% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="23"] {
|
||||
animation-name: session-progress-indicator-v2-dot-23;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-4 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="4"] {
|
||||
animation-name: session-progress-indicator-v2-dot-4;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-9 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="9"] {
|
||||
animation-name: session-progress-indicator-v2-dot-9;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-14 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="14"] {
|
||||
animation-name: session-progress-indicator-v2-dot-14;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-19 {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="19"] {
|
||||
animation-name: session-progress-indicator-v2-dot-19;
|
||||
}
|
||||
|
||||
@keyframes session-progress-indicator-v2-dot-24 {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
12.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
37.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
62.5% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
87.5% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="24"] {
|
||||
animation-name: session-progress-indicator-v2-dot-24;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-progress-indicator-v2"] [data-dot] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-component="session-progress-indicator-v2"] [data-dot="12"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { SessionProgressIndicatorV2 } from "./session-progress-indicator-v2"
|
||||
|
||||
const docs = `### Overview
|
||||
Animated 5×5 dot grid loader for in-progress session state.
|
||||
|
||||
Derived from Figma \`_sessionProgressIndicator\` with 8-frame rotation.
|
||||
|
||||
### API
|
||||
- Accepts standard SVG props.
|
||||
|
||||
### Behavior
|
||||
- CSS keyframes drive per-dot opacity across 8 frames (1.2s loop).
|
||||
- Center dot stays at full opacity throughout the cycle.
|
||||
|
||||
### Accessibility
|
||||
- Sets \`aria-hidden="true"\` by default.
|
||||
|
||||
### Theming
|
||||
- Uses \`currentColor\` via \`--v2-icon-icon-muted\`.
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI V2/SessionProgressIndicator",
|
||||
id: "components-session-progress-indicator-v2",
|
||||
component: SessionProgressIndicatorV2,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = {
|
||||
render: () => <SessionProgressIndicatorV2 />,
|
||||
}
|
||||
|
||||
export const Sizes = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
|
||||
<SessionProgressIndicatorV2 width={12} height={12} />
|
||||
<SessionProgressIndicatorV2 />
|
||||
<SessionProgressIndicatorV2 width={24} height={24} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const OnDark = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "16px",
|
||||
"align-items": "center",
|
||||
padding: "16px",
|
||||
"background-color": "#171717",
|
||||
color: "#c7c7c7",
|
||||
}}
|
||||
>
|
||||
<SessionProgressIndicatorV2 />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { For, splitProps, type ComponentProps } from "solid-js"
|
||||
import "./session-progress-indicator-v2.css"
|
||||
|
||||
const grid = 5
|
||||
const dot = 2
|
||||
const gap = 1
|
||||
const origin = 1.5
|
||||
const dots = Array.from({ length: grid * grid }, (_, index) => ({
|
||||
index,
|
||||
x: origin + (index % grid) * (dot + gap),
|
||||
y: origin + Math.floor(index / grid) * (dot + gap),
|
||||
}))
|
||||
|
||||
export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "width", "height"])
|
||||
return (
|
||||
<svg
|
||||
{...rest}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
width={local.width ?? 16}
|
||||
height={local.height ?? 16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
data-component="session-progress-indicator-v2"
|
||||
aria-hidden={rest["aria-hidden"] ?? "true"}
|
||||
>
|
||||
<For each={dots}>{(cell) => <rect data-dot={cell.index} x={cell.x} y={cell.y} width={dot} height={dot} />}</For>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
[data-component="tool-error-card"] {
|
||||
--tec-border: var(--v2-state-fg-danger);
|
||||
--tec-title: var(--v2-text-text-base);
|
||||
--tec-sep: var(--v2-text-text-muted);
|
||||
--tec-subtitle: var(--v2-text-text-muted);
|
||||
--tec-suffix: var(--v2-text-text-faint);
|
||||
--tec-chevron: var(--v2-text-text-faint);
|
||||
--tec-icon: var(--v2-icon-icon-base);
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0 0 0 10px;
|
||||
gap: 8px;
|
||||
border-left: 2px solid var(--tec-border);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
[data-slot="tool-error-card-trigger"] {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
padding: 2px 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-trigger"]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-icon-wrap"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
color: var(--tec-icon);
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-ban"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-loader"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
animation: tool-error-card-spin 0.65s linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-main"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
min-height: 20px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-labels"] {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
height: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--tec-title);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-sep"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--tec-sep);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-subtitle"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
flex: 0 1 auto;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--tec-subtitle);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
a[data-slot="tool-error-card-subtitle"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 20px;
|
||||
flex: 0 1 auto;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-chevron-wrap"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 20px;
|
||||
color: var(--tec-chevron);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-chevron"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--tec-chevron);
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] [data-slot="tool-error-card-chevron"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-content"] {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-suffix"] {
|
||||
padding: 0 0 0 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--tec-suffix);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tool-error-card-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { ButtonV2 } from "./button-v2"
|
||||
import { ToolErrorCardV2, type ToolErrorCardV2Props } from "./tool-error-card-v2"
|
||||
|
||||
const docs = `### Overview
|
||||
Compact tool error row with optional expandable detail, aligned to the OpenCode design system spec.
|
||||
|
||||
### API
|
||||
- \`ToolErrorCardV2\` wraps Kobalte \`Collapsible\` directly. Pass \`open\`, \`defaultOpen\`, and \`onOpenChange\` like any disclosure (controlled when \`open\` is defined).
|
||||
- Without a non-empty \`suffix\`, the card is not expandable (\`disabled\` on the collapsible root).
|
||||
|
||||
### Theming
|
||||
- Uses \`data-component="tool-error-card"\` and slot attributes; colors are CSS variables on the root (\`--tec-*\`).
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI V2/ToolErrorCard",
|
||||
id: "components-tool-error-card-v2",
|
||||
component: ToolErrorCardV2,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
frameBackground: "#fff",
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
title: "Read",
|
||||
subtitle: "Permission denied",
|
||||
suffix: "The tool could not access the requested path.",
|
||||
defaultOpen: false,
|
||||
} satisfies ToolErrorCardV2Props,
|
||||
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
|
||||
}
|
||||
|
||||
export const Loading = {
|
||||
args: {
|
||||
title: "Read",
|
||||
subtitle: "Working",
|
||||
suffix: "Details appear when the tool finishes.",
|
||||
loading: true,
|
||||
defaultOpen: false,
|
||||
} satisfies ToolErrorCardV2Props,
|
||||
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
|
||||
}
|
||||
|
||||
export const SubtitleLink = {
|
||||
args: {
|
||||
title: "Task",
|
||||
subtitle: "View logs",
|
||||
subtitleHref: "https://example.com",
|
||||
suffix: "Subagent exited with code 1.",
|
||||
defaultOpen: false,
|
||||
} satisfies ToolErrorCardV2Props,
|
||||
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
|
||||
}
|
||||
|
||||
export const NoSuffixDisabled = {
|
||||
args: {
|
||||
title: "List",
|
||||
subtitle: "No detail",
|
||||
defaultOpen: false,
|
||||
} satisfies ToolErrorCardV2Props,
|
||||
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
|
||||
}
|
||||
|
||||
export const Controlled = {
|
||||
render: () => {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
return (
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "24px", "max-width": "420px" }}>
|
||||
<ButtonV2 type="button" classList={{ "w-fit": true }} onClick={() => setOpen((o) => !o)}>
|
||||
Toggle from outside: {open() ? "Open" : "Closed"}
|
||||
</ButtonV2>
|
||||
<ToolErrorCardV2
|
||||
title="Grep"
|
||||
subtitle="Timeout"
|
||||
suffix="Operation exceeded 30s."
|
||||
open={open()}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
import { Collapsible } from "@kobalte/core/collapsible"
|
||||
import { type ComponentProps, type JSX, Show, createMemo, splitProps } from "solid-js"
|
||||
import "./tool-error-card-v2.css"
|
||||
|
||||
function BanIcon() {
|
||||
return (
|
||||
<svg
|
||||
data-slot="tool-error-card-ban"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.44283 12.5575L12.5495 3.45081M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** duo-progress-25: faint track ring + ~25% solid arc (Figma OpenCode DS) */
|
||||
function LoaderIcon() {
|
||||
const r = 5.9
|
||||
return (
|
||||
<svg
|
||||
data-slot="tool-error-card-loader"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform="translate(8 8)">
|
||||
<circle
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--v2-icon-icon-base)"
|
||||
stroke-width="1"
|
||||
stroke-opacity="0.3"
|
||||
transform="rotate(-90)"
|
||||
/>
|
||||
<circle
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--v2-icon-icon-base)"
|
||||
stroke-width="1"
|
||||
pathLength="100"
|
||||
stroke-dasharray="25 75"
|
||||
transform="rotate(-90)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg
|
||||
data-slot="tool-error-card-chevron"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5.90795 9.62425C5.61628 9.81865 5.25 9.57825 5.25 9.19235V4.80837C5.25 4.42247 5.61628 4.18204 5.90795 4.37648L9.1959 6.56846C9.48535 6.7614 9.48535 7.2393 9.1959 7.43224L5.90795 9.62425Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ToolErrorCardV2Props extends Omit<ComponentProps<"div">, "children" | "title"> {
|
||||
title: JSX.Element | string
|
||||
subtitle: JSX.Element | string
|
||||
suffix?: JSX.Element | string
|
||||
loading?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
/** When set, subtitle renders as a link (clicks do not toggle expand). */
|
||||
subtitleHref?: string
|
||||
}
|
||||
|
||||
export function ToolErrorCardV2(props: ToolErrorCardV2Props) {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"title",
|
||||
"subtitle",
|
||||
"suffix",
|
||||
"loading",
|
||||
"open",
|
||||
"defaultOpen",
|
||||
"onOpenChange",
|
||||
"subtitleHref",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
|
||||
const hasSuffix = createMemo(() => {
|
||||
const s = local.suffix
|
||||
if (s == null) return false
|
||||
if (typeof s === "string") return s.length > 0
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
{...rest}
|
||||
data-component="tool-error-card"
|
||||
open={local.open}
|
||||
defaultOpen={local.defaultOpen}
|
||||
onOpenChange={local.onOpenChange}
|
||||
disabled={!hasSuffix()}
|
||||
aria-busy={local.loading ? true : undefined}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<Collapsible.Trigger as="div" role="button" data-slot="tool-error-card-trigger">
|
||||
<span data-slot="tool-error-card-icon-wrap">
|
||||
<Show when={local.loading} fallback={<BanIcon />}>
|
||||
<LoaderIcon />
|
||||
</Show>
|
||||
</span>
|
||||
<div data-slot="tool-error-card-main">
|
||||
<div data-slot="tool-error-card-labels">
|
||||
<span data-slot="tool-error-card-title">{local.title}</span>
|
||||
<span data-slot="tool-error-card-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<Show
|
||||
when={local.subtitleHref}
|
||||
fallback={<span data-slot="tool-error-card-subtitle">{local.subtitle}</span>}
|
||||
>
|
||||
<a
|
||||
data-slot="tool-error-card-subtitle"
|
||||
href={local.subtitleHref!}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{local.subtitle}
|
||||
</a>
|
||||
</Show>
|
||||
<Show when={hasSuffix()}>
|
||||
<span data-slot="tool-error-card-chevron-wrap">
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={hasSuffix()}>
|
||||
<Collapsible.Content data-slot="tool-error-card-content">
|
||||
<div data-slot="tool-error-card-suffix">{local.suffix}</div>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue