feat(ui): tool call abstraction, turn diff summary, and composer improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kit Langton 2026-03-06 16:34:10 -06:00 committed by Adam
commit 4795806b13
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
136 changed files with 11978 additions and 4261 deletions

3
packages/ui/happydom.ts Normal file
View file

@ -0,0 +1,3 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
GlobalRegistrator.register()

View file

@ -25,10 +25,14 @@
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun run test:unit",
"test:unit": "bun test --preload ./happydom.ts ./src/components",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src/components",
"dev": "vite",
"generate:tailwind": "bun run script/tailwind.ts"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",

View file

@ -9,19 +9,20 @@
display: inline-flex;
flex-direction: row-reverse;
align-items: baseline;
justify-content: flex-end;
justify-content: flex-start;
line-height: inherit;
width: var(--animated-number-width, 1ch);
overflow: hidden;
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
overflow: clip;
transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"] {
display: inline-block;
flex-shrink: 0;
width: 1ch;
height: 1em;
line-height: 1em;
overflow: hidden;
overflow: clip;
vertical-align: baseline;
-webkit-mask-image: linear-gradient(
to bottom,
@ -46,7 +47,7 @@
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
transition-duration: var(--animated-number-duration, 560ms);
transition-duration: var(--animated-number-duration, 600ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}

View file

@ -1,7 +1,7 @@
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
const DURATION = 600
const DURATION = 800
function normalize(value: number) {
return ((value % 10) + 10) % 10
@ -90,10 +90,35 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
)
const width = createMemo(() => `${digits().length}ch`)
const [exitingDigits, setExitingDigits] = createSignal<number[]>([])
let exitTimer: number | undefined
createEffect(
on(
digits,
(current, prev) => {
if (prev && current.length < prev.length) {
setExitingDigits(prev.slice(current.length))
clearTimeout(exitTimer)
exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
} else {
clearTimeout(exitTimer)
setExitingDigits([])
}
},
{ defer: true },
),
)
const displayDigits = createMemo(() => {
const exiting = exitingDigits()
return exiting.length ? [...digits(), ...exiting] : digits()
})
return (
<span data-component="animated-number" class={props.class} aria-label={label()}>
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
<Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
</span>
</span>
)

View file

@ -0,0 +1,122 @@
import { createSignal } from "solid-js"
import {
getGrowDuration,
getCollapsibleDuration,
setGrowDuration,
setCollapsibleDuration,
} from "./motion"
export function AnimationDebugPanel() {
const [grow, setGrow] = createSignal(getGrowDuration())
const [collapsible, setCollapsible] = createSignal(getCollapsibleDuration())
const [collapsed, setCollapsed] = createSignal(true)
const [dragging, setDragging] = createSignal(false)
const [pos, setPos] = createSignal({ x: 16, y: 16 })
let dragOffset = { x: 0, y: 0 }
const onPointerDown = (e: PointerEvent) => {
if ((e.target as HTMLElement).closest("button, input")) return
setDragging(true)
dragOffset = { x: e.clientX + pos().x, y: e.clientY + pos().y }
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging()) return
setPos({ x: dragOffset.x - e.clientX, y: dragOffset.y - e.clientY })
}
const onPointerUp = () => setDragging(false)
return (
<div
style={{
position: "fixed",
bottom: `${pos().y}px`,
right: `${pos().x}px`,
"z-index": "99999",
background: "rgba(0, 0, 0, 0.85)",
color: "#e0e0e0",
"border-radius": "8px",
"font-family": "monospace",
"font-size": "12px",
"box-shadow": "0 4px 20px rgba(0,0,0,0.4)",
"user-select": "none",
cursor: dragging() ? "grabbing" : "grab",
border: "1px solid rgba(255,255,255,0.1)",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
style={{
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "6px 10px",
"border-bottom": collapsed() ? "none" : "1px solid rgba(255,255,255,0.1)",
gap: "8px",
}}
>
<span style={{ "font-weight": "bold", "font-size": "11px", opacity: "0.7" }}>
springs
</span>
<button
style={{
background: "none",
border: "none",
color: "#e0e0e0",
cursor: "pointer",
padding: "0 2px",
"font-size": "14px",
"line-height": "1",
}}
onClick={() => setCollapsed((c) => !c)}
>
{collapsed() ? "+" : "\u2013"}
</button>
</div>
{!collapsed() && (
<div style={{ padding: "8px 10px", display: "flex", "flex-direction": "column", gap: "6px" }}>
<SliderRow
label="Grow"
value={grow()}
onChange={(v) => {
setGrow(v)
setGrowDuration(v)
}}
/>
<SliderRow
label="Collapsible"
value={collapsible()}
onChange={(v) => {
setCollapsible(v)
setCollapsibleDuration(v)
}}
/>
</div>
)}
</div>
)
}
function SliderRow(props: { label: string; value: number; onChange: (v: number) => void }) {
return (
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<span style={{ width: "72px", "font-size": "11px" }}>{props.label}</span>
<input
type="range"
min="0.05"
max="2.0"
step="0.05"
value={props.value}
onInput={(e) => props.onChange(parseFloat(e.currentTarget.value))}
style={{ width: "100px", cursor: "pointer" }}
/>
<span style={{ width: "32px", "text-align": "right", "font-size": "11px" }}>
{props.value.toFixed(2)}
</span>
</div>
)
}

View file

@ -8,54 +8,28 @@
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
width: auto;
width: 100%;
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;
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: auto;
max-width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@ -63,11 +37,12 @@
}
[data-slot="basic-tool-tool-info-main"] {
flex: 0 1 auto;
display: flex;
align-items: baseline;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
overflow: clip;
}
[data-slot="basic-tool-tool-title"] {
@ -80,21 +55,14 @@
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;
display: inline-block;
flex: 0 1 auto;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;
@ -138,8 +106,7 @@
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;

View file

@ -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>
)
},
}

View file

@ -1,8 +1,20 @@
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import {
createEffect,
createSignal,
For,
Match,
on,
onCleanup,
onMount,
Show,
splitProps,
Switch,
type JSX,
} from "solid-js"
import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
import { Collapsible } from "./collapsible"
import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
import { hold } from "./tool-utils"
export type TriggerTitle = {
title: string
@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
)
}
export interface BasicToolProps {
icon: IconProps["name"]
interface ToolCallPanelBaseProps {
icon: string
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
animate?: boolean
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
defer?: boolean
locked?: boolean
animated?: boolean
watchDetails?: boolean
springContent?: boolean
onSubtitleClick?: () => void
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
function ToolCallTriggerBody(props: {
trigger: TriggerTitle | JSX.Element
pending: boolean
onSubtitleClick?: () => void
arrow?: boolean
}) {
return (
<div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={props.pending} />
</span>
<Show when={!props.pending}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (!props.onSubtitleClick) return
e.stopPropagation()
props.onSubtitleClick()
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!props.pending && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.arrow}>
<Collapsible.Arrow />
</Show>
</div>
)
}
export function BasicTool(props: BasicToolProps) {
function ToolCallPanel(props: ToolCallPanelBaseProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
const pending = () => props.status === "pending" || props.status === "running"
const pendingRaw = () => props.status === "pending" || props.status === "running"
const pending = hold(pendingRaw, 1000)
const watchDetails = () => props.watchDetails !== false
let frame: number | undefined
@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) {
on(
open,
(value) => {
if (!props.defer) return
if (!props.defer || props.springContent) return
if (!value) {
cancel()
setReady(false)
@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) {
),
)
// Animated height for collapsible open/close
// Animated content height — single springValue drives all height changes
let contentRef: HTMLDivElement | undefined
let heightAnim: AnimationPlaybackControls | undefined
let bodyRef: HTMLDivElement | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let observer: ResizeObserver | undefined
let resizeFrame: number | undefined
const initialOpen = open()
const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING)
const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
const doOpen = () => {
if (!contentRef || !bodyRef) return
contentRef.style.display = ""
// Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
if (bodyRef.style.opacity === "") {
bodyRef.style.opacity = "0"
bodyRef.style.filter = "blur(2px)"
}
const next = read()
fadeAnim?.stop()
fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
fadeAnim.finished.then(() => {
if (!bodyRef) return
bodyRef.style.opacity = ""
bodyRef.style.filter = ""
})
heightSpring.set(next)
}
const doClose = () => {
if (!contentRef || !bodyRef) return
fadeAnim?.stop()
fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
fadeAnim.finished.then(() => {
if (!contentRef || open()) return
contentRef.style.display = "none"
})
heightSpring.set(0)
}
const grow = () => {
if (!contentRef || !open()) return
const next = read()
if (Math.abs(next - heightSpring.get()) < 1) return
heightSpring.set(next)
}
onMount(() => {
if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
const offChange = heightSpring.on("change", (v) => {
if (!contentRef) return
contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
})
onCleanup(() => {
offChange()
})
if (watchDetails()) {
observer = new ResizeObserver(() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
grow()
})
})
observer.observe(bodyRef)
}
if (!open()) return
if (contentRef.style.display !== "none") {
const next = read()
heightSpring.jump(next)
contentRef.style.height = `${next}px`
return
}
let mountFrame: number | undefined = requestAnimationFrame(() => {
mountFrame = undefined
if (!open()) return
doOpen()
})
onCleanup(() => {
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
})
})
createEffect(
on(
open,
(isOpen) => {
if (!props.animated || !contentRef) return
heightAnim?.stop()
if (isOpen) {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
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)
}
if (!props.springContent || props.animate === false || !contentRef) return
if (isOpen) doOpen()
else doClose()
},
{ defer: true },
),
)
onCleanup(() => {
heightAnim?.stop()
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
observer?.disconnect()
fadeAnim?.stop()
heightSpring.destroy()
})
const handleOpenChange = (value: boolean) => {
@ -118,85 +277,34 @@ export function BasicTool(props: BasicToolProps) {
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
/>
</Collapsible.Trigger>
<Show when={props.animated && props.children && !props.hideDetails}>
<Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-animated
data-spring-content
style={{
height: initialOpen ? "auto" : "0px",
overflow: initialOpen ? "visible" : "hidden",
overflow: "hidden",
display: initialOpen ? undefined : "none",
}}
>
{props.children}
<div ref={bodyRef} data-slot="basic-tool-content-inner">
{props.children}
</div>
</div>
</Show>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
<Show when={!props.defer || ready()}>
<div data-slot="basic-tool-content-inner">{props.children}</div>
</Show>
</Collapsible.Content>
</Show>
</Collapsible>
@ -222,6 +330,60 @@ function args(input: Record<string, unknown> | undefined) {
.slice(0, 3)
}
export interface ToolCallRowProps {
variant: "row"
icon: string
trigger: TriggerTitle | JSX.Element
status?: string
animate?: boolean
onSubtitleClick?: () => void
open?: boolean
showArrow?: boolean
onOpenChange?: (value: boolean) => void
}
export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
variant: "panel"
}
export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
function ToolCallRoot(props: ToolCallProps) {
const pending = () => props.status === "pending" || props.status === "running"
if (props.variant === "row") {
return (
<Show
when={props.onOpenChange}
fallback={
<div data-component="collapsible" data-variant="normal" class="tool-collapsible">
<div data-slot="collapsible-trigger">
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
/>
</div>
</div>
}
>
{(onOpenChange) => (
<Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
<Collapsible.Trigger>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.showArrow}
/>
</Collapsible.Trigger>
</Collapsible>
)}
</Show>
)
}
const [, rest] = splitProps(props, ["variant"])
return <ToolCallPanel {...rest} />
}
export const ToolCall = ToolCallRoot
export function GenericTool(props: {
tool: string
status?: string
@ -229,7 +391,8 @@ export function GenericTool(props: {
input?: Record<string, unknown>
}) {
return (
<BasicTool
<ToolCall
variant={props.hideDetails ? "row" : "panel"}
icon="mcp"
status={props.status}
trigger={{
@ -237,7 +400,6 @@ export function GenericTool(props: {
subtitle: label(props.input),
args: args(props.input),
}}
hideDetails={props.hideDetails}
/>
)
}

View file

@ -8,14 +8,18 @@
border-radius: var(--radius-md);
overflow: visible;
&.tool-collapsible {
gap: 8px;
&.tool-collapsible [data-slot="collapsible-trigger"] {
height: 37px;
}
&.tool-collapsible [data-slot="basic-tool-content-inner"] {
padding-top: 0;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 32px;
height: 36px;
padding: 0;
align-items: center;
align-self: stretch;
@ -23,6 +27,17 @@
user-select: none;
color: var(--text-base);
> [data-component="tool-trigger"][data-arrow] {
width: auto;
max-width: 100%;
flex: 0 1 auto;
[data-slot="basic-tool-tool-trigger-content"] {
width: auto;
max-width: 100%;
}
}
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
@ -50,9 +65,6 @@
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
/* &:hover { */
/* background-color: var(--surface-base); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@ -82,16 +94,16 @@
}
[data-slot="collapsible-content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */
overflow: clip;
&[data-expanded] {
overflow: visible;
}
/* &[data-expanded] { */
/* animation: slideDown 250ms ease-out; */
/* } */
/* JS-animated content: overflow managed by animate() */
&[data-spring-content] {
overflow: clip;
}
}
&[data-variant="ghost"] {
@ -103,9 +115,6 @@
border: none;
padding: 0;
/* &:hover { */
/* color: var(--text-strong); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@ -122,21 +131,3 @@
}
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--kb-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--kb-collapsible-content-height);
}
to {
height: 0;
}
}

View file

@ -0,0 +1,311 @@
// @ts-nocheck
import { createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { ComposerIsland } from "./composer-island"
const docs = `### Overview
Composer island with a runtime/service API.
Use the same component in Storybook and app code by swapping the \`runtime\` object:
- Storybook: mocked async handlers
- App: SDK-backed handlers
### Runtime API
- \`submit\` / \`abort\`
- \`searchAt\` / \`searchSlash\`
- \`toggleAccept\`
- \`submitQuestion\` / \`rejectQuestion\`
- \`decidePermission\`
`
export default {
title: "UI/ComposerIsland",
id: "components-composer-island",
component: ComposerIsland,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
const questions = [
{
text: "Which editor do you use most often?",
options: [
{ label: "Neovim (Recommended)", description: "Fast keyboard-driven workflow" },
{ label: "VS Code", description: "Feature-rich and extensible" },
{ label: "Zed", description: "Lightweight modern editor" },
],
multiple: false,
},
{
text: "Which testing frameworks should we add?",
options: [
{ label: "Vitest", description: "Fast unit testing" },
{ label: "Playwright", description: "E2E browser testing" },
{ label: "Testing Library", description: "Component testing" },
],
multiple: true,
},
]
const at = [
{ type: "file", path: "src/auth.ts", display: "src/auth.ts", recent: true },
{ type: "file", path: "src/middleware.ts", display: "src/middleware.ts" },
{ type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" },
{ type: "agent", name: "coder", display: "coder" },
{ type: "agent", name: "reviewer", display: "reviewer" },
]
const slash = [
{
id: "help",
trigger: "help",
title: "Help",
description: "Show available commands",
type: "builtin",
source: "command",
},
{
id: "clear",
trigger: "clear",
title: "Clear",
description: "Clear conversation",
type: "builtin",
source: "command",
},
{
id: "review",
trigger: "review",
title: "Review",
description: "Review code changes",
type: "custom",
source: "skill",
},
{
id: "test",
trigger: "test",
title: "Test",
description: "Run tests",
type: "custom",
source: "mcp",
keybind: "mod+t",
},
]
const todos = [
{ content: "Read auth module", status: "completed" as const },
{ content: "Refactor token logic", status: "in_progress" as const },
{ content: "Add tests", status: "pending" as const },
{ content: "Ship changes", status: "pending" as const },
]
const contexts = [
{ id: "c1", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "JWT expiry looks brittle" },
{ id: "c2", path: "src/routes/login.ts", comment: "Rate limit should be stricter" },
]
const dragCycle = [null, "image", "@mention"] as const
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))
export const Interactive = () => {
const [mode, setMode] = createSignal<"input" | "question" | "permission">("input")
const [tab, setTab] = createSignal(0)
const [showTodos, setShowTodos] = createSignal(true)
const [todoCollapsed, setTodoCollapsed] = createSignal(false)
const [showContext, setShowContext] = createSignal(true)
const [dragIndex, setDragIndex] = createSignal(0)
const [working, setWorking] = createSignal(false)
const [accepting, setAccepting] = createSignal(false)
const [agent, setAgent] = createSignal("ask")
const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex")
const [variant, setVariant] = createSignal("default")
const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] })
const q = createMemo(() => questions[tab()] ?? questions[0])
const drag = createMemo(() => dragCycle[dragIndex()] ?? null)
const runtime = {
submit: async (input) => {
setWorking(true)
await wait(600)
setWorking(false)
console.log("submit", input)
},
abort: () => {
setWorking(false)
console.log("abort")
},
toggleAccept: () => {
setAccepting((value) => !value)
},
runSlash: (cmd) => {
if (cmd.type !== "builtin") return false
console.log("slash:run", cmd.id)
return true
},
historyRead: (mode) => history()[mode],
historyWrite: (mode, list) => {
setHistory((prev) => ({ ...prev, [mode]: list }))
},
searchAt: async (filter: string) => {
await wait(120)
return at.filter((item) => {
const value = item.type === "agent" ? item.name : item.path
return value.toLowerCase().includes(filter.toLowerCase())
})
},
searchSlash: async (filter: string) => {
await wait(120)
return slash.filter((item) => item.trigger.toLowerCase().includes(filter.toLowerCase()))
},
decidePermission: async (response) => {
console.log("permission", response)
await wait(300)
setMode("input")
},
submitQuestion: async (answers) => {
console.log("question answers", answers)
await wait(300)
setMode("input")
setTab(0)
},
rejectQuestion: async () => {
await wait(150)
setMode("input")
setTab(0)
},
openContext: (item) => {
console.log("open context", item)
},
removeContext: (item) => {
console.log("remove context", item)
},
}
onMount(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return
if (target instanceof HTMLElement && target.isContentEditable) return
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
let hit = false
if (event.key === "1") {
setMode("input")
hit = true
}
if (event.key === "2") {
setMode("question")
hit = true
}
if (event.key === "3") {
setMode("permission")
hit = true
}
if (event.key === "4") {
setTodoCollapsed((value) => !value)
hit = true
}
if (event.key === "5") {
setShowTodos((value) => !value)
hit = true
}
if (event.key === "6") {
setShowContext((value) => !value)
hit = true
}
if (event.key === "7") {
setDragIndex((value) => (value + 1) % dragCycle.length)
hit = true
}
if (!hit) return
event.preventDefault()
event.stopPropagation()
}
window.addEventListener("keydown", onKey)
onCleanup(() => window.removeEventListener("keydown", onKey))
})
return (
<div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "20px" }}>
<div
style={{
position: "fixed",
top: "16px",
left: "16px",
display: "flex",
gap: "8px",
"font-family": "monospace",
"font-size": "12px",
color: "rgba(255, 255, 255, 0.7)",
}}
>
<button onClick={() => setMode("input")}>Ctrl+1 Input</button>
<button onClick={() => setMode("question")}>Ctrl+2 Question</button>
<button onClick={() => setMode("permission")}>Ctrl+3 Permission</button>
<button onClick={() => setTodoCollapsed((value) => !value)}>Ctrl+4 Collapse</button>
<button onClick={() => setShowTodos((value) => !value)}>Ctrl+5 Todos</button>
<button onClick={() => setShowContext((value) => !value)}>Ctrl+6 Context</button>
<button onClick={() => setDragIndex((value) => (value + 1) % dragCycle.length)}>Ctrl+7 Drag</button>
</div>
<ComposerIsland
mode={mode()}
runtime={runtime}
agentOptions={["ask", "coder", "reviewer"]}
modelOptions={[
"OpenAI/GPT-5.2",
"OpenAI/GPT-5.3 Codex",
"OpenAI/GPT-5.3 Codex Spark",
"Anthropic/Claude Sonnet 4",
"Anthropic/Claude Haiku 4.5",
"Google/Gemini 2.5 Pro",
]}
variantOptions={["default", "fast", "quality"]}
agentCurrent={agent()}
modelCurrent={model()}
variantCurrent={variant()}
onAgentSelect={setAgent}
onModelSelect={setModel}
onVariantSelect={setVariant}
agentKeybind="mod+."
modelKeybind="mod+'"
variantKeybind="shift+mod+d"
working={working()}
accepting={accepting()}
placeholder="Ask anything..."
questionText={q().text}
questionOptions={q().options}
questionMultiple={q().multiple}
questionIndex={tab()}
questionTotal={questions.length}
onQuestionBack={() => setTab((value) => Math.max(0, value - 1))}
onQuestionNext={() => setTab((value) => Math.min(questions.length - 1, value + 1))}
onQuestionJump={setTab}
onQuestionDismiss={() => {
setMode("input")
setTab(0)
}}
agentName="Ask"
modelName="GPT-4"
variant="default"
todos={todos}
showTodos={showTodos()}
todoCollapsed={todoCollapsed()}
onTodoCollapseChange={setTodoCollapsed}
contextItems={showContext() ? contexts : []}
forceDragType={drag()}
permissionDescription="Modify files, including edits, writes, patches, and multi-edits"
permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]}
/>
</div>
)
}

View file

@ -0,0 +1,28 @@
import type { Component } from "solid-js"
import { NewComposer } from "./new-composer"
import type { NewComposerProps } from "./new-composer"
/**
* Composer Island is now backed by `NewComposer`.
*
* This keeps the existing import path (`@opencode-ai/ui/composer-island`) stable
* while using the split/runtime-ready architecture under the hood.
*/
export type ComposerIslandProps = NewComposerProps
export const ComposerIsland: Component<ComposerIslandProps> = (props) => {
return <NewComposer {...props} />
}
export type {
AtOption,
ComposerMode,
ComposerPart,
ComposerRuntime,
ComposerSource,
ComposerSubmit,
ContextItem,
ImageAttachment,
SlashCommand,
TodoItem,
} from "./new-composer/types"

View file

@ -0,0 +1,290 @@
import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount } from "solid-js"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/util/path"
import { useI18n } from "../context/i18n"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import { ToolCall } from "./basic-tool"
import { ToolStatusTitle } from "./tool-status-title"
import { AnimatedCountList } from "./tool-count-summary"
import { RollingResults } from "./rolling-results"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
COLLAPSIBLE_SPRING,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { useSpring } from "./motion-spring"
import { busy } from "./tool-utils"
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
const state = part.state
const title = "title" in state ? (state.title as string | undefined) : undefined
const input = state.input
if (part.tool === "read") {
const path = input?.filePath as string | undefined
return { action: "Read", detail: title || (path ? getFilename(path) : "") }
}
if (part.tool === "grep") {
const pattern = input?.pattern as string | undefined
return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
}
if (part.tool === "glob") {
const pattern = input?.pattern as string | undefined
return { action: "Find", detail: title || (pattern ?? "") }
}
if (part.tool === "list") {
const path = input?.path as string | undefined
return { action: "List", detail: title || (path ? getFilename(path) : "") }
}
return { action: part.tool, detail: title || "" }
}
function contextToolSummary(parts: ToolPart[]) {
let read = 0
let search = 0
let list = 0
for (const part of parts) {
if (part.tool === "read") read++
else if (part.tool === "glob" || part.tool === "grep") search++
else if (part.tool === "list") list++
}
return { read, search, list }
}
export function ContextToolGroupHeader(props: {
parts: ToolPart[]
pending: boolean
open: boolean
onOpenChange: (value: boolean) => void
}) {
const i18n = useI18n()
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<ToolCall
variant="row"
icon="magnifying-glass-menu"
open={!props.pending && props.open}
showArrow={!props.pending}
onOpenChange={(v) => {
if (!props.pending) props.onOpenChange(v)
}}
trigger={
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={props.pending}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{
key: "read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
/>
</span>
</span>
</div>
}
/>
)
}
export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
let heightAnim: AnimationPlaybackControls | undefined
let fadeAnim: AnimationPlaybackControls | undefined
const FADE = 12
const updateMask = () => {
if (!scrollRef) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
const overflow = scrollHeight - clientHeight
if (overflow <= 1) {
scrollRef.style.maskImage = ""
scrollRef.style.webkitMaskImage = ""
return
}
const top = scrollTop > 1
const bottom = scrollTop < overflow - 1
const mask =
top && bottom
? `linear-gradient(to bottom, transparent 0, black ${FADE}px, black calc(100% - ${FADE}px), transparent 100%)`
: top
? `linear-gradient(to bottom, transparent 0, black ${FADE}px)`
: bottom
? `linear-gradient(to bottom, black calc(100% - ${FADE}px), transparent 100%)`
: ""
scrollRef.style.maskImage = mask
scrollRef.style.webkitMaskImage = mask
}
createEffect(
on(
() => props.expanded,
(isOpen) => {
if (!contentRef || !bodyRef) return
heightAnim?.stop()
fadeAnim?.stop()
if (isOpen) {
contentRef.style.display = ""
bodyRef.style.opacity = "0"
bodyRef.style.filter = "blur(2px)"
const h = bodyRef.getBoundingClientRect().height
heightAnim = animate(contentRef, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
fadeAnim = animate(bodyRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
heightAnim.finished
.catch(() => {})
.then(() => {
if (!contentRef || !props.expanded) return
contentRef.style.height = "auto"
updateMask()
})
} else {
const h = contentRef.getBoundingClientRect().height
heightAnim = animate(contentRef, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
fadeAnim = animate(bodyRef, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
heightAnim.finished
.catch(() => {})
.then(() => {
if (!contentRef || props.expanded) return
contentRef.style.display = "none"
})
}
},
{ defer: true },
),
)
onCleanup(() => {
heightAnim?.stop()
fadeAnim?.stop()
})
return (
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
<div ref={bodyRef}>
<div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
<For each={props.parts}>
{(part) => {
const label = createMemo(() => contextToolLabel(part))
return (
<div data-component="context-tool-expanded-row">
<span data-slot="context-tool-expanded-action">{label().action}</span>
<span data-slot="context-tool-expanded-detail">{label().detail}</span>
</div>
)
}}
</For>
</div>
</div>
</div>
)
}
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
onMount(() => setMounted(true))
const reduce = prefersReducedMotion
const show = () => mounted() && props.pending
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
return (
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
<RollingResults
items={props.parts}
rows={5}
rowHeight={22}
rowGap={0}
open={props.pending}
animate
getKey={(part) => part.callID || part.id}
render={(part) => {
const label = createMemo(() => contextToolLabel(part))
const k = part.callID || part.id
return (
<div data-component="context-tool-rolling-row">
<span data-slot="context-tool-rolling-action">{label().action}</span>
{(() => {
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
createEffect(() => {
const el = detailRef()
const d = label().detail
if (!el || !d) return
if (wiped.has(k)) return
wiped.add(k)
if (reduce()) return
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
animate(
el,
{
opacity: [0, 1],
filter: ["blur(2px)", "blur(0px)"],
transform: ["translateX(-0.06em)", "translateX(0)"],
maskPosition: "0% 0%",
},
GROW_SPRING,
).finished.then(() => {
if (!el) return
clearFadeStyles(el)
clearMaskStyles(el)
})
})
return (
<span
ref={setDetailRef}
data-slot="context-tool-rolling-detail"
style={{ display: label().detail ? undefined : "none" }}
>
{label().detail}
</span>
)
})()}
</div>
)
}}
/>
</div>
)
}

View file

@ -0,0 +1,426 @@
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
export interface GrowBoxProps {
children: JSX.Element
/** Enable animation. When false, content shows immediately at full height. */
animate?: boolean
/** Animate height from 0 to content height. Default: true. */
grow?: boolean
/** Keep watching body size and animate subsequent height changes. Default: false. */
watch?: boolean
/** Fade in body content (opacity + blur). Default: true. */
fade?: boolean
/** Top padding in px on the body wrapper. Default: 0. */
gap?: number
/** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
autoHeight?: boolean
/** Controlled visibility for animating open/close without unmounting children. */
open?: boolean
/** Animate controlled open/close changes after mount. Default: true. */
animateToggle?: boolean
/** data-slot attribute on the root div. */
slot?: string
/** CSS class on the root div. */
class?: string
/** Override mount and resize spring config. Default: GROW_SPRING. */
spring?: SpringConfig
/** Override controlled open/close spring config. Default: spring. */
toggleSpring?: SpringConfig
/** Show a temporary bottom edge fade while height animation is running. */
edge?: boolean
/** Edge fade height in px. Default: 20. */
edgeHeight?: number
/** Edge fade opacity (0-1). Default: 1. */
edgeOpacity?: number
/** Delay before edge fades out after height settles. Default: 320. */
edgeIdle?: number
/** Edge fade-out duration in seconds. Default: 0.24. */
edgeFade?: number
/** Edge fade-in duration in seconds. Default: 0.2. */
edgeRise?: number
}
/**
* Wraps children in a container that animates from zero height on mount.
*
* Includes a ResizeObserver so content changes after mount are also spring-animated.
* Used for timeline turns, assistant part groups, and user messages.
*/
export function GrowBox(props: GrowBoxProps) {
const reduce = prefersReducedMotion
const spring = () => props.spring ?? GROW_SPRING
const toggleSpring = () => props.toggleSpring ?? spring()
let mode: "mount" | "toggle" = "mount"
let root: HTMLDivElement | undefined
let body: HTMLDivElement | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let edgeRef: HTMLDivElement | undefined
let edgeAnim: AnimationPlaybackControls | undefined
let edgeTimer: ReturnType<typeof setTimeout> | undefined
let edgeOn = false
let mountFrame: number | undefined
let resizeFrame: number | undefined
let observer: ResizeObserver | undefined
let springTarget = -1
const height = tunableSpringValue<number>(0, {
type: "spring",
get visualDuration() {
return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
},
get bounce() {
return (mode === "toggle" ? toggleSpring() : spring()).bounce
},
})
const gap = () => Math.max(0, props.gap ?? 0)
const grow = () => props.grow !== false
const watch = () => props.watch === true
const open = () => props.open !== false
const animateToggle = () => props.animateToggle !== false
const edge = () => props.edge === true
const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
const animated = () => props.animate !== false && !reduce()
const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
const stopEdgeTimer = () => {
if (edgeTimer === undefined) return
clearTimeout(edgeTimer)
edgeTimer = undefined
}
const hideEdge = (instant = false) => {
stopEdgeTimer()
if (!edgeRef) {
edgeOn = false
return
}
edgeAnim?.stop()
edgeAnim = undefined
if (instant || reduce()) {
edgeRef.style.opacity = "0"
edgeOn = false
return
}
if (!edgeOn) {
edgeRef.style.opacity = "0"
return
}
const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
edgeAnim = current
current.finished
.catch(() => {})
.finally(() => {
if (edgeAnim !== current) return
edgeAnim = undefined
if (!edgeRef) return
edgeRef.style.opacity = "0"
edgeOn = false
})
}
const showEdge = () => {
stopEdgeTimer()
if (!edgeRef) return
if (reduce()) {
edgeRef.style.opacity = `${edgeOpacity()}`
edgeOn = true
return
}
if (edgeOn && edgeAnim === undefined) {
edgeRef.style.opacity = `${edgeOpacity()}`
return
}
edgeAnim?.stop()
edgeAnim = undefined
if (!edgeOn) edgeRef.style.opacity = "0"
const current = animate(
edgeRef,
{ opacity: edgeOpacity() },
{ type: "spring", visualDuration: edgeRise(), bounce: 0 },
)
edgeAnim = current
edgeOn = true
current.finished
.catch(() => {})
.finally(() => {
if (edgeAnim !== current) return
edgeAnim = undefined
if (!edgeRef) return
edgeRef.style.opacity = `${edgeOpacity()}`
})
}
const queueEdgeHide = () => {
stopEdgeTimer()
if (!edgeOn) return
if (edgeIdle() <= 0) {
hideEdge()
return
}
edgeTimer = setTimeout(() => {
edgeTimer = undefined
hideEdge()
}, edgeIdle())
}
const hideBody = () => {
if (!body) return
body.style.opacity = "0"
body.style.filter = "blur(2px)"
}
const clearBody = () => {
if (!body) return
body.style.opacity = ""
body.style.filter = ""
}
const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
if (props.fade === false || !body) return
if (reduce()) {
clearBody()
return
}
hideBody()
fadeAnim?.stop()
fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
fadeAnim.finished.then(() => {
if (!body || !open()) return
clearBody()
})
}
const setInstant = (visible: boolean) => {
const next = visible ? targetHeight() : 0
springTarget = next
height.jump(next)
root!.style.height = visible ? "" : "0px"
root!.style.overflow = visible ? "" : "clip"
hideEdge(true)
if (visible || props.fade === false) clearBody()
else hideBody()
}
const currentHeight = () => {
if (!root) return 0
const v = root.style.height
if (v && v !== "auto") {
const n = Number.parseFloat(v)
if (!Number.isNaN(n)) return n
}
return Math.max(0, root.getBoundingClientRect().height)
}
const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
if (!root || !open()) return
const next = targetHeight()
if (reduce()) {
springTarget = next
height.jump(next)
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
root.style.height = "auto"
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
if (next === springTarget) return
const prev = currentHeight()
if (Math.abs(next - prev) < 1) {
springTarget = next
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
}
return
}
root.style.overflow = "clip"
springTarget = next
mode = nextMode
height.set(next)
}
onMount(() => {
if (!root || !body) return
const offChange = height.on("change", (next) => {
if (!root) return
root.style.height = `${Math.max(0, next)}px`
})
const offStart = height.on("animationStart", () => {
if (!root) return
root.style.overflow = "clip"
root.style.willChange = "height"
root.style.contain = "layout style"
if (edgeReady()) showEdge()
})
const offComplete = height.on("animationComplete", () => {
if (!root) return
root.style.willChange = ""
root.style.contain = ""
if (!open()) {
springTarget = 0
root.style.height = "0px"
root.style.overflow = "clip"
return
}
const next = targetHeight()
springTarget = next
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
if (edgeReady()) queueEdgeHide()
return
}
root.style.height = "auto"
root.style.overflow = "visible"
if (edgeReady()) queueEdgeHide()
})
onCleanup(() => {
offComplete()
offStart()
offChange()
})
if (!animated()) {
setInstant(open())
return
}
if (props.fade !== false) hideBody()
hideEdge(true)
if (!open()) {
root.style.height = "0px"
root.style.overflow = "clip"
} else {
if (grow()) {
root.style.height = "0px"
root.style.overflow = "clip"
} else {
root.style.height = "auto"
root.style.overflow = "visible"
}
mountFrame = requestAnimationFrame(() => {
mountFrame = undefined
fadeBodyIn("mount")
if (grow()) setHeight("mount")
})
}
if (watch()) {
observer = new ResizeObserver(() => {
if (!open()) return
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
setHeight("mount")
})
})
observer.observe(body)
}
})
createEffect(
on(
() => props.open,
(value) => {
if (value === undefined) return
if (!root || !body) return
if (!animateToggle() || reduce()) {
setInstant(value)
return
}
fadeAnim?.stop()
if (!value) hideEdge(true)
if (!value) {
const next = currentHeight()
if (Math.abs(next - height.get()) >= 1) {
springTarget = next
height.jump(next)
root.style.height = `${next}px`
}
if (props.fade !== false) {
fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
}
root.style.overflow = "clip"
springTarget = 0
mode = "toggle"
height.set(0)
return
}
fadeBodyIn("toggle")
setHeight("toggle")
},
{ defer: true },
),
)
createEffect(() => {
if (!edgeRef) return
edgeRef.style.height = `${edgeHeight()}px`
if (!animated() || !open() || edgeHeight() <= 0) {
hideEdge(true)
return
}
if (edge()) return
hideEdge()
})
createEffect(() => {
if (!root || !body) return
if (!reduce()) return
fadeAnim?.stop()
edgeAnim?.stop()
setInstant(open())
})
onCleanup(() => {
stopEdgeTimer()
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
observer?.disconnect()
height.destroy()
fadeAnim?.stop()
edgeAnim?.stop()
edgeAnim = undefined
edgeOn = false
})
return (
<div
ref={root}
data-slot={props.slot}
class={props.class}
style={{ transform: "translateZ(0)", position: "relative" }}
>
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
{props.children}
</div>
<div
ref={edgeRef}
data-slot="grow-box-edge"
style={{
position: "absolute",
left: "0",
right: "0",
bottom: "0",
height: `${edgeHeight()}px`,
opacity: 0,
"pointer-events": "none",
background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
}}
/>
</div>
)
}

View file

@ -44,19 +44,6 @@ function sanitize(html: string) {
return DOMPurify.sanitize(html, config)
}
function escape(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function fallback(markdown: string) {
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
}
type CopyLabels = {
copy: string
copied: string
@ -250,7 +237,7 @@ export function Markdown(
const [html] = createResource(
() => local.text,
async (markdown) => {
if (isServer) return fallback(markdown)
if (isServer) return ""
const hash = checksum(markdown)
const key = local.cacheKey ?? hash
@ -268,7 +255,7 @@ export function Markdown(
if (key && hash) touch(key, { hash, html: safe })
return safe
},
{ initialValue: isServer ? fallback(local.text) : "" },
{ initialValue: "" },
)
let copySetupTimer: ReturnType<typeof setTimeout> | undefined

View file

@ -27,6 +27,14 @@
color: var(--text-weak);
}
[data-slot="user-message-inner"] {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
gap: 4px;
}
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@ -35,6 +43,7 @@
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
margin-bottom: 4px;
}
[data-slot="user-message-attachment"] {
@ -134,7 +143,7 @@
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
margin-top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
@ -144,7 +153,6 @@
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
@ -187,56 +195,21 @@
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
color: var(--text-strong);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
}
[data-component="text-part"] {
width: 100%;
margin-top: 24px;
margin-top: 0;
padding-bottom: 8px;
position: relative;
[data-slot="text-part-body"] {
margin-top: 0;
}
[data-slot="text-part-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
[data-slot="text-part-meta"] {
user-select: none;
}
[data-slot="text-part-copy-wrapper"][data-interrupted] {
[data-slot="text-part-turn-summary"] {
width: 100%;
justify-content: flex-end;
gap: 12px;
}
&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
min-width: 0;
}
[data-component="markdown"] {
@ -278,7 +251,6 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
@ -372,13 +344,16 @@
height: auto;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
&::-webkit-scrollbar {
display: none;
}
[data-component="markdown"] {
overflow: visible;
}
@ -448,7 +423,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 8px;
width: 100%;
@ -461,7 +436,8 @@
}
[data-slot="message-part-title"] {
flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@ -493,40 +469,45 @@
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-meta-line"],
.message-part-meta-line {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: var(--font-weight-regular);
[data-component="diff-changes"] {
flex-shrink: 0;
gap: 6px;
}
}
.message-part-meta-line.soft {
[data-slot="message-part-title-filename"] {
color: var(--text-base);
}
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
font-weight: var(--font-weight-regular);
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
[data-slot="message-part-directory-inline"] {
color: var(--text-weak);
min-width: 0;
max-width: min(48vw, 36ch);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-actions"] {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
}
[data-component="edit-content"] {
@ -617,6 +598,17 @@
}
}
[data-slot="webfetch-meta"] {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
[data-component="tool-action"] {
flex-shrink: 0;
}
}
[data-component="todos"] {
padding: 10px 0 24px 0;
display: flex;
@ -639,7 +631,6 @@
}
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@ -647,28 +638,237 @@
gap: 0px;
cursor: pointer;
&[data-pending] {
cursor: default;
}
[data-slot="context-tool-group-title"] {
flex-shrink: 1;
min-width: 0;
}
}
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
width: auto;
flex: 0 1 auto;
[data-slot="basic-tool-tool-info"] {
flex: 0 1 auto;
}
}
[data-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
[data-component="context-tool-step"] {
width: 100%;
min-width: 0;
padding-left: 12px;
}
[data-component="context-tool-expanded-list"] {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0 4px 12px;
max-height: 200px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
&::-webkit-scrollbar {
display: none;
}
}
[data-component="context-tool-expanded-row"] {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 22px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
[data-slot="context-tool-expanded-action"] {
flex-shrink: 0;
font-size: var(--font-size-base);
font-weight: 500;
color: var(--text-base);
}
[data-slot="context-tool-expanded-detail"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-base);
color: var(--text-base);
opacity: 0.75;
}
}
[data-component="context-tool-rolling-row"] {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
padding-left: 12px;
[data-slot="context-tool-rolling-action"] {
flex-shrink: 0;
font-size: var(--font-size-base);
font-weight: 500;
color: var(--text-base);
}
[data-slot="context-tool-rolling-detail"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-base);
color: var(--text-weak);
}
}
[data-component="shell-rolling-results"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
[data-slot="shell-rolling-header-clip"] {
&:hover [data-slot="shell-rolling-actions"] {
opacity: 1;
}
&[data-clickable="true"] {
cursor: pointer;
}
}
[data-slot="shell-rolling-header"] {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
height: 37px;
box-sizing: border-box;
}
[data-slot="shell-rolling-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);
}
[data-slot="shell-rolling-subtitle"] {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-large);
color: var(--text-weak);
}
[data-slot="shell-rolling-actions"] {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.shell-rolling-copy {
border: none !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
[data-slot="icon-svg"] {
color: var(--icon-weaker);
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
border-radius: var(--radius-sm);
[data-slot="icon-svg"] {
color: var(--icon-base);
}
}
}
[data-slot="shell-rolling-arrow"] {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-weaker);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
[data-slot="shell-rolling-arrow"][data-open="true"] {
transform: rotate(0deg);
}
}
[data-component="shell-rolling-output"] {
width: 100%;
min-width: 0;
}
[data-component="shell-rolling-command"],
[data-component="shell-rolling-row"] {
display: inline-flex;
align-items: center;
width: 100%;
min-width: 0;
overflow: hidden;
white-space: pre;
padding-left: 12px;
}
[data-slot="shell-rolling-text"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
color: var(--text-base);
}
[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
color: var(--text-weaker);
}
[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
color: var(--text-weak);
}
[data-component="diagnostics"] {
display: flex;
flex-direction: column;
@ -729,6 +929,30 @@
width: 100%;
}
[data-slot="assistant-part-grow"] {
width: 100%;
min-width: 0;
overflow: visible;
}
[data-component="tool-part-wrapper"][data-tool="bash"] {
[data-component="tool-trigger"] {
width: auto;
max-width: 100%;
}
[data-slot="basic-tool-tool-info-main"] {
align-items: center;
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-subtitle"] {
display: inline-flex;
align-items: center;
line-height: var(--line-height-large);
}
}
[data-component="dock-prompt"][data-kind="permission"] {
position: relative;
display: flex;
@ -1187,8 +1411,7 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
height: 40px;
padding-bottom: 8px;
height: 37px;
background-color: var(--background-stronger);
}
}
@ -1199,11 +1422,12 @@
}
[data-slot="apply-patch-trigger-content"] {
display: flex;
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
justify-content: flex-start;
max-width: 100%;
min-width: 0;
gap: 8px;
}
[data-slot="apply-patch-file-info"] {
@ -1237,9 +1461,9 @@
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
gap: 8px;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
}
[data-slot="apply-patch-change"] {
@ -1279,10 +1503,11 @@
}
[data-component="tool-loaded-file"] {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 4px 28px;
padding: 4px 0 4px 12px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
@ -1293,4 +1518,11 @@
flex-shrink: 0;
color: var(--icon-weak);
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.visualDuration === b?.visualDuration &&
a?.bounce === b?.bounce &&
@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options)
const reduce = prefersReducedMotion
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
let stop = attachSpring(spring, source, config)
let off = spring.on("change", (next: number) => setValue(next))
let reduced = reduce()
let stop = reduced ? () => {} : attachSpring(spring, source, config)
let off = spring.on("change", (next) => setValue(next))
createEffect(() => {
source.set(target())
const next = target()
if (reduced) {
source.set(next)
spring.set(next)
setValue(next)
return
}
source.set(next)
})
createEffect(() => {
if (!options) return
const next = read()
if (eq(config, next)) return
const skip = reduce()
if (eq(config, next) && reduced === skip) return
config = next
reduced = skip
stop()
stop = attachSpring(spring, source, next)
stop = skip ? () => {} : attachSpring(spring, source, next)
if (skip) {
const value = target()
source.set(value)
spring.set(value)
setValue(value)
return
}
setValue(spring.get())
})

View file

@ -0,0 +1,77 @@
import { followValue } from "motion"
import type { MotionValue } from "motion"
export { animate, springValue } from "motion"
export type { AnimationPlaybackControls } from "motion"
/**
* Like `springValue` but preserves getters on the config object.
* `springValue` spreads config at creation, snapshotting getter values.
* This passes the config through to `followValue` intact, so getters
* on `visualDuration` etc. fire on every `.set()` call.
*/
export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
return followValue(initial, config as any)
}
let _growDuration = 0.5
let _collapsibleDuration = 0.3
export const GROW_SPRING = {
type: "spring" as const,
get visualDuration() {
return _growDuration
},
bounce: 0,
}
export const COLLAPSIBLE_SPRING = {
type: "spring" as const,
get visualDuration() {
return _collapsibleDuration
},
bounce: 0,
}
export const setGrowDuration = (v: number) => {
_growDuration = v
}
export const setCollapsibleDuration = (v: number) => {
_collapsibleDuration = v
}
export const getGrowDuration = () => _growDuration
export const getCollapsibleDuration = () => _collapsibleDuration
export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
export const FAST_SPRING = {
type: "spring" as const,
visualDuration: 0.35,
bounce: 0,
}
export const GLOW_SPRING = {
type: "spring" as const,
visualDuration: 0.4,
bounce: 0.15,
}
export const WIPE_MASK =
"linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
export const clearMaskStyles = (el: HTMLElement) => {
el.style.maskImage = ""
el.style.webkitMaskImage = ""
el.style.maskSize = ""
el.style.webkitMaskSize = ""
el.style.maskRepeat = ""
el.style.webkitMaskRepeat = ""
el.style.maskPosition = ""
el.style.webkitMaskPosition = ""
}
export const clearFadeStyles = (el: HTMLElement) => {
el.style.opacity = ""
el.style.filter = ""
el.style.transform = ""
}

View file

@ -0,0 +1,451 @@
// @ts-nocheck
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { NewComposer } from "./new-composer"
const docs = `### Overview
Runtime-ready composer story with visible canvas controls + Storybook controls.
### Canvas controls
- Input / Question / Permission mode
- Todos, collapse, context, drag overlay
- Keyboard: Ctrl+1..7 toggles, Ctrl+8/9 answer count (Q1), Ctrl+[/] answer count (Q2), Ctrl+;/' answer count (Q3), Ctrl+0/- todo count
### Storybook controls
- mode
- working
- accepting
- showTodos
- todoCollapsed
- forceDragType
- answerCount1
- answerCount2
- answerCount3
- todoCount
`
export default {
title: "UI/NewComposer",
id: "components-new-composer",
component: NewComposer,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
mode: {
control: "select",
options: ["input", "question", "permission"],
},
working: {
control: "boolean",
},
accepting: {
control: "boolean",
},
showTodos: {
control: "boolean",
},
todoCollapsed: {
control: "boolean",
},
forceDragType: {
control: "select",
options: [null, "image", "@mention"],
},
answerCount1: {
control: { type: "range", min: 1, max: 6, step: 1 },
},
answerCount2: {
control: { type: "range", min: 1, max: 6, step: 1 },
},
answerCount3: {
control: { type: "range", min: 1, max: 6, step: 1 },
},
todoCount: {
control: { type: "range", min: 0, max: 8, step: 1 },
},
},
}
const questions = [
{
text: "Which editor do you use most often?",
options: [
{ label: "Neovim", description: "Fast keyboard-driven workflow" },
{ label: "VS Code", description: "Feature-rich and extensible" },
{ label: "Zed", description: "Lightweight modern editor" },
{ label: "JetBrains IDE", description: "Deep language tooling" },
{ label: "Sublime Text", description: "Fast, lightweight setup" },
{ label: "Helix", description: "Modal editor with tree-sitter" },
],
multiple: false,
},
{
text: "Which testing frameworks should we add?",
options: [
{ label: "Vitest", description: "Fast unit testing" },
{ label: "Playwright", description: "E2E browser testing" },
{ label: "Testing Library", description: "Component testing" },
{ label: "Cypress", description: "Browser integration tests" },
{ label: "Jest", description: "Legacy compatibility" },
{ label: "Storybook Tests", description: "Interaction smoke tests" },
],
multiple: true,
},
{
text: "How strict should linting be?",
options: [
{ label: "Minimal", description: "Keep only important rules" },
{ label: "Balanced", description: "Recommended defaults" },
{ label: "Strict", description: "Catch everything possible" },
{ label: "Very strict", description: "Treat warnings as errors" },
{ label: "Preset by package", description: "Different rules per surface" },
{ label: "Experimental", description: "Try strict mode for one sprint" },
],
multiple: false,
},
]
const todos = [
{ content: "Read auth module", status: "completed" },
{ content: "Refactor token logic", status: "in_progress" },
{ content: "Add tests", status: "pending" },
{ content: "Ship changes", status: "pending" },
{ content: "Check telemetry events", status: "pending" },
{ content: "Validate markdown rendering", status: "pending" },
{ content: "Run regression suite", status: "pending" },
{ content: "Update release notes", status: "pending" },
]
const ctx = [
{ id: "a", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "Check token refresh" },
{ id: "b", path: "src/routes/login.ts", comment: "Review edge cases" },
]
const dragModes = [null, "image", "@mention"] as const
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))
const panel = {
position: "fixed",
top: "14px",
left: "14px",
display: "flex",
"flex-wrap": "wrap",
gap: "8px",
padding: "8px",
"border-radius": "10px",
background: "rgba(0, 0, 0, 0.55)",
border: "1px solid rgba(255, 255, 255, 0.12)",
color: "#fff",
"font-family": "monospace",
"font-size": "11px",
"z-index": 50,
} as const
const btn = (on: boolean) =>
({
padding: "4px 8px",
"border-radius": "8px",
border: "1px solid rgba(255, 255, 255, 0.2)",
background: on ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.07)",
color: "#fff",
cursor: "pointer",
}) as const
export const Interactive = {
args: {
mode: "input",
working: false,
accepting: false,
showTodos: true,
todoCollapsed: false,
forceDragType: null,
answerCount1: 4,
answerCount2: 3,
answerCount3: 5,
todoCount: 4,
},
render: (args) => {
const limit = (value: number, i: number) => Math.max(1, Math.min(questions[i].options.length, value))
const [mode, setMode] = createSignal(args.mode ?? "input")
const [tab, setTab] = createSignal(0)
const [work, setWork] = createSignal(!!args.working)
const [accept, setAccept] = createSignal(!!args.accepting)
const [showTodos, setShowTodos] = createSignal(args.showTodos ?? true)
const [todoCollapsed, setTodoCollapsed] = createSignal(args.todoCollapsed ?? false)
const [drag, setDrag] = createSignal(args.forceDragType ?? null)
const [a1, setA1] = createSignal(limit(args.answerCount1 ?? 4, 0))
const [a2, setA2] = createSignal(limit(args.answerCount2 ?? 3, 1))
const [a3, setA3] = createSignal(limit(args.answerCount3 ?? 5, 2))
const [tCount, setTCount] = createSignal(Math.max(0, Math.min(todos.length, args.todoCount ?? 4)))
const [showCtx, setShowCtx] = createSignal(true)
const [agent, setAgent] = createSignal("ask")
const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex")
const [variant, setVariant] = createSignal("default")
const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] })
createEffect(() => setMode(args.mode ?? "input"))
createEffect(() => setWork(!!args.working))
createEffect(() => setAccept(!!args.accepting))
createEffect(() => setShowTodos(args.showTodos ?? true))
createEffect(() => setTodoCollapsed(args.todoCollapsed ?? false))
createEffect(() => setDrag(args.forceDragType ?? null))
createEffect(() => setA1(limit(args.answerCount1 ?? 4, 0)))
createEffect(() => setA2(limit(args.answerCount2 ?? 3, 1)))
createEffect(() => setA3(limit(args.answerCount3 ?? 5, 2)))
createEffect(() => setTCount(Math.max(0, Math.min(todos.length, args.todoCount ?? 4))))
const qList = createMemo(() => [
{ ...questions[0], options: questions[0].options.slice(0, a1()) },
{ ...questions[1], options: questions[1].options.slice(0, a2()) },
{ ...questions[2], options: questions[2].options.slice(0, a3()) },
])
const tList = createMemo(() => todos.slice(0, tCount()))
createEffect(() => {
if (tab() <= qList().length - 1) return
setTab(qList().length - 1)
})
const q = createMemo(() => qList()[tab()] ?? qList()[0])
const runtime = {
submit: async (input) => {
setWork(true)
await wait(550)
setWork(false)
console.log("submit", input)
},
abort: () => {
setWork(false)
},
toggleAccept: () => setAccept((v) => !v),
runSlash: (cmd) => {
if (cmd.type !== "builtin") return false
console.log("slash:run", cmd.id)
return true
},
historyRead: (mode) => history()[mode],
historyWrite: (mode, list) => {
setHistory((prev) => ({ ...prev, [mode]: list }))
},
decidePermission: async (response) => {
console.log("permission", response)
await wait(200)
setMode("input")
},
submitQuestion: async (answers) => {
console.log("question", answers)
await wait(250)
setMode("input")
setTab(0)
},
rejectQuestion: async () => {
await wait(150)
setMode("input")
setTab(0)
},
openContext: (item) => console.log("open context", item),
removeContext: (item) => console.log("remove context", item),
}
const cycle = () => {
const i = dragModes.findIndex((v) => v === drag())
const n = (i + 1) % dragModes.length
setDrag(dragModes[n])
}
onMount(() => {
const onKey = (event: KeyboardEvent) => {
const target = event.target
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return
if (target instanceof HTMLElement && target.isContentEditable) return
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
let hit = false
if (event.key === "1") {
setMode("input")
hit = true
}
if (event.key === "2") {
setMode("question")
hit = true
}
if (event.key === "3") {
setMode("permission")
hit = true
}
if (event.key === "4") {
setTodoCollapsed((v) => !v)
hit = true
}
if (event.key === "5") {
setShowTodos((v) => !v)
hit = true
}
if (event.key === "6") {
setShowCtx((v) => !v)
hit = true
}
if (event.key === "7") {
cycle()
hit = true
}
if (event.key === "8") {
setA1((v) => limit(v - 1, 0))
hit = true
}
if (event.key === "9") {
setA1((v) => limit(v + 1, 0))
hit = true
}
if (event.key === "[") {
setA2((v) => limit(v - 1, 1))
hit = true
}
if (event.key === "]") {
setA2((v) => limit(v + 1, 1))
hit = true
}
if (event.key === ";") {
setA3((v) => limit(v - 1, 2))
hit = true
}
if (event.key === "'") {
setA3((v) => limit(v + 1, 2))
hit = true
}
if (event.key === "0") {
setTCount((v) => Math.max(0, v - 1))
hit = true
}
if (event.key === "-") {
setTCount((v) => Math.min(todos.length, v + 1))
hit = true
}
if (!hit) return
event.preventDefault()
event.stopPropagation()
}
window.addEventListener("keydown", onKey)
onCleanup(() => window.removeEventListener("keydown", onKey))
})
return (
<div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "20px" }}>
<div style={panel}>
<button style={btn(mode() === "input")} onClick={() => setMode("input")}>
Ctrl+1 Input
</button>
<button style={btn(mode() === "question")} onClick={() => setMode("question")}>
Ctrl+2 Question
</button>
<button style={btn(mode() === "permission")} onClick={() => setMode("permission")}>
Ctrl+3 Permission
</button>
<button style={btn(showTodos())} onClick={() => setShowTodos((v) => !v)}>
Ctrl+5 Todos
</button>
<button style={btn(todoCollapsed())} onClick={() => setTodoCollapsed((v) => !v)}>
Ctrl+4 Collapse
</button>
<button style={btn(showCtx())} onClick={() => setShowCtx((v) => !v)}>
Ctrl+6 Context
</button>
<button style={btn(!!drag())} onClick={cycle}>
Ctrl+7 Drag: {drag() ?? "off"}
</button>
<button style={btn(work())} onClick={() => setWork((v) => !v)}>
Working
</button>
<button style={btn(accept())} onClick={() => setAccept((v) => !v)}>
Auto-accept
</button>
<button style={btn(false)} onClick={() => setA1((v) => limit(v - 1, 0))}>
Ctrl+8 Q1-
</button>
<span>Q1:{a1()}</span>
<button style={btn(false)} onClick={() => setA1((v) => limit(v + 1, 0))}>
Ctrl+9 Q1+
</button>
<button style={btn(false)} onClick={() => setA2((v) => limit(v - 1, 1))}>
Ctrl+[ Q2-
</button>
<span>Q2:{a2()}</span>
<button style={btn(false)} onClick={() => setA2((v) => limit(v + 1, 1))}>
Ctrl+] Q2+
</button>
<button style={btn(false)} onClick={() => setA3((v) => limit(v - 1, 2))}>
Ctrl+; Q3-
</button>
<span>Q3:{a3()}</span>
<button style={btn(false)} onClick={() => setA3((v) => limit(v + 1, 2))}>
Ctrl+' Q3+
</button>
<button style={btn(false)} onClick={() => setTCount((v) => Math.max(0, v - 1))}>
Ctrl+0 T-
</button>
<span>T:{tCount()}</span>
<button style={btn(false)} onClick={() => setTCount((v) => Math.min(todos.length, v + 1))}>
Ctrl+- T+
</button>
</div>
<NewComposer
mode={mode()}
runtime={runtime}
agentOptions={["ask", "coder", "reviewer"]}
modelOptions={[
"OpenAI/GPT-5.2",
"OpenAI/GPT-5.3 Codex",
"OpenAI/GPT-5.3 Codex Spark",
"Anthropic/Claude Sonnet 4",
"Anthropic/Claude Haiku 4.5",
"Google/Gemini 2.5 Pro",
]}
variantOptions={["default", "fast", "quality"]}
agentCurrent={agent()}
modelCurrent={model()}
variantCurrent={variant()}
onAgentSelect={setAgent}
onModelSelect={setModel}
onVariantSelect={setVariant}
agentKeybind="mod+."
modelKeybind="mod+'"
variantKeybind="shift+mod+d"
working={work()}
accepting={accept()}
questionText={q()?.text ?? "Which editor do you use most often?"}
questionOptions={q()?.options ?? []}
questionMultiple={q()?.multiple ?? false}
questionIndex={tab()}
questionTotal={qList().length}
onQuestionBack={() => setTab((v) => Math.max(0, v - 1))}
onQuestionNext={() => setTab((v) => Math.min(qList().length - 1, v + 1))}
onQuestionJump={setTab}
onQuestionDismiss={() => {
setMode("input")
setTab(0)
}}
showTodos={showTodos()}
todoCollapsed={todoCollapsed()}
onTodoCollapseChange={setTodoCollapsed}
todos={tList()}
contextItems={showCtx() ? ctx : []}
forceDragType={drag()}
permissionDescription="This action needs write access to project files."
permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]}
/>
</div>
)
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import type { AtOption, SlashCommand } from "./types"
export const DEFAULT_AT_OPTIONS: AtOption[] = [
{ type: "file", path: "src/auth.ts", display: "src/auth.ts" },
{ type: "file", path: "src/middleware.ts", display: "src/middleware.ts" },
{ type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" },
{ type: "file", path: "src/utils/token.ts", display: "src/utils/token.ts" },
{ type: "file", path: "src/config/database.ts", display: "src/config/database.ts" },
{ type: "agent", name: "coder", display: "coder" },
{ type: "agent", name: "reviewer", display: "reviewer" },
{ type: "agent", name: "planner", display: "planner" },
]
export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [
{
id: "help",
trigger: "help",
title: "Help",
description: "Show available commands",
type: "builtin",
keybind: "?",
source: "command",
},
{
id: "clear",
trigger: "clear",
title: "Clear",
description: "Clear conversation",
type: "builtin",
source: "command",
},
{
id: "compact",
trigger: "compact",
title: "Compact",
description: "Compact conversation history",
type: "builtin",
source: "command",
},
{
id: "init",
trigger: "init",
title: "Init",
description: "Initialize CLAUDE.md",
type: "builtin",
source: "command",
},
{
id: "review",
trigger: "review",
title: "Review",
description: "Review code changes",
type: "custom",
source: "skill",
},
{
id: "test",
trigger: "test",
title: "Test",
description: "Run tests",
type: "custom",
source: "mcp",
},
]

View file

@ -0,0 +1,23 @@
import { Show } from "solid-js"
import { useI18n } from "../../context/i18n"
import { Icon } from "../icon"
interface Props {
type: "image" | "@mention" | null
}
export function ComposerDragOverlay(props: Props) {
const i18n = useI18n()
return (
<Show when={props.type}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name={props.type === "image" ? "photo" : "link"} class="size-8" />
<span class="text-14-regular">
{props.type === "image" ? i18n.t("ui.prompt.dropzone.attach") : i18n.t("ui.prompt.dropzone.mention")}
</span>
</div>
</div>
</Show>
)
}

View file

@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-utils"
describe("new-composer editor utils", () => {
test("createTextFragment preserves newlines with consecutive br nodes", () => {
const fragment = createTextFragment("foo\n\nbar")
const box = document.createElement("div")
box.appendChild(fragment)
expect(box.childNodes.length).toBe(4)
expect(box.childNodes[0]?.textContent).toBe("foo")
expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect((box.childNodes[2] as HTMLElement).tagName).toBe("BR")
expect(box.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const box = document.createElement("div")
box.appendChild(fragment)
expect(box.childNodes.length).toBe(2)
expect(box.childNodes[0]?.textContent).toBe("foo")
expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("createTextFragment avoids break-node explosion for large multiline content", () => {
const value = Array.from({ length: 220 }, () => "line").join("\n")
const fragment = createTextFragment(value)
const box = document.createElement("div")
box.appendChild(fragment)
expect(box.childNodes.length).toBe(1)
expect(box.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
expect(box.textContent).toBe(value)
})
test("createTextFragment keeps terminal break in large multiline fallback", () => {
const value = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
const fragment = createTextFragment(value)
const box = document.createElement("div")
box.appendChild(fragment)
expect(box.childNodes.length).toBe(2)
expect(box.childNodes[0]?.textContent).toBe(value.slice(0, -1))
expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const box = document.createElement("div")
box.appendChild(document.createTextNode("ab\u200B"))
box.appendChild(document.createElement("br"))
box.appendChild(document.createTextNode("cd"))
expect(getNodeLength(box.childNodes[0]!)).toBe(2)
expect(getNodeLength(box.childNodes[1]!)).toBe(1)
expect(getTextLength(box)).toBe(5)
})
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
const box = document.createElement("div")
const pill = document.createElement("span")
pill.dataset.type = "file"
pill.textContent = "@file"
box.appendChild(document.createTextNode("ab"))
box.appendChild(pill)
box.appendChild(document.createElement("br"))
box.appendChild(document.createTextNode("cd"))
document.body.appendChild(box)
setCursorPosition(box, 2)
expect(getCursorPosition(box)).toBe(2)
setCursorPosition(box, 7)
expect(getCursorPosition(box)).toBe(7)
setCursorPosition(box, 8)
expect(getCursorPosition(box)).toBe(8)
box.remove()
})
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const box = document.createElement("div")
box.appendChild(document.createTextNode("a"))
box.appendChild(document.createElement("br"))
box.appendChild(document.createElement("br"))
box.appendChild(document.createTextNode("b"))
document.body.appendChild(box)
setCursorPosition(box, 2)
expect(getCursorPosition(box)).toBe(2)
setCursorPosition(box, 3)
expect(getCursorPosition(box)).toBe(3)
box.remove()
})
})

View file

@ -0,0 +1,206 @@
import type { ComposerPart } from "./types"
const MAX_BREAKS = 200
export function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
let breaks = 0
for (const char of content) {
if (char !== "\n") continue
breaks += 1
if (breaks > MAX_BREAKS) {
const tail = content.endsWith("\n")
const text = tail ? content.slice(0, -1) : content
if (text) fragment.appendChild(document.createTextNode(text))
if (tail) fragment.appendChild(document.createElement("br"))
return fragment
}
}
const parts = content.split("\n")
parts.forEach((part, i) => {
if (part) fragment.appendChild(document.createTextNode(part))
if (i < parts.length - 1) fragment.appendChild(document.createElement("br"))
})
return fragment
}
export function getNodeLength(node: Node): number {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
return (node.textContent ?? "").replace(/\u200B/g, "").length
}
export function getTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
let len = 0
for (const child of Array.from(node.childNodes)) len += getTextLength(child)
return len
}
export function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
if (!parent.contains(range.startContainer)) return 0
const copy = range.cloneRange()
copy.selectNodeContents(parent)
copy.setEnd(range.startContainer, range.startOffset)
return getTextLength(copy.cloneContents())
}
export function setCursorPosition(parent: HTMLElement, position: number) {
let rest = position
let node = parent.firstChild
while (node) {
const len = getNodeLength(node)
const text = node.nodeType === Node.TEXT_NODE
const pill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (text && rest <= len) {
const range = document.createRange()
const selection = window.getSelection()
range.setStart(node, rest)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
return
}
if ((pill || br) && rest <= len) {
const range = document.createRange()
const selection = window.getSelection()
if (rest === 0) range.setStartBefore(node)
else if (pill) range.setStartAfter(node)
else {
const next = node.nextSibling
if (next && next.nodeType === Node.TEXT_NODE) range.setStart(next, 0)
else range.setStartAfter(node)
}
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
return
}
rest -= len
node = node.nextSibling
}
const end = document.createRange()
end.selectNodeContents(parent)
end.collapse(false)
window.getSelection()?.removeAllRanges()
window.getSelection()?.addRange(end)
}
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
let rest = offset
for (const node of Array.from(parent.childNodes)) {
const len = getNodeLength(node)
const text = node.nodeType === Node.TEXT_NODE
const pill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (text && rest <= len) {
if (edge === "start") range.setStart(node, rest)
else range.setEnd(node, rest)
return
}
if ((pill || br) && rest <= len) {
if (edge === "start") rest === 0 ? range.setStartBefore(node) : range.setStartAfter(node)
else rest === 0 ? range.setEndBefore(node) : range.setEndAfter(node)
return
}
rest -= len
}
}
export function createPill(type: "file" | "agent", content: string, path?: string) {
const el = document.createElement("span")
el.textContent = content
el.setAttribute("data-type", type)
if (type === "file" && path) el.setAttribute("data-path", path)
if (type === "agent") el.setAttribute("data-name", content.replace("@", ""))
el.setAttribute("contenteditable", "false")
el.style.userSelect = "text"
el.style.cursor = "default"
return el
}
export function parseEditorText(editor: HTMLElement): string {
return parseEditorParts(editor)
.map((part) => part.content)
.join("")
}
function pushText(parts: ComposerPart[], text: string) {
if (!text) return
const last = parts[parts.length - 1]
if (last?.type === "text") {
last.content += text
return
}
parts.push({ type: "text", content: text })
}
export function parseEditorParts(editor: HTMLElement): ComposerPart[] {
const parts: ComposerPart[] = []
let text = ""
const flush = () => {
if (!text) return
pushText(parts, text)
text = ""
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
text += (node.textContent ?? "").replace(/\u200B/g, "")
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.dataset.type === "file" || el.dataset.type === "agent") {
flush()
const content = el.textContent ?? ""
if (el.dataset.type === "file") {
parts.push({
type: "file",
path: el.dataset.path ?? content,
content,
})
}
if (el.dataset.type === "agent") {
parts.push({
type: "agent",
name: el.dataset.name ?? content.replace(/^@/, ""),
content,
})
}
return
}
if (el.tagName === "BR") {
text += "\n"
return
}
for (const child of Array.from(el.childNodes)) visit(child)
}
const nodes = Array.from(editor.childNodes)
nodes.forEach((node, i) => {
const block = node.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((node as HTMLElement).tagName)
visit(node)
if (block && i < nodes.length - 1) text += "\n"
})
flush()
return parts
}
export function isImeComposing(event: KeyboardEvent): boolean {
return event.isComposing || (event as unknown as { keyCode?: number }).keyCode === 229
}

View file

@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test"
import {
canNavigateAtCursor,
cloneParts,
navigateEntry,
normalizeEntry,
partText,
prependEntry,
type HistoryEntry,
} from "./history"
const text = (value: string): HistoryEntry => ({
text: value,
parts: [{ type: "text", content: value }],
})
describe("new-composer history", () => {
test("prependEntry skips empty entries and deduplicates consecutive entries", () => {
const blank: HistoryEntry[] = []
const first = prependEntry(blank, text(""))
expect(first).toBe(blank)
const one = prependEntry([], text("hello"))
expect(one).toHaveLength(1)
const dup = prependEntry(one, text("hello"))
expect(dup).toBe(one)
})
test("navigateEntry restores saved draft when moving down from newest", () => {
const entries = [text("third"), text("second"), text("first")]
const up = navigateEntry({
direction: "up",
entries,
historyIndex: -1,
current: text("draft"),
saved: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start")
expect(up.entry.text).toBe("third")
const down = navigateEntry({
direction: "down",
entries,
historyIndex: up.historyIndex,
current: text("ignored"),
saved: up.saved,
})
expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1)
expect(down.entry.text).toBe("draft")
expect(down.entry.parts).toEqual([{ type: "text", content: "draft" }])
})
test("navigateEntry keeps structured parts when navigating history", () => {
const entry: HistoryEntry = {
text: "@src/auth.ts",
parts: [{ type: "file", path: "src/auth.ts", content: "@src/auth.ts" }],
}
const up = navigateEntry({
direction: "up",
entries: [entry],
historyIndex: -1,
current: text("draft"),
saved: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.entry.parts).toEqual(entry.parts)
})
test("normalizeEntry supports legacy string entries", () => {
const entry = normalizeEntry("legacy")
expect(entry.text).toBe("legacy")
expect(entry.parts).toEqual([{ type: "text", content: "legacy" }])
})
test("helpers clone parts and count combined content", () => {
const src = [
{ type: "text", content: "one" },
{ type: "file", path: "src/a.ts", content: "@src/a.ts" },
{ type: "agent", name: "coder", content: "@coder" },
] as const
const copy = cloneParts([...src])
expect(copy).not.toBe(src)
expect(partText(copy)).toBe("one@src/a.ts@coder")
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].path = "src/b.ts"
if (src[1].type !== "file") throw new Error("expected file")
expect(src[1].path).toBe("src/a.ts")
})
test("canNavigateAtCursor only allows history navigation at boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateAtCursor("up", value, 0)).toBe(true)
expect(canNavigateAtCursor("down", value, 0)).toBe(false)
expect(canNavigateAtCursor("up", value, 2)).toBe(false)
expect(canNavigateAtCursor("down", value, 2)).toBe(false)
expect(canNavigateAtCursor("up", value, 5)).toBe(false)
expect(canNavigateAtCursor("down", value, 5)).toBe(true)
expect(canNavigateAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateAtCursor("down", "abc", 0, true)).toBe(true)
expect(canNavigateAtCursor("down", "abc", 3, true)).toBe(true)
expect(canNavigateAtCursor("up", "abc", 1, true)).toBe(false)
expect(canNavigateAtCursor("down", "abc", 1, true)).toBe(false)
})
})

View file

@ -0,0 +1,189 @@
import type { ComposerHistoryItem, ComposerPart } from "./types"
export type HistoryEntry = {
text: string
parts: ComposerPart[]
}
export type HistoryStored = string | ComposerHistoryItem
export type NavInput = {
direction: "up" | "down"
entries: HistoryStored[]
historyIndex: number
current: HistoryEntry
saved: HistoryEntry | null
}
export type NavResult =
| {
handled: false
historyIndex: number
saved: HistoryEntry | null
}
| {
handled: true
historyIndex: number
saved: HistoryEntry | null
entry: HistoryEntry
cursor: "start" | "end"
}
export const MAX_HISTORY = 50
export const EMPTY_ENTRY: HistoryEntry = {
text: "",
parts: [{ type: "text", content: "" }],
}
const clonePart = (part: ComposerPart): ComposerPart => {
if (part.type === "text") return { ...part }
if (part.type === "file") return { ...part }
return { ...part }
}
export const cloneParts = (parts: ComposerPart[]) => parts.map(clonePart)
const cloneEntry = (entry: HistoryEntry): HistoryEntry => ({
text: entry.text,
parts: cloneParts(entry.parts),
})
export const partText = (parts: ComposerPart[]) => parts.map((part) => part.content).join("")
export const normalizeEntry = (item: HistoryStored): HistoryEntry => {
if (typeof item === "string") {
return {
text: item,
parts: [{ type: "text", content: item }],
}
}
if (!item.parts || item.parts.length === 0) {
return {
text: item.text,
parts: [{ type: "text", content: item.text }],
}
}
const parts = cloneParts(item.parts)
return {
text: partText(parts),
parts,
}
}
const samePart = (a: ComposerPart, b: ComposerPart) => {
if (a.type !== b.type) return false
if (a.type === "text" && b.type === "text") return a.content === b.content
if (a.type === "file" && b.type === "file") return a.path === b.path && a.content === b.content
if (a.type === "agent" && b.type === "agent") return a.name === b.name && a.content === b.content
return false
}
export const sameEntry = (a: HistoryEntry | undefined, b: HistoryEntry) => {
if (!a) return false
if (a.text !== b.text) return false
if (a.parts.length !== b.parts.length) return false
for (let i = 0; i < a.parts.length; i++) {
const x = a.parts[i]
const y = b.parts[i]
if (!x || !y || !samePart(x, y)) return false
}
return true
}
export const prependEntry = (entries: HistoryEntry[], item: HistoryEntry, max = MAX_HISTORY) => {
if (!item.text.trim()) return entries
const next = normalizeEntry(item)
if (sameEntry(entries[0], next)) return entries
return [next, ...entries].slice(0, max)
}
export const canNavigateAtCursor = (direction: "up" | "down", text: string, cursor: number, inHistory = false) => {
const pos = Math.max(0, Math.min(cursor, text.length))
const start = pos === 0
const end = pos === text.length
if (inHistory) return start || end
if (direction === "up") return start
return end
}
export const navigateEntry = (input: NavInput): NavResult => {
if (input.direction === "up") {
if (input.entries.length === 0) {
return {
handled: false,
historyIndex: input.historyIndex,
saved: input.saved,
}
}
if (input.historyIndex === -1) {
return {
handled: true,
historyIndex: 0,
saved: cloneEntry(input.current),
entry: normalizeEntry(input.entries[0] ?? EMPTY_ENTRY),
cursor: "start",
}
}
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
return {
handled: true,
historyIndex: next,
saved: input.saved,
entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY),
cursor: "start",
}
}
return {
handled: false,
historyIndex: input.historyIndex,
saved: input.saved,
}
}
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
return {
handled: true,
historyIndex: next,
saved: input.saved,
entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY),
cursor: "end",
}
}
if (input.historyIndex === 0) {
if (input.saved) {
return {
handled: true,
historyIndex: -1,
saved: null,
entry: cloneEntry(input.saved),
cursor: "end",
}
}
return {
handled: true,
historyIndex: -1,
saved: null,
entry: normalizeEntry(EMPTY_ENTRY),
cursor: "end",
}
}
return {
handled: false,
historyIndex: input.historyIndex,
saved: input.saved,
}
}

View file

@ -0,0 +1,223 @@
import { For, Show } from "solid-js"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useI18n } from "../../context/i18n"
import { Button } from "../button"
import { FileIcon } from "../file-icon"
import { Icon } from "../icon"
import { IconButton } from "../icon-button"
import { Tooltip } from "../tooltip"
import type { ContextItem, ImageAttachment } from "./types"
interface Props {
value: string
mode: "normal" | "shell"
images: ImageAttachment[]
contexts: ContextItem[]
working: boolean
accepting: boolean
placeholder?: string
onImageDrop: (id: string) => void
contextActive?: (item: ContextItem) => boolean
onContextOpen?: (item: ContextItem) => void
onContextDrop: (item: ContextItem) => void
onAccept: () => void
onPick: () => void
onSend: () => void
onEditorRef: (el: HTMLDivElement) => void
onInput: () => void
onKeyDown: (event: KeyboardEvent) => void
onPaste: (event: ClipboardEvent) => void
onCompStart: () => void
onCompEnd: () => void
}
export function ComposerInputLayer(props: Props) {
const i18n = useI18n()
const isShell = () => props.mode === "shell"
const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 })
const buttonsOpacity = () => 1 - shell()
const buttonsBlur = () => shell()
return (
<>
<Show when={props.images.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.images}>
{(item) => (
<div class="relative group">
<Show
when={item.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<img
src={item.dataUrl}
alt={item.filename}
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
/>
</Show>
<button
type="button"
onClick={() => props.onImageDrop(item.id)}
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
aria-label={i18n.t("ui.prompt.attachment.remove")}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
<span class="text-10-regular text-white truncate block">{item.filename}</span>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={props.contexts.length > 0}>
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={props.contexts}>
{(item) => {
const active = () => props.contextActive?.(item) ?? false
const dir = () => getDirectory(item.path)
const file = () => getFilename(item.path)
const filename = () => getFilenameTruncated(item.path, 14)
const label = () => {
if (!item.selection) return null
if (item.selection.startLine === item.selection.endLine) return `:${item.selection.startLine}`
return `:${item.selection.startLine}-${item.selection.endLine}`
}
return (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">{dir()}</span>
<span class="shrink-0">{file()}</span>
</span>
}
placement="top"
openDelay={2000}
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all shadow-xs-border": true,
"hover:bg-surface-interactive-weak": !!item.commentID && !active(),
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
active(),
"bg-background-stronger": !active(),
}}
onClick={() => props.onContextOpen?.(item)}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{filename()}</span>
<Show when={label()}>
<span class="text-text-weak whitespace-nowrap shrink-0">{label()}</span>
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(event) => {
event.stopPropagation()
props.onContextDrop(item)
}}
aria-label={i18n.t("ui.prompt.context.removeFile")}
/>
</div>
<Show when={item.comment}>
{(note) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{note()}</div>}
</Show>
</div>
</Tooltip>
)
}}
</For>
</div>
</Show>
<div class="relative max-h-[200px] overflow-y-auto no-scrollbar" style={{ flex: 1 }}>
<div
data-component="prompt-input"
ref={props.onEditorRef}
role="textbox"
aria-multiline="true"
aria-label={props.placeholder ?? i18n.t("ui.prompt.placeholder.simple")}
contentEditable={true}
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={props.onInput}
onKeyDown={props.onKeyDown}
onPaste={props.onPaste}
onCompositionStart={props.onCompStart}
onCompositionEnd={props.onCompEnd}
class="select-text w-full pl-3 pr-2 pt-2 pb-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap"
classList={{
"font-mono!": props.mode === "shell",
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
}}
/>
<Show when={!props.value.trim()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": props.mode === "shell" }}
>
{props.mode === "shell"
? i18n.t("ui.prompt.placeholder.shell")
: (props.placeholder ?? i18n.t("ui.prompt.placeholder.simple"))}
</div>
</Show>
</div>
<div
class="flex items-center justify-between px-2 pb-2 shrink-0"
style={{
opacity: `${buttonsOpacity()}`,
filter: buttonsBlur() > 0.01 ? `blur(${buttonsBlur()}px)` : "none",
"pointer-events": shell() < 0.5 ? "auto" : "none",
}}
>
<Button
variant="ghost"
onClick={props.onAccept}
class="size-6"
classList={{
"text-text-base": !props.accepting,
"hover:bg-surface-success-base": props.accepting,
}}
style={{ display: "flex", "align-items": "center", "justify-content": "center" }}
aria-label={i18n.t("ui.prompt.autoAccept")}
aria-pressed={props.accepting}
>
<Icon name="chevron-double-right" size="small" classList={{ "text-icon-success-base": props.accepting }} />
</Button>
<div class="flex items-center gap-1">
<Button
variant="ghost"
class="size-8 p-0"
aria-label={i18n.t("ui.prompt.attachment.add")}
onClick={props.onPick}
>
<Icon name="plus" class="size-4.5" />
</Button>
<IconButton
icon={props.working ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
onClick={props.onSend}
disabled={!props.working && props.value.trim().length === 0 && props.images.length === 0}
aria-label={props.working ? i18n.t("ui.prompt.stop") : i18n.t("ui.prompt.send")}
/>
</div>
</div>
</>
)
}

View file

@ -0,0 +1,242 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js"
import { useI18n } from "../../context/i18n"
import { Button } from "../button"
import { Icon } from "../icon"
import { IconButton } from "../icon-button"
import { ProviderIcon } from "../provider-icon"
interface Props {
options: string[]
current?: string
onSelect?: (value: string) => void
openTick?: number
}
type Item = {
value: string
provider: string
label: string
}
function parse(value: string, fallback: string): Item {
const i = value.indexOf("/")
if (i <= 0 || i >= value.length - 1) {
return {
value,
provider: fallback,
label: value,
}
}
return {
value,
provider: value.slice(0, i),
label: value.slice(i + 1),
}
}
export function ModelPicker(props: Props) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const [full, setFull] = createSignal(false)
const [query, setQuery] = createSignal("")
let input: HTMLInputElement | undefined
const focus = () => {
requestAnimationFrame(() => {
input?.focus()
input?.select()
})
}
createEffect(
on(
() => props.openTick,
(next, prev) => {
if (next === undefined) return
if (next === prev) return
setFull(true)
setOpen(true)
focus()
},
{ defer: true },
),
)
const list = createMemo(() => {
const q = query().trim().toLowerCase()
const fallback = i18n.t("ui.prompt.model.group.default")
const items = props.options.map((item) => parse(item, fallback))
if (!q) return items
return items.filter((item) => {
return item.label.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q)
})
})
const groups = createMemo(() => {
const map = new Map<string, Item[]>()
for (const item of list()) {
const group = map.get(item.provider)
if (group) {
group.push(item)
continue
}
map.set(item.provider, [item])
}
return Array.from(map.entries()).map(([provider, items]) => ({ provider, items }))
})
const label = createMemo(() => {
const fallback = i18n.t("ui.prompt.model.group.default")
const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current)
if (!hit) return props.current ?? i18n.t("ui.prompt.control.model")
return hit.label
})
const provider = createMemo(() => {
const fallback = i18n.t("ui.prompt.model.group.default")
const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current)
if (!hit) return undefined
if (hit.provider === fallback) return undefined
return hit.provider.toLowerCase()
})
return (
<Kobalte
open={open()}
onOpenChange={(next) => {
setOpen(next)
if (!next) {
setQuery("")
setFull(false)
}
if (next) focus()
}}
modal={false}
placement="top-start"
gutter={4}
>
<Kobalte.Trigger
as={Button}
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
onPointerDown={() => setFull(false)}
>
<Show when={provider()} fallback={<Icon name="brain" size="small" class="shrink-0" />}>
{(id) => (
<ProviderIcon
id={id()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Kobalte.Trigger>
<Kobalte.Portal>
<Show when={open() && full()}>
<div class="fixed inset-0 bg-black/35 z-40" onPointerDown={() => setOpen(false)} />
</Show>
<Kobalte.Content
class="flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
classList={{
"w-72 h-80 p-2": !full(),
"w-[min(92vw,1130px)] h-[min(86vh,860px)] p-5": full(),
}}
style={
full()
? {
position: "fixed",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}
: undefined
}
onEscapeKeyDown={(event) => {
setOpen(false)
event.preventDefault()
event.stopPropagation()
}}
>
<Show when={full()}>
<div class="flex items-center justify-between mb-3">
<div class="text-35-medium text-text-strong">{i18n.t("ui.prompt.model.select")}</div>
<Button icon="plus-small" variant="secondary" size="normal" class="text-32-medium h-10 px-4">
{i18n.t("ui.prompt.model.connect")}
</Button>
</div>
</Show>
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-surface-base mb-2">
<Icon name="magnifying-glass" size="small" class="text-icon-weak shrink-0" />
<input
ref={input}
value={query()}
onInput={(event) => setQuery(event.currentTarget.value)}
placeholder={i18n.t("ui.prompt.model.search")}
class="min-w-0 flex-1 bg-transparent border-none outline-none text-14-regular text-text-strong placeholder:text-text-weak"
/>
<Show when={!full()}>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="normal"
class="size-6"
aria-label={i18n.t("ui.prompt.model.connect")}
/>
<IconButton
icon="sliders"
variant="ghost"
iconSize="normal"
class="size-6"
aria-label={i18n.t("ui.prompt.model.manage")}
/>
</Show>
</div>
<div class="flex-1 min-h-0 overflow-auto no-scrollbar px-1 pt-1">
<Show
when={groups().length > 0}
fallback={<div class="px-2 py-1 text-13-regular text-text-weak">{i18n.t("ui.prompt.model.none")}</div>}
>
<For each={groups()}>
{(group) => (
<div class="pb-2.5">
<div class="px-2 py-1.5 text-35-medium text-text-weak">{group.provider}</div>
<For each={group.items}>
{(item) => (
<button
class="w-full h-10 px-2 rounded-md flex items-center justify-between text-left"
classList={{ "bg-surface-raised-base-hover": props.current === item.value }}
onClick={() => {
props.onSelect?.(item.value)
setOpen(false)
}}
>
<span class="text-32-medium text-text-strong truncate">{item.label}</span>
<Show when={props.current === item.value}>
<Icon name="check-small" size="small" class="text-icon-strong-base shrink-0" />
</Show>
</button>
)}
</For>
</div>
)}
</For>
</Show>
</div>
<Show when={full()}>
<div class="pt-2 px-2 text-32-medium text-text-weak">{i18n.t("ui.prompt.model.manage")}</div>
</Show>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View file

@ -0,0 +1,41 @@
import { For, Show } from "solid-js"
import { useI18n } from "../../context/i18n"
import { Icon } from "../icon"
interface Props {
tool?: string
description?: string
patterns?: string[]
}
export function PermissionBody(props: Props) {
const i18n = useI18n()
const hint = () => props.description ?? props.tool ?? ""
return (
<>
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{i18n.t("ui.permission.title")}</div>
</div>
<Show when={hint()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{hint()}</div>
</div>
</Show>
<Show when={(props.patterns?.length ?? 0) > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</div>
</Show>
</>
)
}

View file

@ -0,0 +1,111 @@
import { For, Match, Show, Switch } from "solid-js"
import { useI18n } from "../../context/i18n"
import { Icon } from "../icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import type { AtOption, SlashCommand } from "./types"
interface Props {
kind: "at" | "slash" | null
bottom: number
atFlat: AtOption[]
atActive: string | null
atKey: (item: AtOption) => string
onAtHover: (key: string) => void
onAtPick: (item: AtOption) => void
slashFlat: SlashCommand[]
slashActive: string | null
onSlashHover: (key: string) => void
onSlashPick: (item: SlashCommand) => void
}
export function ComposerPopover(props: Props) {
const i18n = useI18n()
return (
<Show when={props.kind}>
<div
class="absolute left-0 right-0 max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
style={{
bottom: `${props.bottom}px`,
"z-index": 100,
}}
onMouseDown={(event) => event.preventDefault()}
>
<Switch>
<Match when={props.kind === "at"}>
<Show
when={props.atFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{i18n.t("ui.list.empty")}</div>}
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => {
const key = props.atKey(item)
const isDir = item.type === "file" && item.path.endsWith("/")
const dir = item.type === "file" ? (isDir ? item.path : getDirectory(item.path)) : ""
const file = item.type === "file" && !isDir ? getFilename(item.path) : ""
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtPick(item)}
onMouseEnter={() => props.onAtHover(key)}
>
<Icon
name={item.type === "agent" ? "brain" : "file-tree"}
size="small"
class={item.type === "agent" ? "text-icon-info-active shrink-0" : "text-icon-base shrink-0"}
/>
{item.type === "agent" ? (
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
) : (
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{dir}</span>
<Show when={!isDir}>
<span class="text-text-strong whitespace-nowrap">{file}</span>
</Show>
</div>
)}
</button>
)
}}
</For>
</Show>
</Match>
<Match when={props.kind === "slash"}>
<Show
when={props.slashFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{i18n.t("ui.prompt.list.noCommands")}</div>}
>
<For each={props.slashFlat}>
{(cmd) => (
<button
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
}}
onClick={() => props.onSlashPick(cmd)}
onMouseEnter={() => props.onSlashHover(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
<Show when={cmd.description}>
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
</Show>
</div>
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
{cmd.source === "skill" ? "skill" : cmd.source === "mcp" ? "mcp" : "custom"}
</span>
</Show>
<Show when={cmd.keybind}>
<span class="text-12-regular text-text-subtle">{cmd.keybind}</span>
</Show>
</button>
)}
</For>
</Show>
</Match>
</Switch>
</div>
</Show>
)
}

View file

@ -0,0 +1,218 @@
import { For, Index, Show } from "solid-js"
import { useI18n } from "../../context/i18n"
import { Icon } from "../icon"
interface Option {
label: string
description?: string
}
interface Base {
text?: string
options?: Option[]
index?: number
total?: number
multi?: boolean
answered?: boolean[]
}
interface BodyProps extends Base {
selected: string[]
custom: string
customOn: boolean
busy?: boolean
onToggle: (label: string) => void
onCustomOn: (value: boolean) => void
onCustom: (value: string) => void
onJump?: (index: number) => void
}
function Header(props: Base & { onJump?: (index: number) => void; click?: boolean }) {
const i18n = useI18n()
const tab = () => props.index ?? 0
const done = (i: number) => {
if (props.answered) return props.answered[i] === true
return i < tab()
}
return (
<Show when={(props.total ?? 0) > 0}>
<div data-slot="question-header">
<div data-slot="question-header-title">
{i18n.t("ui.question.progress", { index: tab() + 1, total: props.total ?? 0 })}
</div>
<div data-slot="question-progress">
<Index each={Array.from({ length: props.total ?? 0 })}>
{(_, i) => (
<Show
when={props.click}
fallback={
<span data-slot="question-progress-segment" data-active={i === tab()} data-answered={done(i)} />
}
>
<button
type="button"
data-slot="question-progress-segment"
data-active={i === tab()}
data-answered={done(i)}
onClick={() => props.onJump?.(i)}
/>
</Show>
)}
</Index>
</div>
</div>
</Show>
)
}
export function QuestionBody(props: BodyProps) {
const i18n = useI18n()
const multi = () => props.multi ?? false
const selected = (label: string) => props.selected.includes(label)
const customPicked = () => {
if (props.customOn) return true
if (!multi()) return false
const text = props.custom.trim()
if (!text) return false
return props.selected.some((item) => item.trim() === text)
}
return (
<>
<Header index={props.index} total={props.total} answered={props.answered} onJump={props.onJump} click />
<div data-slot="question-content">
<div data-slot="question-text">{props.text}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{i18n.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{i18n.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options" style={{ "padding-bottom": "8px" }}>
<For each={props.options}>
{(opt) => {
const picked = () => selected(opt.label)
return (
<button
data-slot="question-option"
data-picked={picked()}
onClick={() => props.onToggle(opt.label)}
disabled={props.busy}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<form
data-slot="question-option"
data-custom="true"
data-picked={customPicked()}
onMouseDown={(event) => {
if (event.target instanceof HTMLTextAreaElement) return
const input = event.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(event) => event.preventDefault()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const next = multi() ? !props.customOn : true
props.onCustomOn(next)
const form = event.currentTarget.closest("form")
const input = form?.querySelector('[data-slot="question-custom-input"]')
if (!(input instanceof HTMLTextAreaElement)) return
if (!next) {
input.blur()
return
}
input.focus()
}}
>
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={customPicked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
data-slot="question-custom-input"
placeholder={i18n.t("ui.question.custom.placeholder")}
value={props.custom}
disabled={props.busy}
onFocus={() => props.onCustomOn(true)}
onInput={(event) => {
const value = event.currentTarget.value
props.onCustom(value)
props.onCustomOn(value.trim().length > 0)
}}
rows={1}
/>
</span>
</form>
</div>
</div>
</>
)
}
export function QuestionSizer(props: Base) {
const i18n = useI18n()
return (
<>
<Header index={props.index} total={props.total} answered={props.answered} />
<div data-slot="question-content">
<div data-slot="question-text">{props.text}</div>
<div data-slot="question-hint">
{props.multi ? i18n.t("ui.question.multiHint") : i18n.t("ui.question.singleHint")}
</div>
<div data-slot="question-options" style={{ overflow: "hidden", "padding-bottom": "8px" }}>
<For each={props.options}>
{(opt) => (
<div data-slot="question-option" style={{ "pointer-events": "none" }}>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</div>
)}
</For>
<div data-slot="question-option" style={{ "pointer-events": "none" }}>
<span data-slot="question-option-main">
<span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{i18n.t("ui.question.custom.placeholder")}</span>
</span>
</div>
</div>
</div>
</>
)
}

View file

@ -0,0 +1,302 @@
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { useI18n } from "../../context/i18n"
import { AnimatedNumber } from "../animated-number"
import { Checkbox } from "../checkbox"
import { IconButton } from "../icon-button"
import { TextReveal } from "../text-reveal"
import { TextStrikethrough } from "../text-strikethrough"
import type { TodoItem } from "./types"
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
interface Props {
todos: TodoItem[]
collapsed: boolean
onToggle: () => void
progress: number
collapse: number
visibleHeight: number
hide: number
shut: number
bottom: number
onContentRef: (el: HTMLDivElement) => void
}
function dot(status: TodoItem["status"]) {
if (status !== "in_progress") return undefined
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="block"
>
<circle
cx="6"
cy="6"
r="3"
style={{
animation: "var(--animate-pulse-scale)",
"transform-origin": "center",
"transform-box": "fill-box",
}}
/>
</svg>
)
}
export function ComposerTodoTray(props: Props) {
const i18n = useI18n()
const [stuck, setStuck] = createSignal(false)
const [scrolling, setScrolling] = createSignal(false)
let scroll: HTMLDivElement | undefined
let timer: number | undefined
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((item) => item.status === "completed").length)
const activeIndex = createMemo(() => props.todos.findIndex((item) => item.status === "in_progress"))
const active = createMemo(
() =>
props.todos.find((item) => item.status === "in_progress") ??
props.todos.find((item) => item.status === "pending") ??
props.todos.filter((item) => item.status === "completed").at(-1) ??
props.todos[0],
)
const preview = createMemo(() => active()?.content ?? "")
const ensure = () => {
if (props.collapsed) return
if (scrolling()) return
if (!scroll || scroll.offsetParent === null) return
const el = scroll.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const box = scroll.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - box.top + scroll.scrollTop
const bottom = rect.bottom - box.top + scroll.scrollTop
const viewTop = scroll.scrollTop + topFade
const viewBottom = scroll.scrollTop + scroll.clientHeight - bottomFade
if (top < viewTop) {
scroll.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scroll.scrollTop = bottom - (scroll.clientHeight - bottomFade)
}
setStuck(scroll.scrollTop > 0)
}
createEffect(
on([() => props.collapsed, activeIndex], () => {
if (props.collapsed || activeIndex() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
return (
<div
data-dock-surface="tray"
style={{
position: "absolute",
bottom: `${props.bottom}px`,
left: 0,
right: 0,
"z-index": 5,
"max-height": `${props.visibleHeight}px`,
"overflow-x": "visible",
"overflow-y": "hidden",
"border-color": "light-dark(#dcd9d9, #3e3a3a)",
"pointer-events": props.progress < 0.98 ? "none" : "auto",
transform: `translateY(${(1 - props.progress) * 12}px)`,
}}
>
<div ref={props.onContentRef}>
<div
style={{
display: "flex",
"align-items": "center",
gap: "8px",
padding: "8px 8px 8px 12px",
height: "40px",
cursor: "pointer",
overflow: "visible",
}}
onClick={props.onToggle}
>
<span
style={{
"font-size": "14px",
color: "var(--text-strong)",
"white-space": "nowrap",
display: "inline-flex",
"align-items": "baseline",
"flex-shrink": 0,
overflow: "visible",
cursor: "default",
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
"--tool-motion-mask": `${COUNT.mask}%`,
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
opacity: `${1 - props.shut}`,
filter: props.shut > 0.01 ? `blur(${props.shut * 2}px)` : "none",
}}
>
<AnimatedNumber value={done()} />
<span style={{ margin: "0 4px" }}>{i18n.t("ui.todo.word.of")}</span>
<AnimatedNumber value={total()} />
<span>&nbsp;{i18n.t("ui.todo.word.completed")}</span>
</span>
<div
style={{
"margin-left": "4px",
"min-width": 0,
overflow: "hidden",
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={props.collapsed ? preview() : undefined}
duration={SUBTITLE.duration}
travel={SUBTITLE.travel}
edge={SUBTITLE.edge}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div style={{ "margin-left": "auto" }}>
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${props.collapse * 180}deg)` }}
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event: MouseEvent) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? i18n.t("ui.todo.expand") : i18n.t("ui.todo.collapse")}
/>
</div>
</div>
<div
style={{
position: "relative",
opacity: `${1 - props.hide}`,
filter: props.hide > 0.01 ? `blur(${props.hide * 2}px)` : "none",
visibility: props.hide > 0.98 ? "hidden" : "visible",
"pointer-events": props.hide > 0.1 ? "none" : "auto",
}}
>
<div style={{ position: "relative" }}>
<div
ref={(el) => {
scroll = el
}}
class="no-scrollbar"
style={{
padding: "0 12px 44px",
display: "flex",
"flex-direction": "column",
gap: "6px",
"max-height": "200px",
"overflow-y": "auto",
"overflow-anchor": "none",
}}
onScroll={(event) => {
setStuck(event.currentTarget.scrollTop > 0)
setScrolling(true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setScrolling(false)
if (activeIndex() < 0) return
requestAnimationFrame(ensure)
}, 250)
}}
>
<Index each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms cubic-bezier(0.22, 1, 0.36, 1)",
opacity: todo().status === "pending" ? "0.5" : "1",
}}
>
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words"
style={{
"line-height": "var(--line-height-normal)",
transition: "color 220ms cubic-bezier(0.22, 1, 0.36, 1)",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
}}
/>
</Checkbox>
)}
</Index>
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "16px",
background: "linear-gradient(to bottom, var(--background-base), transparent)",
"pointer-events": "none",
opacity: stuck() ? 1 : 0,
transition: "opacity 150ms ease",
"z-index": 2,
}}
/>
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "56px",
background: "linear-gradient(to bottom, transparent, var(--background-base) 85%)",
"pointer-events": "none",
"z-index": 2,
}}
/>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,410 @@
import { Show, type JSXElement } from "solid-js"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useI18n } from "../../context/i18n"
import { Button } from "../button"
import { Icon } from "../icon"
import { RadioGroup } from "../radio-group"
import { Select } from "../select"
import { Tooltip, TooltipKeybind } from "../tooltip"
import { ModelPicker } from "./model-picker"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const pretty = (config: string | undefined) => {
if (!config) return ""
const parts = config.split(",")[0]?.trim().toLowerCase().split("+")
if (!parts) return ""
const kb = {
key: "",
ctrl: false,
meta: false,
shift: false,
alt: false,
}
for (const part of parts) {
if (part === "ctrl" || part === "control") {
kb.ctrl = true
continue
}
if (part === "meta" || part === "cmd" || part === "command") {
kb.meta = true
continue
}
if (part === "mod") {
if (IS_MAC) kb.meta = true
else kb.ctrl = true
continue
}
if (part === "alt" || part === "option") {
kb.alt = true
continue
}
if (part === "shift") {
kb.shift = true
continue
}
kb.key = part
}
const out: string[] = []
if (kb.ctrl) out.push(IS_MAC ? "⌃" : "Ctrl")
if (kb.alt) out.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) out.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) out.push(IS_MAC ? "⌘" : "Meta")
const map: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
space: "Space",
}
if (kb.key) {
const hit = map[kb.key]
if (hit) out.push(hit)
else if (kb.key.length === 1) out.push(kb.key.toUpperCase())
else out.push(kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
}
if (IS_MAC) return out.join("")
return out.join("+")
}
interface Props {
inputOpacity: number
inputBlur: number
questionOpacity: number
questionBlur: number
morph: number
isQuestion: boolean
isPermission: boolean
showQuestion: boolean
showPermission: boolean
agentName?: string
modelName?: string
variant?: string
agentOptions?: string[]
modelOptions?: string[]
variantOptions?: string[]
agentCurrent?: string
modelCurrent?: string
variantCurrent?: string
onAgentSelect?: (value: string) => void
onModelSelect?: (value: string) => void
onVariantSelect?: (value: string) => void
agentKeybind?: string
modelKeybind?: string
variantKeybind?: string
modelOpenTick?: number
agentControl?: JSXElement
modelControl?: JSXElement
variantControl?: JSXElement
shell: "shell" | "normal"
onShell: (mode: "shell" | "normal") => void
questionIndex?: number
questionTotal?: number
onQuestionDismiss?: () => void
onQuestionBack?: () => void
onQuestionNext?: () => void
questionBusy?: boolean
onPermissionDecide?: (response: "once" | "always" | "reject") => void | Promise<void>
permissionBusy?: boolean
onRef: (el: HTMLDivElement) => void
}
export function ComposerTray(props: Props) {
const i18n = useI18n()
const isShell = () => props.shell === "shell"
const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 })
const buttonsOpacity = () => 1 - shell()
const buttonsBlur = () => shell()
const labelOpacity = () => shell()
const labelBlur = () => 1 - shell()
const hint = (title: string, keybind: string | undefined, node: JSXElement) => {
const kb = pretty(keybind)
if (kb) {
return (
<TooltipKeybind placement="top" gutter={4} title={title} keybind={kb}>
{node}
</TooltipKeybind>
)
}
return (
<Tooltip placement="top" value={<span>{title}</span>}>
{node}
</Tooltip>
)
}
return (
<div
ref={props.onRef}
data-dock-surface="tray"
data-dock-attach="top"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
"z-index": 0,
height: "58px",
"border-color": "light-dark(#dcd9d9, #3e3a3a)",
}}
>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "22px 7px 8px",
gap: "8px",
opacity: props.inputOpacity,
filter: `blur(${props.inputBlur}px)`,
"pointer-events": props.morph > 0.5 ? "none" : "auto",
}}
>
<div
style={{
display: "flex",
"align-items": "center",
gap: "6px",
"min-width": 0,
flex: 1,
position: "relative",
}}
>
{/* Shell label — fades in when shell mode active */}
<div
class="absolute inset-y-0 left-0 flex items-center px-1"
style={{
opacity: `${labelOpacity()}`,
filter: labelBlur() > 0.01 ? `blur(${labelBlur()}px)` : "none",
"pointer-events": shell() > 0.5 ? "auto" : "none",
}}
>
<span class="truncate text-13-medium text-text-strong">{i18n.t("ui.prompt.control.shell")}</span>
</div>
{/* Agent / model / variant buttons — fade out in shell mode */}
<div
style={{
display: "flex",
"align-items": "center",
gap: "6px",
opacity: `${buttonsOpacity()}`,
filter: buttonsBlur() > 0.01 ? `blur(${buttonsBlur()}px)` : "none",
"pointer-events": shell() < 0.5 ? "auto" : "none",
}}
>
<Show
when={props.agentControl}
fallback={
<Show
when={(props.agentOptions?.length ?? 0) > 0}
fallback={hint(
i18n.t("ui.prompt.control.agent"),
props.agentKeybind,
<Button variant="ghost" size="normal">
{props.agentName ?? "Ask"}
<Icon name="chevron-down" size="small" />
</Button>,
)}
>
{hint(
i18n.t("ui.prompt.control.agent"),
props.agentKeybind,
<Select
size="normal"
variant="ghost"
options={props.agentOptions ?? []}
current={props.agentCurrent}
onSelect={(value) => {
if (!value) return
props.onAgentSelect?.(value)
}}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
/>,
)}
</Show>
}
>
{props.agentControl}
</Show>
<Show
when={props.modelControl}
fallback={
<Show
when={(props.modelOptions?.length ?? 0) > 0}
fallback={hint(
i18n.t("ui.prompt.control.model"),
props.modelKeybind,
<Button variant="ghost" size="normal">
<Icon name="brain" size="small" />
{props.modelName ?? "GPT-4"}
<Icon name="chevron-down" size="small" />
</Button>,
)}
>
{hint(
i18n.t("ui.prompt.control.model"),
props.modelKeybind,
<ModelPicker
options={props.modelOptions ?? []}
current={props.modelCurrent ?? props.modelName}
onSelect={props.onModelSelect}
openTick={props.modelOpenTick}
/>,
)}
</Show>
}
>
{props.modelControl}
</Show>
<Show
when={props.variantControl}
fallback={
<Show
when={(props.variantOptions?.length ?? 0) > 0}
fallback={hint(
i18n.t("ui.prompt.control.variant"),
props.variantKeybind,
<Button variant="ghost" size="normal">
{props.variant ?? "Default"}
<Icon name="chevron-down" size="small" />
</Button>,
)}
>
{hint(
i18n.t("ui.prompt.control.variant"),
props.variantKeybind,
<Select
size="normal"
variant="ghost"
options={props.variantOptions ?? []}
current={props.variantCurrent}
onSelect={(value) => {
if (!value) return
props.onVariantSelect?.(value)
}}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
/>,
)}
</Show>
}
>
{props.variantControl}
</Show>
</div>
</div>
<RadioGroup
options={["shell", "normal"] as const}
current={props.shell}
onSelect={(mode) => props.onShell(mode ?? "normal")}
value={(mode) => mode}
label={(mode) => <Icon name={mode === "shell" ? "console" : "prompt"} class="size-[18px]" />}
fill
pad="none"
class="w-[68px] shrink-0"
/>
</div>
<Show when={props.showQuestion}>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "22px 8px 8px",
opacity: props.questionOpacity,
filter: `blur(${props.questionBlur}px)`,
"pointer-events": props.morph < 0.5 ? "none" : "auto",
}}
>
<Button variant="ghost" size="normal" onClick={() => props.onQuestionDismiss?.()}>
{i18n.t("ui.common.dismiss")}
</Button>
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<Show when={(props.questionIndex ?? 0) > 0}>
<Button
variant="secondary"
size="normal"
onClick={() => props.onQuestionBack?.()}
disabled={props.questionBusy}
>
{i18n.t("ui.common.back")}
</Button>
</Show>
<Button
variant={(props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 ? "primary" : "secondary"}
size="normal"
onClick={() => props.onQuestionNext?.()}
disabled={props.questionBusy}
>
{(props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1
? i18n.t("ui.common.submit")
: i18n.t("ui.common.next")}
</Button>
</div>
</div>
</Show>
<Show when={props.showPermission}>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "22px 8px 8px",
opacity: props.questionOpacity,
filter: `blur(${props.questionBlur}px)`,
"pointer-events": props.morph < 0.5 ? "none" : "auto",
}}
>
<div />
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<Button
variant="ghost"
size="normal"
onClick={() => props.onPermissionDecide?.("reject")}
disabled={props.permissionBusy}
>
{i18n.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onPermissionDecide?.("always")}
disabled={props.permissionBusy}
>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="normal"
onClick={() => props.onPermissionDecide?.("once")}
disabled={props.permissionBusy}
>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</Show>
</div>
)
}

View file

@ -0,0 +1,157 @@
import type { JSXElement } from "solid-js"
export interface TodoItem {
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
}
export type ComposerMode = "normal" | "shell"
export type ComposerSource = "enter" | "button"
export type AtOption =
| { type: "agent"; name: string; display: string }
| { type: "file"; path: string; display: string; recent?: boolean }
export interface SlashCommand {
id: string
trigger: string
title: string
description?: string
keybind?: string
type: "builtin" | "custom"
source?: "command" | "mcp" | "skill"
}
export interface ImageAttachment {
id: string
filename: string
mime: string
dataUrl: string
}
export type ComposerPart =
| { type: "text"; content: string }
| { type: "file"; path: string; content: string }
| { type: "agent"; name: string; content: string }
export type ComposerHistoryItem = {
text: string
parts?: ComposerPart[]
}
export interface ContextItem {
id?: string
path: string
selection?: { startLine: number; endLine: number }
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export interface ComposerSubmit {
source: ComposerSource
mode: ComposerMode
text: string
parts: ComposerPart[]
files: ImageAttachment[]
context: ContextItem[]
}
export interface ComposerRuntime {
submit?: (input: ComposerSubmit) => void | Promise<void>
abort?: () => void | Promise<void>
toggleAccept?: () => void | Promise<void>
openModel?: () => void | Promise<void>
cycleAgent?: () => void | Promise<void>
cycleVariant?: () => void | Promise<void>
runSlash?: (cmd: SlashCommand) => boolean | Promise<boolean>
historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
decidePermission?: (response: "once" | "always" | "reject") => void | Promise<void>
submitQuestion?: (answers: string[][]) => void | Promise<void>
rejectQuestion?: () => void | Promise<void>
dialogActive?: () => boolean
readClipboardImage?: () => Promise<File | null>
fileRejected?: (input: { source: "paste" | "drop" | "pick"; file?: File }) => void
searchAt?: (filter: string) => AtOption[] | Promise<AtOption[]>
searchSlash?: (filter: string) => SlashCommand[] | Promise<SlashCommand[]>
openContext?: (item: ContextItem) => void
removeContext?: (item: ContextItem) => void
}
export interface NewComposerProps {
mode?: "input" | "question" | "permission"
questionText?: string
questionOptions?: Array<{ label: string; description?: string }>
questionMultiple?: boolean
questionAnswered?: boolean[]
questionAnswers?: string[][]
placeholder?: string
value?: string
onValueChange?: (value: string) => void
onSubmit?: (input: ComposerSubmit) => void | Promise<void>
onAbort?: () => void | Promise<void>
onAcceptToggle?: () => void | Promise<void>
dialogActive?: boolean
readClipboardImage?: () => Promise<File | null>
onFileRejected?: (input: { source: "paste" | "drop" | "pick"; file?: File }) => void
onSlashCommand?: (cmd: SlashCommand) => boolean | Promise<boolean>
historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
agentName?: string
modelName?: string
variant?: string
agentOptions?: string[]
modelOptions?: string[]
variantOptions?: string[]
agentCurrent?: string
modelCurrent?: string
variantCurrent?: string
onAgentSelect?: (value: string) => void
onModelSelect?: (value: string) => void
onVariantSelect?: (value: string) => void
onModelOpen?: () => void | Promise<void>
onAgentCycle?: () => void | Promise<void>
onVariantCycle?: () => void | Promise<void>
agentKeybind?: string
modelKeybind?: string
variantKeybind?: string
agentControl?: JSXElement
modelControl?: JSXElement
variantControl?: JSXElement
working?: boolean
accepting?: boolean
todos?: TodoItem[]
showTodos?: boolean
todoCollapsed?: boolean
onTodoCollapseChange?: (collapsed: boolean) => void
heightSpring?: { visualDuration: number; bounce: number }
morphSpring?: { visualDuration: number; bounce: number }
atOptions?: AtOption[] | ((filter: string) => AtOption[] | Promise<AtOption[]>)
slashCommands?: SlashCommand[] | ((filter: string) => SlashCommand[] | Promise<SlashCommand[]>)
questionIndex?: number
questionTotal?: number
onQuestionNext?: () => void
onQuestionBack?: () => void
onQuestionDismiss?: () => void
onQuestionSubmit?: (answers: string[][]) => void | Promise<void>
onQuestionReject?: () => void | Promise<void>
onQuestionAnswersChange?: (answers: string[][]) => void
onQuestionJump?: (index: number) => void
questionBusy?: boolean
contextItems?: ContextItem[]
contextActive?: (item: ContextItem) => boolean
onContextOpen?: (item: ContextItem) => void
onContextDrop?: (item: ContextItem) => void
forceDragType?: "image" | "@mention" | null
permissionTool?: string
permissionDescription?: string
permissionPatterns?: string[]
onPermissionDecide?: (response: "once" | "always" | "reject") => void | Promise<void>
permissionBusy?: boolean
runtime?: ComposerRuntime
}
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]

View file

@ -0,0 +1,722 @@
import { createEffect, createSignal, on } from "solid-js"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import {
canNavigateAtCursor,
cloneParts,
navigateEntry,
normalizeEntry,
partText,
prependEntry,
type HistoryEntry,
} from "./history"
import {
createPill,
createTextFragment,
getCursorPosition,
isImeComposing,
parseEditorParts,
parseEditorText,
setCursorPosition,
setRangeEdge,
} from "./editor-utils"
import type { AtOption, ComposerHistoryItem, ComposerMode, ComposerPart, ComposerSource, SlashCommand } from "./types"
interface Props {
value?: string
onValueChange?: (value: string) => void
onSubmit?: (input: {
source: ComposerSource
mode: ComposerMode
text: string
parts: ComposerPart[]
}) => void | Promise<void>
onAbort?: () => void | Promise<void>
onAuto?: () => void | Promise<void>
onPick?: () => void | Promise<void>
onModel?: () => void | Promise<void>
onAgent?: () => void | Promise<void>
onVariant?: () => void | Promise<void>
modelKeybind?: string
agentKeybind?: string
variantKeybind?: string
onSlash?: (cmd: SlashCommand) => boolean | Promise<boolean>
historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
working?: () => boolean
atOptions: AtOption[] | ((filter: string) => AtOption[] | Promise<AtOption[]>)
slashCommands: SlashCommand[] | ((filter: string) => SlashCommand[] | Promise<SlashCommand[]>)
editor: () => HTMLDivElement | undefined
measure: () => void
}
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
type Keybind = {
key: string
ctrl: boolean
meta: boolean
shift: boolean
alt: boolean
}
const normalize = (key: string, code?: string) => {
if (code === "Quote") return "'"
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
if (key === "Dead" && code === "Quote") return "'"
return key.toLowerCase()
}
const parse = (config: string | undefined): Keybind[] => {
if (!config || config === "none") return []
return config.split(",").map((combo) => {
const out: Keybind = {
key: "",
ctrl: false,
meta: false,
shift: false,
alt: false,
}
for (const part of combo.trim().toLowerCase().split("+")) {
if (part === "ctrl" || part === "control") {
out.ctrl = true
continue
}
if (part === "meta" || part === "cmd" || part === "command") {
out.meta = true
continue
}
if (part === "mod") {
if (IS_MAC) out.meta = true
else out.ctrl = true
continue
}
if (part === "alt" || part === "option") {
out.alt = true
continue
}
if (part === "shift") {
out.shift = true
continue
}
out.key = part
}
return out
})
}
const match = (config: string | undefined, event: KeyboardEvent) => {
const key = normalize(event.key, event.code)
return parse(config).some((kb) => {
return (
kb.key === key &&
kb.ctrl === !!event.ctrlKey &&
kb.meta === !!event.metaKey &&
kb.shift === !!event.shiftKey &&
kb.alt === !!event.altKey
)
})
}
export function useEditor(props: Props) {
const [value, setValue] = createSignal(props.value ?? "")
const [mode, setMode] = createSignal<ComposerMode>("normal")
const [popover, setPopover] = createSignal<"at" | "slash" | null>(null)
const [composing, setComposing] = createSignal(false)
const readList = (kind: ComposerMode) => {
const list = props.historyRead?.(kind) ?? []
return list.map(normalizeEntry)
}
const [history, setHistory] = createSignal<Record<ComposerMode, HistoryEntry[]>>({
normal: readList("normal"),
shell: readList("shell"),
})
const [index, setIndex] = createSignal(-1)
const [saved, setSaved] = createSignal<HistoryEntry | null>(null)
const setList = (next: HistoryEntry[]) => {
const kind = mode()
setHistory((prev) => ({ ...prev, [kind]: next }))
props.historyWrite?.(
kind,
next.map((item) => ({
text: item.text,
parts: cloneParts(item.parts),
})),
)
}
const getList = () => history()[mode()]
const setEditorMode = (next: ComposerMode) => {
if (next !== mode() && props.historyRead) {
setHistory((prev) => ({ ...prev, [next]: readList(next) }))
}
setMode(next)
setIndex(-1)
setSaved(null)
setPopover(null)
}
const atKey = (item: AtOption) => (item.type === "file" ? item.path : `agent:${item.name}`)
const at = useFilteredList<AtOption>({
items: props.atOptions,
key: atKey,
filterKeys: ["display", "name", "path"],
onSelect: (item) => {
if (!item) return
insertAt(item)
},
})
const slash = useFilteredList<SlashCommand>({
items: props.slashCommands,
key: (x) => x.id,
filterKeys: ["trigger", "title"],
onSelect: (item) => {
if (!item) return
insertSlash(item)
},
})
createEffect(
on(
() => props.value,
(next) => {
if (next === undefined || next === value()) return
setText(next)
},
{ defer: true },
),
)
const keepCursor = () => {
const el = props.editor()
if (!el) return
const viewport = el.parentElement
if (!(viewport instanceof HTMLElement)) return
if (el.scrollHeight <= viewport.clientHeight + 2) return
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!el.contains(range.startContainer)) return
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect()
const box = viewport.getBoundingClientRect()
const pad = Math.max(4, Math.min(12, Math.floor(viewport.clientHeight * 0.25)))
const top = box.top + pad
const bottom = box.bottom - pad
if (rect.top < top) {
viewport.scrollTop -= top - rect.top
return
}
if (rect.bottom > bottom) {
viewport.scrollTop += rect.bottom - bottom
}
}
const handleInput = () => {
const el = props.editor()
if (!el) return
const text = parseEditorText(el)
setValue(text)
props.onValueChange?.(text)
props.measure()
if (mode() !== "shell") {
const cursor = getCursorPosition(el)
const head = text.substring(0, cursor)
const atMatch = head.match(/@(\S*)$/)
const slashMatch = text.match(/^\/(\S*)$/)
if (atMatch) {
at.onInput(atMatch[1] ?? "")
setPopover("at")
} else if (slashMatch) {
slash.onInput(slashMatch[1] ?? "")
setPopover("slash")
} else {
setPopover(null)
}
}
if (mode() === "shell") setPopover(null)
setIndex(-1)
setSaved(null)
}
const setParts = (parts: ComposerPart[], cursor?: number) => {
const el = props.editor()
if (!el) return
el.textContent = ""
for (const part of parts) {
if (part.type === "text") {
if (!part.content) continue
el.appendChild(createTextFragment(part.content))
continue
}
if (part.type === "file") {
el.appendChild(createPill("file", part.content || `@${part.path}`, part.path))
continue
}
el.appendChild(createPill("agent", part.content || `@${part.name}`))
}
const text = partText(parts)
if (cursor === undefined) {
setCursorPosition(el, text.length)
} else {
setCursorPosition(el, cursor)
}
setValue(text)
props.onValueChange?.(text)
props.measure()
requestAnimationFrame(keepCursor)
}
const setText = (text: string) => {
setParts([{ type: "text", content: text }], text.length)
}
const setEntry = (item: HistoryEntry, cursor: "start" | "end" = "end") => {
setParts(item.parts, cursor === "start" ? 0 : partText(item.parts).length)
}
const deleteSelection = () => {
const el = props.editor()
if (!el) return false
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
if (selection.isCollapsed) return false
const range = selection.getRangeAt(0)
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return false
range.deleteContents()
selection.removeAllRanges()
selection.addRange(range)
handleInput()
return true
}
const deleteSpan = (start: number, end: number) => {
const el = props.editor()
if (!el) return false
if (start >= end) return false
const range = document.createRange()
range.selectNodeContents(el)
setRangeEdge(el, range, "start", start)
setRangeEdge(el, range, "end", end)
range.deleteContents()
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
handleInput()
return true
}
const lineStart = (text: string, cursor: number) => {
const i = text.lastIndexOf("\n", Math.max(0, cursor - 1))
if (i < 0) return 0
return i + 1
}
const lineEnd = (text: string, cursor: number) => {
const i = text.indexOf("\n", cursor)
if (i < 0) return text.length
return i
}
const deleteWordBackward = () => {
const el = props.editor()
if (!el) return false
const text = parseEditorText(el)
const cursor = getCursorPosition(el)
if (cursor <= 0) return false
let i = cursor
while (i > 0 && /\s/.test(text[i - 1] ?? "")) i -= 1
while (i > 0 && !/\s/.test(text[i - 1] ?? "")) i -= 1
if (i === cursor) i = cursor - 1
return deleteSpan(i, cursor)
}
const deleteLineForward = () => {
const el = props.editor()
if (!el) return false
const text = parseEditorText(el)
const cursor = getCursorPosition(el)
if (cursor >= text.length) return false
let i = lineEnd(text, cursor)
if (i === cursor && text[i] === "\n") i += 1
return deleteSpan(cursor, Math.min(i, text.length))
}
const deleteLineBackward = () => {
const el = props.editor()
if (!el) return false
const text = parseEditorText(el)
const cursor = getCursorPosition(el)
if (cursor <= 0) return false
return deleteSpan(lineStart(text, cursor), cursor)
}
const insertAt = (item: AtOption) => {
const el = props.editor()
if (!el) return
const getRange = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
if (!el.contains(range.startContainer)) return null
return { selection, range }
}
let current = getRange()
if (!current) {
el.focus()
setCursorPosition(el, parseEditorText(el).length)
current = getRange()
}
if (!current) return
const selection = current.selection
const range = current.range
const cursor = getCursorPosition(el)
const text = parseEditorText(el)
const head = text.substring(0, cursor)
const match = head.match(/@(\S*)$/)
const label = item.type === "file" ? item.path : `@${item.name}`
const pill = createPill(item.type, label, item.type === "file" ? item.path : undefined)
const gap = document.createTextNode(" ")
if (match) {
const start = match.index ?? cursor - match[0].length
setRangeEdge(el, range, "start", start)
setRangeEdge(el, range, "end", cursor)
}
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
handleInput()
setPopover(null)
}
const applySlash = (cmd: SlashCommand) => {
const el = props.editor()
if (!el) return
el.textContent = ""
el.appendChild(document.createTextNode(`/${cmd.trigger} `))
setCursorPosition(el, cmd.trigger.length + 2)
handleInput()
setPopover(null)
}
const insertSlash = (cmd: SlashCommand) => {
const run = props.onSlash?.(cmd)
if (run instanceof Promise) {
void run
.then((ok) => {
if (ok) {
setText("")
setPopover(null)
return
}
applySlash(cmd)
})
.catch(() => {
applySlash(cmd)
})
return
}
if (run) {
setText("")
setPopover(null)
return
}
applySlash(cmd)
}
const insertFile = (path: string) => {
insertAt({
type: "file",
path,
display: path,
})
}
const parts = (): ComposerPart[] => {
const el = props.editor()
if (!el) return []
return parseEditorParts(el)
}
const submit = (source: ComposerSource) => {
const el = props.editor()
if (!el) return
const text = parseEditorText(el)
const clean = text.trim()
const parsed = parseEditorParts(el)
if (clean) {
const list = getList()
const next = {
text,
parts: cloneParts(parsed),
} satisfies HistoryEntry
const out = prependEntry(list, next)
if (out !== list) setList(out)
}
void props.onSubmit?.({ source, mode: mode(), text, parts: parsed })
}
const abort = () => {
if (!props.working?.()) return
void props.onAbort?.()
}
const handleKeyDown = (event: KeyboardEvent) => {
const el = props.editor()
if (!el) return
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
const cmd = event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
const mod = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey
if (ctrl && event.key.toLowerCase() === "w") {
event.preventDefault()
if (deleteSelection()) return
deleteWordBackward()
return
}
if (ctrl && event.key === "Backspace") {
event.preventDefault()
if (deleteSelection()) return
deleteWordBackward()
return
}
if (!event.metaKey && !event.ctrlKey && !event.shiftKey && event.key === "Backspace" && event.altKey) {
event.preventDefault()
if (deleteSelection()) return
deleteWordBackward()
return
}
if (ctrl && event.key.toLowerCase() === "k") {
event.preventDefault()
if (deleteSelection()) return
deleteLineForward()
return
}
if (cmd && event.key === "Backspace") {
event.preventDefault()
if (deleteSelection()) return
deleteLineBackward()
return
}
if (cmd && event.key === "Delete") {
event.preventDefault()
if (deleteSelection()) return
deleteLineForward()
return
}
if (match(props.modelKeybind ?? "mod+'", event)) {
event.preventDefault()
event.stopPropagation()
void props.onModel?.()
return
}
if (match(props.variantKeybind ?? "shift+mod+d", event)) {
event.preventDefault()
event.stopPropagation()
void props.onVariant?.()
return
}
if (match(props.agentKeybind ?? "mod+.", event)) {
event.preventDefault()
event.stopPropagation()
void props.onAgent?.()
return
}
if (mod && event.key.toLowerCase() === "u") {
event.preventDefault()
if (mode() !== "normal") return
void props.onPick?.()
return
}
if (event.key === "!" && mode() === "normal") {
const cursor = getCursorPosition(el)
if (cursor === 0) {
setEditorMode("shell")
event.preventDefault()
return
}
}
if (event.key === "Escape") {
if (popover()) {
setPopover(null)
event.preventDefault()
event.stopPropagation()
return
}
if (mode() === "shell") {
setEditorMode("normal")
event.preventDefault()
event.stopPropagation()
return
}
if (props.working?.()) {
abort()
event.preventDefault()
event.stopPropagation()
return
}
}
if (mode() === "shell" && event.key === "Backspace") {
if (!parseEditorText(el).trim()) {
setEditorMode("normal")
event.preventDefault()
return
}
}
if (event.key === "Enter" && event.shiftKey) return
if (event.key === "Enter" && isImeComposing(event)) return
if (popover()) {
if (event.key === "Tab") {
if (popover() === "at") {
const selected = at.flat().find((x) => atKey(x) === at.active())
if (selected) insertAt(selected)
} else {
const selected = slash.flat().find((x) => x.id === slash.active())
if (selected) insertSlash(selected)
}
event.preventDefault()
return
}
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
if (nav || ctrlNav) {
if (popover() === "at") at.onKeyDown(event)
else slash.onKeyDown(event)
event.preventDefault()
return
}
}
if (ctrl && event.code === "KeyG") {
if (popover()) {
setPopover(null)
event.preventDefault()
return
}
abort()
event.preventDefault()
return
}
const up = event.key === "ArrowUp" || (ctrl && event.key === "p")
const down = event.key === "ArrowDown" || (ctrl && event.key === "n")
if (up || down) {
if (event.altKey || event.metaKey) return
if (event.ctrlKey && !(event.key === "n" || event.key === "p")) return
if (popover()) return
const text = parseEditorText(el)
const cursor = getCursorPosition(el)
const list = getList()
const dir = up ? "up" : "down"
if (!canNavigateAtCursor(dir, text, cursor, index() >= 0)) return
const out = navigateEntry({
direction: dir,
entries: list,
historyIndex: index(),
current: {
text,
parts: cloneParts(parseEditorParts(el)),
},
saved: saved(),
})
if (!out.handled) return
setIndex(out.historyIndex)
setSaved(out.saved)
setEntry(out.entry, out.cursor)
event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
submit("enter")
}
}
return {
value,
setValue,
popover,
setPopover,
mode,
setMode: setEditorMode,
composing,
setComposing,
atKey,
at,
slash,
handleInput,
handleKeyDown,
setText,
submit,
parts,
insertAt,
insertFile,
insertSlash,
}
}

View file

@ -0,0 +1,87 @@
import { createEffect, createMemo, createSignal } from "solid-js"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
const EDITOR_PADDING = 16
const MAX_EDITOR_CONTENT = 200 - EDITOR_PADDING
const BUTTON_ROW_HEIGHT = 40
const IMAGE_BAR_HEIGHT = 80
const CONTEXT_BAR_HEIGHT = 64
const SHELL_PADDING_INPUT = EDITOR_PADDING + BUTTON_ROW_HEIGHT
const SHELL_PADDING_DOCK = 16
interface Props {
isQuestion: () => boolean
isPermission: () => boolean
imageCount: () => number
contextCount: () => number
heightSpring?: { visualDuration: number; bounce: number }
morphSpring?: { visualDuration: number; bounce: number }
}
export function useLayout(props: Props) {
const [editorHeight, setEditorHeight] = createSignal(24)
const [questionRef, setQuestionRef] = createSignal<HTMLDivElement>()
const [permissionRef, setPermissionRef] = createSignal<HTMLDivElement>()
const questionHeight = useElementHeight(questionRef, 280)
const permissionHeight = useElementHeight(permissionRef, 200)
const imageBarHeight = createMemo(() => (props.imageCount() > 0 ? IMAGE_BAR_HEIGHT : 0))
const contextBarHeight = createMemo(() => (props.contextCount() > 0 ? CONTEXT_BAR_HEIGHT : 0))
const target = createMemo(() => {
if (props.isPermission()) return permissionHeight() + SHELL_PADDING_DOCK
if (props.isQuestion()) return questionHeight() + SHELL_PADDING_DOCK
return editorHeight() + SHELL_PADDING_INPUT + imageBarHeight() + contextBarHeight()
})
const morphTarget = () => (props.isQuestion() || props.isPermission() ? 1 : 0)
const morph = useSpring(morphTarget, () => props.morphSpring ?? { visualDuration: 0.25, bounce: 0.1 })
const spring = useSpring(target, () => props.heightSpring ?? { visualDuration: 0.35, bounce: 0.2 })
const inputOpacity = createMemo(() => Math.max(0, 1 - morph() * 1.5))
const inputScale = createMemo(() => 1 + morph() * 0.15)
const inputBlur = createMemo(() => morph() * 5)
const questionOpacity = createMemo(() => Math.max(0, morph() * 1.5 - 0.5))
const questionScale = createMemo(() => 0.85 + morph() * 0.15)
const questionBlur = createMemo(() => (1 - morph()) * 5)
// Keep overlay content mounted until both morph and height springs settle
const [overlay, setOverlay] = createSignal<"question" | "permission" | null>(null)
createEffect(() => {
if (props.isQuestion()) setOverlay("question")
else if (props.isPermission()) setOverlay("permission")
else if (morph() < 0.01 && Math.abs(spring() - target()) < 2) setOverlay(null)
})
const showQuestion = () => overlay() === "question"
const showPermission = () => overlay() === "permission"
const height = () => spring()
const measure = (el: HTMLDivElement | undefined) => {
if (!el) return
const raw = Math.ceil(el.scrollHeight - EDITOR_PADDING)
const next = Math.min(MAX_EDITOR_CONTENT, Math.max(24, raw))
const curr = editorHeight()
if (Math.abs(next - curr) <= 2) return
setEditorHeight(next)
}
return {
measure,
setQuestionRef,
setPermissionRef,
height,
morph,
inputOpacity,
inputScale,
inputBlur,
questionOpacity,
questionScale,
questionBlur,
showQuestion,
showPermission,
}
}

View file

@ -0,0 +1,76 @@
import { createEffect, createMemo, createSignal, on } from "solid-js"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import type { TodoItem } from "./types"
export const COLLAPSED_HEIGHT = 78
interface Props {
todos: () => TodoItem[]
show: () => boolean
blocked: () => boolean
collapsed?: () => boolean | undefined
onCollapsed?: (value: boolean) => void
shellHeight: () => number
trayHeight: () => number
trayOverlap: number
}
export function useTodo(props: Props) {
const [collapsed, setCollapsedInner] = createSignal(props.collapsed?.() ?? false)
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(
on(
() => props.collapsed?.(),
(next) => {
if (next === undefined) return
setCollapsedInner(next)
},
{ defer: true },
),
)
const setCollapsed = (value: boolean | ((prev: boolean) => boolean)) => {
const next = typeof value === "function" ? value(collapsed()) : value
setCollapsedInner(next)
props.onCollapsed?.(next)
}
const toggle = () => setCollapsed((prev) => !prev)
const open = createMemo(() => props.todos().length > 0 && props.show() && !props.blocked())
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const raw = useElementHeight(contentRef, 200)
const full = useSpring(() => Math.max(COLLAPSED_HEIGHT, raw()), { visualDuration: 0.3, bounce: 0 })
const visible = createMemo(() => {
const max = full()
const cut = max - collapse() * (max - COLLAPSED_HEIGHT)
return cut * progress()
})
const overlap = createMemo(() => 36 * progress())
const shut = createMemo(() => 1 - progress())
const hide = createMemo(() => Math.max(collapse(), shut()))
const bottom = createMemo(() => props.trayHeight() - props.trayOverlap + props.shellHeight() - overlap())
const totalHeight = createMemo(
() => visible() - overlap() + props.shellHeight() + props.trayHeight() - props.trayOverlap,
)
return {
contentRef: setContentRef,
collapsed,
setCollapsed,
toggle,
progress,
collapse,
visible,
shut,
hide,
bottom,
totalHeight,
}
}

View file

@ -0,0 +1,92 @@
[data-component="rolling-results"] {
--rolling-results-row-height: 22px;
--rolling-results-fixed-height: var(--rolling-results-row-height);
--rolling-results-fixed-gap: 0px;
--rolling-results-row-gap: 0px;
display: block;
width: 100%;
min-width: 0;
[data-slot="rolling-results-viewport"] {
position: relative;
min-width: 0;
height: 0;
overflow: clip;
}
&[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black var(--rolling-results-fade),
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black var(--rolling-results-fade),
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
transparent 100%
);
}
[data-slot="rolling-results-fixed"] {
min-width: 0;
height: var(--rolling-results-fixed-height);
min-height: var(--rolling-results-fixed-height);
display: flex;
align-items: center;
}
[data-slot="rolling-results-window"] {
min-width: 0;
margin-top: var(--rolling-results-fixed-gap);
height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
overflow: clip;
}
&[data-scrollable="true"] [data-slot="rolling-results-window"] {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
&[data-scrollable="true"] [data-slot="rolling-results-track"] {
transform: none !important;
will-change: auto;
}
[data-slot="rolling-results-body"] {
min-width: 0;
}
[data-slot="rolling-results-track"] {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--rolling-results-row-gap);
will-change: transform;
}
[data-slot="rolling-results-row"],
[data-slot="rolling-results-empty"] {
min-width: 0;
height: var(--rolling-results-row-height);
min-height: var(--rolling-results-row-height);
display: flex;
align-items: center;
}
[data-slot="rolling-results-row"] {
color: var(--text-base);
}
[data-slot="rolling-results-empty"] {
color: var(--text-weaker);
}
}

View file

@ -0,0 +1,316 @@
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
export type RollingResultsProps<T> = {
items: T[]
render: (item: T, index: number) => JSX.Element
fixed?: JSX.Element
getKey?: (item: T, index: number) => string
rows?: number
rowHeight?: number
fixedHeight?: number
rowGap?: number
open?: boolean
scrollable?: boolean
spring?: SpringConfig
animate?: boolean
class?: string
empty?: JSX.Element
}
export function RollingResults<T>(props: RollingResultsProps<T>) {
let view: HTMLDivElement | undefined
let track: HTMLDivElement | undefined
let windowEl: HTMLDivElement | undefined
let shift: AnimationPlaybackControls | undefined
let resize: AnimationPlaybackControls | undefined
let edgeFade: AnimationPlaybackControls | undefined
const reducedMotion = prefersReducedMotion
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
const fixed = createMemo(() => props.fixed !== undefined)
const list = createMemo(() => props.items ?? [])
const count = createMemo(() => list().length)
// scrollReady is the internal "transition complete" state.
// It only becomes true after props.scrollable is true AND the offset animation has settled.
const [scrollReady, setScrollReady] = createSignal(false)
const backstop = createMemo(() => Math.max(rows() * 2, 12))
const rendered = createMemo(() => {
const items = list()
if (scrollReady()) return items
const max = backstop()
return items.length > max ? items.slice(-max) : items
})
const skipped = createMemo(() => {
if (scrollReady()) return 0
return count() - rendered().length
})
const open = createMemo(() => props.open !== false)
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
const overflowing = createMemo(() => count() > rows())
const shown = createMemo(() => Math.min(rows(), count()))
const step = createMemo(() => rowHeight() + rowGap())
const offset = createMemo(() => Math.max(0, count() - shown()) * step())
const body = createMemo(() => {
if (shown() > 0) {
return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
}
if (props.empty === undefined) return 0
return rowHeight()
})
const gap = createMemo(() => {
if (!fixed()) return 0
if (body() <= 0) return 0
return rowGap()
})
const height = createMemo(() => {
if (!open()) return 0
if (!fixed()) return body()
return fixedHeight() + gap() + body()
})
const key = (item: T, index: number) => {
const value = props.getKey
if (value) return value(item, index)
return String(index)
}
const setTrack = (value: number) => {
if (!track) return
track.style.transform = `translateY(${-Math.round(value)}px)`
}
const setView = (value: number) => {
if (!view) return
view.style.height = `${Math.max(0, Math.round(value))}px`
}
onMount(() => {
setTrack(offset())
})
// Original WAAPI offset animation — untouched rolling behavior.
createEffect(
on(
offset,
(next) => {
if (!track) return
if (scrollReady()) return
if (props.scrollable) return
if (!active()) {
shift?.stop()
shift = undefined
setTrack(next)
return
}
shift?.stop()
const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
shift = anim
anim.finished
.catch(() => {})
.finally(() => {
if (shift !== anim) return
setTrack(next)
shift = undefined
})
},
{ defer: true },
),
)
// Scrollable transition: wait for the offset animation to finish,
// then batch all DOM changes in one synchronous pass.
createEffect(
on(
() => props.scrollable === true,
(isScrollable) => {
if (!isScrollable) {
setScrollReady(false)
if (windowEl) {
windowEl.style.overflowY = ""
windowEl.style.maskImage = ""
windowEl.style.webkitMaskImage = ""
}
return
}
// Wait for the current offset animation to settle (if any).
const done = shift?.finished ?? Promise.resolve()
done.catch(() => {}).then(() => {
if (props.scrollable !== true) return
// Batch the signal update — Solid updates the DOM synchronously:
// rendered() returns all items, skipped() returns 0, padding-top removed,
// data-scrollable becomes "true".
batch(() => setScrollReady(true))
// Now the DOM has all items. Safe to switch layout strategy.
// CSS handles `transform: none !important` on [data-scrollable="true"].
if (windowEl) {
windowEl.style.overflowY = "auto"
windowEl.scrollTop = windowEl.scrollHeight
}
updateScrollMask()
})
},
),
)
// Auto-scroll to bottom when new items arrive in scrollable mode
const [userScrolled, setUserScrolled] = createSignal(false)
const updateScrollMask = () => {
if (!windowEl) return
if (!scrollReady()) {
windowEl.style.maskImage = ""
windowEl.style.webkitMaskImage = ""
return
}
const { scrollTop, scrollHeight, clientHeight } = windowEl
const atBottom = scrollHeight - scrollTop - clientHeight < 8
// Top fade is always present in scrollable mode (matches rolling mode appearance).
// Bottom fade only when not scrolled to the end.
const mask = atBottom
? "linear-gradient(to bottom, transparent 0, black 8px)"
: "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
windowEl.style.maskImage = mask
windowEl.style.webkitMaskImage = mask
}
createEffect(() => {
if (!scrollReady()) {
setUserScrolled(false)
return
}
const _n = count()
const scrolled = userScrolled()
if (scrolled) return
if (windowEl) {
windowEl.scrollTop = windowEl.scrollHeight
updateScrollMask()
}
})
const onWindowScroll = () => {
if (!windowEl || !scrollReady()) return
const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
setUserScrolled(!atBottom)
updateScrollMask()
}
const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
const applyEdge = () => {
if (!view) return
edgeFade?.stop()
edgeFade = undefined
view.style.maskImage = EDGE_MASK
view.style.webkitMaskImage = EDGE_MASK
view.style.maskSize = "100% 100%"
view.style.maskRepeat = "no-repeat"
}
const clearEdge = () => {
if (!view) return
if (!active()) {
clearMaskStyles(view)
return
}
edgeFade?.stop()
const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
edgeFade = anim
anim.finished
.catch(() => {})
.then(() => {
if (edgeFade !== anim || !view) return
clearMaskStyles(view)
edgeFade = undefined
})
}
createEffect(
on(height, (next, prev) => {
if (!view) return
if (!active()) {
resize?.stop()
resize = undefined
setView(next)
clearEdge()
return
}
const collapsing = next === 0 && prev !== undefined && prev > 0
const expanding = prev === 0 && next > 0
resize?.stop()
applyEdge()
const spring = props.spring ?? GROW_SPRING
const anim = collapsing
? animate(view, { height: `${next}px`, opacity: 0 }, spring)
: expanding
? animate(view, { height: `${next}px`, opacity: [0, 1] }, spring)
: animate(view, { height: `${next}px` }, spring)
resize = anim
anim.finished
.catch(() => {})
.finally(() => {
if (resize !== anim) return
setView(next)
if (collapsing || expanding) view!.style.opacity = ""
resize = undefined
clearEdge()
})
}),
)
onCleanup(() => {
shift?.stop()
resize?.stop()
edgeFade?.stop()
shift = undefined
resize = undefined
edgeFade = undefined
})
return (
<div
data-component="rolling-results"
class={props.class}
data-open={open() ? "true" : "false"}
data-overflowing={overflowing() ? "true" : "false"}
data-scrollable={scrollReady() ? "true" : "false"}
data-fixed={fixed() ? "true" : "false"}
style={{
"--rolling-results-row-height": `${rowHeight()}px`,
"--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
"--rolling-results-fixed-gap": `${gap()}px`,
"--rolling-results-row-gap": `${rowGap()}px`,
"--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
}}
>
<div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
<Show when={fixed()}>
<div data-slot="rolling-results-fixed">{props.fixed}</div>
</Show>
<div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
<div data-slot="rolling-results-body">
<Show when={list().length === 0 && props.empty !== undefined}>
<div data-slot="rolling-results-empty">{props.empty}</div>
</Show>
<div ref={track} data-slot="rolling-results-track" style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}>
<For each={rendered()}>
{(item, index) => (
<div data-slot="rolling-results-row" data-key={key(item, index())}>
{props.render(item, index())}
</div>
)}
</For>
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -9,6 +9,9 @@
overflow-y: auto;
scrollbar-width: none;
outline: none;
display: flex;
flex-direction: column-reverse;
overflow-anchor: none;
}
.scroll-view__viewport::-webkit-scrollbar {
@ -45,18 +48,6 @@
background-color: var(--border-strong-base);
}
.dark .scroll-view__thumb::after,
[data-theme="dark"] .scroll-view__thumb::after {
background-color: var(--border-weak-base);
}
.dark .scroll-view__thumb:hover::after,
[data-theme="dark"] .scroll-view__thumb:hover::after,
.dark .scroll-view__thumb[data-dragging="true"]::after,
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
}
.scroll-view__thumb[data-visible="true"] {
opacity: 1;
}

View file

@ -1,17 +1,17 @@
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { FAST_SPRING } from "./motion"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
}
export function ScrollView(props: ScrollViewProps) {
const i18n = useI18n()
const merged = mergeProps({ orientation: "vertical" }, props)
const [local, events, rest] = splitProps(
merged,
["class", "children", "viewportRef", "orientation", "style"],
props,
["class", "children", "viewportRef", "style"],
[
"onScroll",
"onWheel",
@ -25,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) {
],
)
let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
let anim: AnimationPlaybackControls | undefined
const [isHovered, setIsHovered] = createSignal(false)
const [isDragging, setIsDragging] = createSignal(false)
@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) {
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
// With column-reverse: scrollTop=0 is at bottom, negative = scrolled up
// Normalize so 0 = at top, maxScrollTop = at bottom
const normalizedScrollTop = maxScrollTop + scrollTop
const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
// Ensure thumb stays within bounds
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
@ -82,6 +85,7 @@ export function ScrollView(props: ScrollViewProps) {
}
onCleanup(() => {
stop()
observer.disconnect()
})
@ -123,6 +127,30 @@ export function ScrollView(props: ScrollViewProps) {
thumbRef.addEventListener("pointerup", onPointerUp)
}
const stop = () => {
if (!anim) return
anim.stop()
anim = undefined
}
const limit = (top: number) => {
const max = viewportRef.scrollHeight - viewportRef.clientHeight
return Math.max(-max, Math.min(0, top))
}
const glide = (top: number) => {
stop()
anim = animate(viewportRef.scrollTop, limit(top), {
...FAST_SPRING,
onUpdate: (v) => {
viewportRef.scrollTop = v
},
onComplete: () => {
anim = undefined
},
})
}
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@ -147,11 +175,13 @@ export function ScrollView(props: ScrollViewProps) {
break
case "Home":
e.preventDefault()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
// With column-reverse, top of content = -(scrollHeight - clientHeight)
glide(-(viewportRef.scrollHeight - viewportRef.clientHeight))
break
case "End":
e.preventDefault()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
// With column-reverse, bottom of content = 0
glide(0)
break
case "ArrowUp":
e.preventDefault()
@ -166,7 +196,6 @@ export function ScrollView(props: ScrollViewProps) {
return (
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
@ -181,12 +210,21 @@ export function ScrollView(props: ScrollViewProps) {
updateThumb()
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={events.onWheel as any}
onTouchStart={events.onTouchStart as any}
onWheel={(e) => {
if (e.deltaY) stop()
if (typeof events.onWheel === "function") events.onWheel(e as any)
}}
onTouchStart={(e) => {
stop()
if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
}}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
onPointerDown={events.onPointerDown as any}
onPointerDown={(e) => {
stop()
if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
}}
onClick={events.onClick as any}
tabIndex={0}
role="region"

View file

@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const searchHandles = new Map<string, FileSearchHandle>()
const readyFiles = new Set<string>()
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
open: [],
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
force: {},
})

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
[data-component="session-turn"] {
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@ -26,7 +25,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 18px;
gap: 0px;
overflow-anchor: none;
}
@ -43,30 +42,127 @@
align-self: stretch;
}
[data-slot="session-turn-assistant-lane"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
}
[data-slot="session-turn-thinking"] {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
white-space: nowrap;
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;
line-height: var(--line-height-large);
height: 36px;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
> [data-component="text-shimmer"] {
flex: 0 0 auto;
white-space: nowrap;
}
}
[data-slot="session-turn-handoff-wrap"] {
width: 100%;
min-width: 0;
overflow: visible;
}
[data-slot="session-turn-handoff"] {
width: 100%;
min-width: 0;
min-height: 37px;
position: relative;
}
[data-slot="session-turn-thinking"] {
position: absolute;
inset: 0;
will-change: opacity, filter;
transition:
opacity 180ms ease-out,
filter 180ms ease-out,
transform 180ms ease-out;
}
[data-slot="session-turn-thinking"][data-visible="false"] {
opacity: 0;
filter: blur(2px);
transform: translateY(1px);
pointer-events: none;
}
[data-slot="session-turn-thinking"][data-visible="true"] {
opacity: 1;
filter: blur(0px);
transform: translateY(0px);
}
[data-slot="session-turn-meta"] {
position: absolute;
inset: 0;
min-height: 37px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
[data-slot="session-turn-meta"][data-interrupted] {
gap: 12px;
}
[data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
[data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
[data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="session-turn-meta-label"] {
user-select: none;
min-width: 0;
overflow: clip;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-component="text-reveal"].session-turn-thinking-heading {
flex: 1 1 auto;
min-width: 0;
overflow: clip;
white-space: nowrap;
line-height: inherit;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
[data-slot="text-reveal-track"],
[data-slot="text-reveal-entering"],
[data-slot="text-reveal-leaving"] {
min-height: 0;
line-height: inherit;
}
}
.error-card {
@ -84,7 +180,7 @@
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
gap: 0px;
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
@ -109,6 +205,7 @@
[data-component="session-turn-diffs-trigger"] {
width: 100%;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
@ -118,7 +215,7 @@
[data-slot="session-turn-diffs-title"] {
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 8px;
}
@ -135,7 +232,7 @@
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);
line-height: var(--line-height-large);
}
[data-slot="session-turn-diffs-meta"] {
@ -171,8 +268,10 @@
[data-slot="session-turn-diff-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
align-items: baseline;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@ -180,16 +279,22 @@
}
[data-slot="session-turn-diff-directory"] {
color: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
color: var(--text-weak);
min-width: 0;
overflow: clip;
white-space: nowrap;
direction: rtl;
unicode-bidi: plaintext;
text-align: left;
}
[data-slot="session-turn-diff-filename"] {
flex-shrink: 0;
max-width: 100%;
min-width: 0;
overflow: clip;
white-space: nowrap;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}

View file

@ -3,23 +3,27 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { same } from "@opencode-ai/util/array"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
import { GrowBox } from "./grow-box"
import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
import { list } from "./text-utils"
import { SessionRetry } from "./session-retry"
import { Tooltip } from "./tooltip"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@ -73,18 +77,12 @@ function unwrap(message: string) {
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
}
const hidden = new Set(["todowrite", "todoread"])
const emptyMessages: MessageType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const idle: SessionStatus = { type: "idle" as const }
const handoffHoldMs = 120
function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
@ -141,6 +139,7 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
animate?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
@ -159,11 +158,7 @@ export function SessionTurn(
const i18n = useI18n()
const fileComponent = useFileComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@ -191,42 +186,8 @@ export function SessionTurn(
return msg
})
const pending = createMemo(() => {
if (typeof props.active === "boolean" && typeof props.queued === "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 queued = createMemo(() => {
if (typeof props.queued === "boolean") return props.queued
const id = message()?.id
if (!id) return false
if (!pendingUser()) return false
const item = pending()
if (!item) return false
return id > item.id
})
const active = createMemo(() => props.active ?? false)
const queued = createMemo(() => props.queued ?? false)
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
@ -289,7 +250,7 @@ export function SessionTurn(
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
const showAssistantCopyPartID = createMemo(() => {
const assistantCopyPart = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
@ -299,13 +260,18 @@ export function SessionTurn(
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
if (!part || part.type !== "text") continue
const text = part.text?.trim()
if (!text) continue
return {
id: part.id,
text,
message,
}
}
}
return undefined
})
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@ -313,18 +279,14 @@ export function SessionTurn(
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 status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => {
if (status().type === "idle") return false
if (!message()) return false
return active()
})
const working = createMemo(() => status().type !== "idle" && active())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
const assistantCopyPartID = createMemo(() => {
if (working()) return null
return showAssistantCopyPartID() ?? null
})
const showDiffSummary = createMemo(() => edited() > 0 && !working())
const turnDurationMs = createMemo(() => {
const start = message()?.time.created
if (typeof start !== "number") return undefined
@ -364,13 +326,109 @@ export function SessionTurn(
.filter((text): text is string => !!text)
.at(-1),
)
const showThinking = createMemo(() => {
const thinking = createMemo(() => {
if (!working() || !!error()) return false
if (queued()) return false
if (status().type === "retry") return false
if (showReasoningSummaries()) return assistantVisible() === 0
return true
})
const hasAssistant = createMemo(() => assistantMessages().length > 0)
const animateEnabled = createMemo(() => props.animate !== false)
const [live, setLive] = createSignal(false)
const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
const duration = createMemo(() => {
const ms = turnDurationMs()
if (typeof ms !== "number" || ms < 0) return ""
const total = Math.round(ms / 1000)
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}m ${seconds}s`
})
const meta = createMemo(() => {
const item = assistantCopyPart()
if (!item) return ""
const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
const model = item.message.modelID
? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
item.message.modelID
]?.name ?? item.message.modelID)
: ""
return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
})
const [copied, setCopied] = createSignal(false)
const [handoffHold, setHandoffHold] = createSignal(false)
const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
const lane = createMemo(() => hasAssistant() || handoffOpen())
let liveFrame: number | undefined
let copiedTimer: ReturnType<typeof setTimeout> | undefined
let handoffTimer: ReturnType<typeof setTimeout> | undefined
const copyAssistant = async () => {
const text = assistantCopyPart()?.text
if (!text) return
await navigator.clipboard.writeText(text)
setCopied(true)
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copiedTimer = undefined
setCopied(false)
}, 2000)
}
createEffect(
on(
() => [animateEnabled(), working()] as const,
([enabled, isWorking]) => {
if (liveFrame !== undefined) {
cancelAnimationFrame(liveFrame)
liveFrame = undefined
}
if (!enabled || !isWorking || live()) return
liveFrame = requestAnimationFrame(() => {
liveFrame = undefined
setLive(true)
})
},
),
)
createEffect(
on(
() => [thinkingOpen(), metaOpen()] as const,
([thinkingNow, metaNow]) => {
if (handoffTimer !== undefined) {
clearTimeout(handoffTimer)
handoffTimer = undefined
}
if (thinkingNow) {
setHandoffHold(true)
return
}
if (metaNow) {
setHandoffHold(false)
return
}
if (!handoffHold()) return
handoffTimer = setTimeout(() => {
handoffTimer = undefined
setHandoffHold(false)
}, handoffHoldMs)
},
{ defer: true },
),
)
const autoScroll = createAutoScroll({
working,
@ -378,6 +436,119 @@ export function SessionTurn(
overflowAnchor: "dynamic",
})
onCleanup(() => {
if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
if (handoffTimer !== undefined) clearTimeout(handoffTimer)
})
const turnDiffSummary = () => (
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "37px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</Collapsible.Content>
</Collapsible>
</div>
)
const divider = (label: string) => (
<div data-component="compaction-part">
<div data-slot="compaction-part-divider">
<span data-slot="compaction-part-line" />
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
{label}
</span>
<span data-slot="compaction-part-line" />
</div>
</div>
)
return (
<div data-component="session-turn" class={props.classes?.root}>
<div
@ -388,149 +559,120 @@ export function SessionTurn(
>
<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()} interrupted={interrupted()} queued={queued()} />
</div>
<Show when={compaction()}>
<div data-slot="session-turn-compaction">
<Part part={compaction()!} message={message()!} hideDetails />
</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}
{(msg) => (
<div
ref={autoScroll.contentRef}
data-message={msg().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<UserMessageDisplay
message={msg()}
parts={parts()}
interrupted={interrupted()}
animate={props.animate}
queued={queued()}
/>
</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">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
<Show when={compaction()}>
{(part) => (
<GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
<div data-slot="session-turn-compaction">
<Part part={part()} message={msg()} hideDetails />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
</GrowBox>
)}
</Show>
<div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
<Show when={hasAssistant()}>
<div
data-slot="session-turn-assistant-content"
aria-hidden={working()}
style={{ contain: "layout paint" }}
>
<AssistantParts
messages={assistantMessages()}
showAssistantCopyPartID={assistantCopyPartID()}
showTurnDiffSummary={showDiffSummary()}
turnDiffSummary={turnDiffSummary}
working={working()}
animate={live()}
showReasoningSummaries={showReasoningSummaries()}
shellToolDefaultOpen={props.shellToolDefaultOpen}
editToolDefaultOpen={props.editToolDefaultOpen}
/>
</div>
</Show>
<GrowBox
animate={live()}
animateToggle={live()}
open={handoffOpen()}
fade
slot="session-turn-handoff-wrap"
>
<div data-slot="session-turn-handoff">
<div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<TextReveal
text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
class="session-turn-thinking-heading"
travel={25}
duration={900}
/>
</div>
<Show when={metaOpen()}>
<div
data-slot="session-turn-meta"
data-visible={thinkingVisible() ? "false" : "true"}
data-interrupted={interrupted() ? "" : undefined}
>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="top"
gutter={4}
>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void copyAssistant()}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
<Show when={meta()}>
<span
data-slot="session-turn-meta-label"
class="text-12-regular text-text-weak cursor-default"
>
{meta()}
</span>
</Show>
</div>
</Show>
</Collapsible.Content>
</Collapsible>
</div>
</GrowBox>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
<GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
{divider(i18n.t("ui.message.interrupted"))}
</GrowBox>
<SessionRetry status={status()} show={active()} />
<GrowBox
animate={props.animate !== false}
fade
gap={0}
open={showDiffSummary() && !assistantCopyPartID()}
>
{turnDiffSummary()}
</GrowBox>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
)}
</Show>
{props.children}
</div>

View file

@ -0,0 +1,249 @@
import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n"
import { RollingResults } from "./rolling-results"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip"
import {
animate,
clearFadeStyles,
clearMaskStyles,
FAST_SPRING,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { useSpring } from "./motion-spring"
import { busy, createThrottledValue, useToolFade } from "./tool-utils"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
<span data-slot="shell-rolling-subtitle">
<span ref={ref}>{props.text}</span>
</span>
)
}
function firstLine(text: string) {
return text
.split(/\r\n|\n|\r/g)
.map((item) => item.trim())
.find((item) => item.length > 0)
}
function shellRows(output: string) {
const rows: { id: string; text: string }[] = []
const lines = output
.split(/\r\n|\n|\r/g)
.map((item) => item.trimEnd())
.filter((item) => item.length > 0)
const start = Math.max(0, lines.length - 80)
for (let i = start; i < lines.length; i++) {
rows.push({ id: `line:${i}`, text: lines[i]! })
}
return rows
}
function ShellRollingCommand(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
<div data-component="shell-rolling-command">
<span ref={ref} data-slot="shell-rolling-text">
<span data-slot="shell-rolling-prompt">$</span> {props.text}
</span>
</div>
)
}
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
const i18n = useI18n()
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
const [userToggled, setUserToggled] = createSignal(false)
const [userOpen, setUserOpen] = createSignal(false)
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record<string, any>)
const pending = createMemo(() => busy(props.part.state.status))
// autoOpen starts true if pending, false if already complete (e.g. scrolling back in history).
// When pending transitions false, autoOpen is still true — no intermediate state — then
// a 2s timer sets it false to trigger the collapse. This avoids the flash caused by
// holdOpen being set in a late-running effect.
const [autoOpen, setAutoOpen] = createSignal(pending())
createEffect(on(pending, (isPending, wasPending) => {
if (isPending) {
setAutoOpen(true)
} else if (wasPending && !userToggled()) {
const timer = setTimeout(() => setAutoOpen(false), 2000)
onCleanup(() => clearTimeout(timer))
}
}))
const effectiveOpen = createMemo(() => {
if (pending()) return true
if (userToggled()) return userOpen()
return autoOpen()
})
const subtitle = createMemo(() => {
const value = state().input?.description ?? state().metadata?.description
if (typeof value === "string") return value
return ""
})
const command = createMemo(() => {
const value = state().input?.command ?? state().metadata?.command
if (typeof value === "string") return value
return ""
})
const output = createMemo(() => {
const value = state().output ?? state().metadata?.output
if (typeof value === "string") return value
return ""
})
const reduce = prefersReducedMotion
const skip = () => reduce() || props.animate === false
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
let headerClipRef: HTMLDivElement | undefined
const [copied, setCopied] = createSignal(false)
const handleHeaderClick = () => {
if (pending()) return
const el = headerClipRef
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
const beforeY = el?.getBoundingClientRect().top ?? 0
setUserToggled(true)
setUserOpen((prev) => !prev)
if (viewport && el) {
requestAnimationFrame(() => {
const afterY = el.getBoundingClientRect().top
const delta = afterY - beforeY
if (delta !== 0) viewport.scrollTop += delta
})
}
}
const handleCopy = async (e: MouseEvent) => {
e.stopPropagation()
const cmd = command()
const out = stripAnsi(output())
const content = `$ ${cmd}${out ? "\n" + out : ""}`
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const line = createMemo(() => firstLine(command()))
const fixed = createMemo(() => {
const value = line()
if (!value) return
return <ShellRollingCommand text={value} animate={props.animate} />
})
const text = createThrottledValue(() => stripAnsi(output()))
const rows = createMemo(() => shellRows(text()))
return (
<div
data-component="shell-rolling-results"
style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
>
<div
ref={headerClipRef}
data-slot="shell-rolling-header-clip"
data-scroll-preserve
data-clickable={!pending() ? "true" : "false"}
onClick={handleHeaderClick}
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
>
<div data-slot="shell-rolling-header">
<span data-slot="shell-rolling-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={subtitle()}>
{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}
</Show>
<Show when={!pending()}>
<span data-slot="shell-rolling-actions">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="ghost"
class="shell-rolling-copy"
onMouseDown={(e: MouseEvent) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
<Icon name="chevron-down" size="small" />
</span>
</span>
</Show>
</div>
</div>
<RollingResults
class="shell-rolling-output"
items={rows()}
fixed={fixed()}
fixedHeight={22}
rows={5}
rowHeight={22}
rowGap={0}
open={effectiveOpen()}
scrollable={!pending() && !autoOpen() && userToggled() && userOpen()}
spring={userToggled() ? FAST_SPRING : undefined}
animate={props.animate !== false}
getKey={(row) => row.id}
render={(row) => {
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
createEffect(() => {
const el = textRef()
if (!el || !row.text) return
if (wiped.has(row.id)) return
wiped.add(row.id)
if (reduce()) return
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
animate(
el,
{
opacity: [0, 1],
filter: ["blur(2px)", "blur(0px)"],
transform: ["translateX(-0.06em)", "translateX(0)"],
maskPosition: "0% 0%",
},
GROW_SPRING,
).finished.then(() => {
if (!el) return
clearFadeStyles(el)
clearMaskStyles(el)
})
})
return (
<div data-component="shell-rolling-row">
<span ref={setTextRef} data-slot="shell-rolling-text">
{row.text}
</span>
</div>
)
}}
/>
</div>
)
}

View file

@ -1,329 +0,0 @@
// @ts-nocheck
import { createEffect, createSignal, onCleanup } from "solid-js"
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 [text, setText] = createSignal("Prints five topic blocks between timed commands")
const [show, setShow] = createSignal(true)
const [visualDuration, setVisualDuration] = createSignal(0.35)
const [bounce, setBounce] = createSignal(0)
const [fadeMs, setFadeMs] = createSignal(320)
const [blur, setBlur] = createSignal(2)
const [fadeEase, setFadeEase] = createSignal<keyof typeof ease>("snappy")
const [auto, setAuto] = createSignal(false)
let replayTimer
let autoTimer
const replay = () => {
setShow(false)
if (replayTimer) clearTimeout(replayTimer)
replayTimer = setTimeout(() => {
setShow(true)
}, 50)
}
const stopAuto = () => {
if (autoTimer) clearInterval(autoTimer)
autoTimer = undefined
setAuto(false)
}
const toggleAuto = () => {
if (auto()) {
stopAuto()
return
}
setAuto(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={() => setShow((v) => !v)} 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) => setText(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) => setVisualDuration(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) => setBounce(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={() =>
setFadeEase((v) =>
v === "snappy" ? "smooth" : v === "smooth" ? "standard" : v === "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) => setFadeMs(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) => setBlur(Number(e.currentTarget.value))}
/>
<span style={sliderValue}>{blur()}px</span>
</div>
</div>
</div>
)
},
}

View file

@ -1,23 +1,13 @@
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
display: inline-block;
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;
}

View file

@ -4,14 +4,14 @@
* Instead of sliding text through a fixed mask (odometer style),
* the mask itself sweeps across each span to reveal/hide text.
*
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
* Direction: bottom-to-top. New text rises in from below, old text exits upward.
*
* Entering: gradient reveals top-to-bottom (top of text appears first).
* Entering: gradient reveals bottom-to-top (bottom of text appears first).
* gradient(to bottom, white 33%, transparent 33%+edge)
* pos 0 100% = transparent covers element = hidden
* pos 0 0% = white covers element = visible
*
* Leaving: gradient hides top-to-bottom (top of text disappears first).
* Leaving: gradient hides bottom-to-top (bottom of text disappears first).
* gradient(to top, white 33%, transparent 33%+edge)
* pos 0 100% = white covers element = visible
* pos 0 0% = transparent covers element = hidden
@ -56,17 +56,17 @@
transition-timing-function: var(--_spring);
}
/* entering: reveal top-to-bottom
* Gradient(to top): white at bottom, transparent at top of mask.
* Settled pos 0 100% = white covers element = visible
* Swap pos 0 0% = transparent covers = hidden
* Slides from above: translateY(-travel) translateY(0)
/* entering: reveal bottom-to-top
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Settled pos 0 0% = white covers element = visible
* Swap pos 0 100% = transparent covers = hidden
* Rises from below: translateY(travel) translateY(0)
*/
[data-slot="text-reveal-entering"] {
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transition-property:
mask-position,
-webkit-mask-position,
@ -74,37 +74,37 @@
transform: translateY(0);
}
/* leaving: hide top-to-bottom + slide downward
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Swap pos 0 0% = white covers element = visible
* Settled pos 0 100% = transparent covers = hidden
* Slides down: translateY(0) translateY(travel)
/* leaving: hide bottom-to-top + slide upward
* Gradient(to top): white at bottom, transparent at top of mask.
* Swap pos 0 100% = white covers element = visible
* Settled pos 0 0% = transparent covers = hidden
* Slides up: translateY(0) translateY(-travel)
*/
[data-slot="text-reveal-leaving"] {
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transition-property:
mask-position,
-webkit-mask-position,
transform;
transform: translateY(var(--_travel));
transform: translateY(calc(var(--_travel) * -1));
}
/* swapping: instant reset
* Snap entering to hidden (above), leaving to visible (center).
* Snap entering to hidden (below), leaving to visible (center).
*/
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transform: translateY(calc(var(--_travel) * -1));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transform: translateY(var(--_travel));
transition-duration: 0ms !important;
}
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transform: translateY(0);
transition-duration: 0ms !important;
}
@ -126,15 +126,14 @@
&[data-truncate="true"] [data-slot="text-reveal-track"] {
width: 100%;
min-width: 0;
overflow: hidden;
overflow: clip;
}
&[data-truncate="true"] [data-slot="text-reveal-entering"],
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
min-width: 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
overflow: clip;
}
}

View file

@ -1,310 +0,0 @@
// @ts-nocheck
import { createSignal, onCleanup } from "solid-js"
import { TextReveal } from "./text-reveal"
export default {
title: "UI/TextReveal",
id: "components-text-reveal",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Playground for the TextReveal text transition component.
**Hybrid** mask wipe + vertical slide: gradient sweeps AND text moves downward.
**Wipe only** pure mask wipe: gradient sweeps top-to-bottom, text stays in place.`,
},
},
},
}
const TEXTS = [
"Refactor ToolStatusTitle DOM measurement",
"Remove inline measure nodes",
"Run typechecks and report changes",
"Verify reduced-motion behavior",
"Review diff for animation edge cases",
"Check keyboard semantics",
undefined,
"Planning key generation details",
"Analyzing error handling",
"Considering edge cases",
]
const btn = (accent?: boolean) =>
({
padding: "5px 12px",
"border-radius": "6px",
border: accent ? "1px solid var(--color-accent, #58f)" : "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": "12px",
}) as const
const sliderLabel = {
width: "90px",
"font-size": "12px",
color: "var(--color-text-secondary, #a3a3a3)",
"flex-shrink": "0",
} as const
const cardStyle = {
padding: "20px 24px",
"border-radius": "10px",
border: "1px solid var(--color-divider, #333)",
background: "var(--color-fill-element, #1a1a1a)",
display: "grid",
gap: "12px",
} as const
const cardLabel = {
"font-size": "11px",
"font-family": "monospace",
color: "var(--color-text-weak, #666)",
} as const
const previewRow = {
display: "flex",
"align-items": "center",
gap: "8px",
"font-size": "14px",
"font-weight": "500",
"line-height": "20px",
color: "var(--text-weak, #aaa)",
"min-height": "20px",
overflow: "visible",
} as const
const headingSlot = {
"min-width": "0",
overflow: "visible",
color: "var(--text-weaker, #888)",
"font-weight": "400",
} as const
export const Playground = {
render: () => {
const [index, setIndex] = createSignal(0)
const [cycling, setCycling] = createSignal(false)
const [growOnly, setGrowOnly] = createSignal(true)
const [duration, setDuration] = createSignal(600)
const [bounce, setBounce] = createSignal(1.0)
const [bounceSoft, setBounceSoft] = createSignal(1.0)
const [hybridTravel, setHybridTravel] = createSignal(25)
const [hybridEdge, setHybridEdge] = createSignal(17)
const [edge, setEdge] = createSignal(17)
const [revealTravel, setRevealTravel] = createSignal(0)
let timer: number | undefined
const text = () => TEXTS[index()]
const next = () => setIndex((i) => (i + 1) % TEXTS.length)
const prev = () => setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length)
const toggleCycle = () => {
if (cycling()) {
if (timer) clearTimeout(timer)
timer = undefined
setCycling(false)
return
}
setCycling(true)
const tick = () => {
next()
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
}
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
}
onCleanup(() => {
if (timer) clearTimeout(timer)
})
const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`
const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)`
return (
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "700px" }}>
<div style={{ display: "grid", gap: "16px" }}>
<div style={cardStyle}>
<span style={cardLabel}>text-reveal (mask wipe + slide)</span>
<div style={previewRow}>
<span>Thinking</span>
<span style={headingSlot}>
<TextReveal
class="text-14-regular"
text={text()}
duration={duration()}
edge={hybridEdge()}
travel={hybridTravel()}
spring={spring()}
springSoft={springSoft()}
growOnly={growOnly()}
/>
</span>
</div>
</div>
<div style={cardStyle}>
<span style={cardLabel}>text-reveal (mask wipe only)</span>
<div style={previewRow}>
<span>Thinking</span>
<span style={headingSlot}>
<TextReveal
class="text-14-regular"
text={text()}
duration={duration()}
edge={edge()}
travel={revealTravel()}
spring={spring()}
springSoft={springSoft()}
growOnly={growOnly()}
/>
</span>
</div>
</div>
</div>
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
{TEXTS.map((t, i) => (
<button onClick={() => setIndex(i)} style={btn(index() === i)}>
{t ?? "(none)"}
</button>
))}
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={prev} style={btn()}>
Prev
</button>
<button onClick={next} style={btn()}>
Next
</button>
<button onClick={toggleCycle} style={btn(cycling())}>
{cycling() ? "Stop cycle" : "Auto cycle"}
</button>
<button onClick={() => setGrowOnly((v) => !v)} style={btn(growOnly())}>
{growOnly() ? "growOnly: on" : "growOnly: off"}
</button>
</div>
<div style={{ display: "grid", gap: "8px", "max-width": "480px" }}>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)" }}>Hybrid (wipe + slide)</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>edge</span>
<input
type="range"
min="1"
max="40"
step="1"
value={hybridEdge()}
onInput={(e) => setHybridEdge(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridEdge()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>travel</span>
<input
type="range"
min="0"
max="40"
step="1"
value={hybridTravel()}
onInput={(e) => setHybridTravel(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridTravel()}px</span>
</label>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Shared</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>duration</span>
<input
type="range"
min="100"
max="1400"
step="10"
value={duration()}
onInput={(e) => setDuration(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{duration()}ms</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>bounce</span>
<input
type="range"
min="1"
max="2"
step="0.01"
value={bounce()}
onInput={(e) => setBounce(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounce().toFixed(2)}</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>bounce soft</span>
<input
type="range"
min="1"
max="1.5"
step="0.01"
value={bounceSoft()}
onInput={(e) => setBounceSoft(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounceSoft().toFixed(2)}</span>
</label>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>
Wipe only
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>edge</span>
<input
type="range"
min="1"
max="40"
step="1"
value={edge()}
onInput={(e) => setEdge(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{edge()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={sliderLabel}>travel</span>
<input
type="range"
min="0"
max="16"
step="1"
value={revealTravel()}
onInput={(e) => setRevealTravel(e.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{revealTravel()}px</span>
</label>
</div>
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #888)", "font-family": "monospace" }}>
text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"}
</div>
</div>
)
},
}

View file

@ -1,4 +1,6 @@
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
import { animate, type AnimationPlaybackControls, clearFadeStyles, clearMaskStyles, GROW_SPRING, WIPE_MASK } from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
const px = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}px`
@ -17,6 +19,11 @@ const pct = (value: number | undefined, fallback: number) => {
return `${v}%`
}
const clearWipe = (el: HTMLElement) => {
clearFadeStyles(el)
clearMaskStyles(el)
}
export function TextReveal(props: {
text?: string
class?: string
@ -39,10 +46,8 @@ export function TextReveal(props: {
let outRef: HTMLSpanElement | undefined
let rootRef: HTMLSpanElement | undefined
let frame: number | undefined
const win = () => inRef?.scrollWidth ?? 0
const wout = () => outRef?.scrollWidth ?? 0
const widen = (next: number) => {
if (next <= 0) return
if (props.growOnly ?? true) {
@ -51,21 +56,14 @@ export function TextReveal(props: {
}
setWidth(`${next}px`)
}
createEffect(
on(
() => props.text,
(next, prev) => {
if (next === prev) return
if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
setCur(next)
widen(win())
return
}
setSwapping(true)
setOld(prev)
setCur(next)
if (typeof requestAnimationFrame !== "function") {
widen(Math.max(win(), wout()))
rootRef?.offsetHeight
@ -133,3 +131,94 @@ export function TextReveal(props: {
</span>
)
}
export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
let frame: number | undefined
let anim: AnimationPlaybackControls | undefined
const run = () => {
if (props.animate === false) return
const el = ref
if (!el || !props.text || typeof window === "undefined") return
if (prefersReducedMotion()) return
const mask =
typeof CSS !== "undefined" &&
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
anim?.stop()
if (frame !== undefined && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(frame)
frame = undefined
}
el.style.opacity = "0"
el.style.filter = "blur(3px)"
el.style.transform = "translateX(-0.06em)"
if (mask) {
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
}
if (typeof requestAnimationFrame !== "function") {
clearWipe(el)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
const node = ref
if (!node) return
anim = mask
? animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
{ ...GROW_SPRING, delay: props.delay ?? 0 },
)
: animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)" },
{ ...GROW_SPRING, delay: props.delay ?? 0 },
)
anim?.finished.then(() => {
const value = ref
if (!value) return
clearWipe(value)
})
})
}
createEffect(
on(
() => [props.text, props.animate] as const,
([text, enabled]) => {
if (!text || enabled === false) {
if (ref) clearWipe(ref)
return
}
run()
},
),
)
onCleanup(() => {
if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
anim?.stop()
})
return (
<span ref={ref} class={props.class} aria-label={props.text ?? ""}>
{props.text ?? "\u00A0"}
</span>
)
}

View file

@ -1,11 +1,11 @@
[data-component="text-shimmer"] {
--text-shimmer-step: 45ms;
--text-shimmer-duration: 1200ms;
--text-shimmer-duration: 2000ms;
--text-shimmer-swap: 220ms;
--text-shimmer-index: 0;
--text-shimmer-angle: 90deg;
--text-shimmer-spread: 5.2ch;
--text-shimmer-size: 360%;
--text-shimmer-size: 600%;
--text-shimmer-base-color: var(--text-weak);
--text-shimmer-peak-color: var(--text-strong);
--text-shimmer-sweep: linear-gradient(
@ -16,15 +16,17 @@
);
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
display: inline-flex;
align-items: baseline;
display: inline-block;
vertical-align: baseline;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
display: inline-grid;
display: inline-block;
position: relative;
vertical-align: baseline;
white-space: pre;
font: inherit;
letter-spacing: inherit;
@ -33,7 +35,7 @@
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
grid-area: 1 / 1;
display: inline-block;
white-space: pre;
transition: opacity var(--text-shimmer-swap) ease-out;
font: inherit;
@ -42,11 +44,14 @@
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
position: relative;
color: inherit;
opacity: 1;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
position: absolute;
inset: 0;
color: var(--text-weaker);
opacity: 0;
}

View file

@ -1,92 +0,0 @@
// @ts-nocheck
import * as mod from "./text-shimmer"
import { useArgs } from "storybook/preview-api"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Animated shimmer effect for loading text placeholders.
Use for pending states inside buttons or list rows.
### API
- Required: \`text\` string.
- Optional: \`as\`, \`active\`, \`offset\`, \`class\`.
### Variants and states
- Active/inactive state via \`active\`.
### Behavior
- Uses a moving gradient sweep clipped to text.
- \`offset\` lets multiple shimmers run out-of-phase.
### Accessibility
- Uses \`aria-label\` with the full text.
### Theming/tokens
- Uses \`data-component="text-shimmer"\` and CSS custom properties for timing.
`
const defaults = {
text: "Loading...",
active: true,
class: "text-14-medium text-text-strong",
offset: 0,
} as const
const story = create({ title: "UI/TextShimmer", mod, args: defaults })
export default {
title: "UI/TextShimmer",
id: "components-text-shimmer",
component: story.meta.component,
tags: ["autodocs"],
args: defaults,
argTypes: {
text: { control: "text" },
class: { control: "text" },
active: { control: "boolean" },
offset: { control: { type: "range", min: 0, max: 80, step: 1 } },
},
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
args: defaults,
render: (args) => {
const [, updateArgs] = useArgs()
const reset = () => updateArgs(defaults)
return (
<div style={{ display: "grid", gap: "12px", "justify-items": "start" }}>
<mod.TextShimmer {...args} />
<button
onClick={reset}
style={{
padding: "4px 10px",
"font-size": "12px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
}}
>
Reset controls
</button>
</div>
)
},
}
export const Inactive = {
args: {
text: "Static text",
active: false,
},
}

View file

@ -36,6 +36,19 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
clearTimeout(timer)
})
const shimmerSize = createMemo(() => {
const len = Math.max(props.text.length, 1)
return Math.max(300, Math.round(200 + 1400 / len))
})
// duration = len × (size - 1) / velocity → uniform perceived sweep speed
const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline
const shimmerDuration = createMemo(() => {
const len = Math.max(props.text.length, 1)
const s = shimmerSize() / 100
return Math.max(1000, Math.min(2500, Math.round((len * (s - 1)) / VELOCITY)))
})
return (
<Dynamic
component={props.as ?? "span"}
@ -46,6 +59,8 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
style={{
"--text-shimmer-swap": `${swap}ms`,
"--text-shimmer-index": `${offset()}`,
"--text-shimmer-size": `${shimmerSize()}%`,
"--text-shimmer-duration": `${shimmerDuration()}ms`,
}}
>
<span data-slot="text-shimmer-char">

View file

@ -0,0 +1,17 @@
/** Find the longest common character prefix between two strings. */
export function commonPrefix(a: string, b: string) {
const ac = Array.from(a)
const bc = Array.from(b)
let i = 0
while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++
return {
prefix: ac.slice(0, i).join(""),
aSuffix: ac.slice(i).join(""),
bSuffix: bc.slice(i).join(""),
}
}
export function list<T>(value: T[] | undefined | null, fallback: T[]): T[] {
if (Array.isArray(value)) return value
return fallback
}

View file

@ -1,584 +0,0 @@
// @ts-nocheck
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useGlobalSync } 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 = useGlobalSync()
const [open, setOpen] = createSignal(true)
const [step, setStep] = createSignal(1)
const [dockOpenDuration, setDockOpenDuration] = createSignal(0.3)
const [dockOpenBounce, setDockOpenBounce] = createSignal(0)
const [dockCloseDuration, setDockCloseDuration] = createSignal(0.3)
const [dockCloseBounce, setDockCloseBounce] = createSignal(0)
const [drawerExpandDuration, setDrawerExpandDuration] = createSignal(0.3)
const [drawerExpandBounce, setDrawerExpandBounce] = createSignal(0)
const [drawerCollapseDuration, setDrawerCollapseDuration] = createSignal(0.3)
const [drawerCollapseBounce, setDrawerCollapseBounce] = createSignal(0)
const [subtitleDuration, setSubtitleDuration] = createSignal(600)
const [subtitleAuto, setSubtitleAuto] = createSignal(true)
const [subtitleTravel, setSubtitleTravel] = createSignal(25)
const [subtitleEdge, setSubtitleEdge] = createSignal(17)
const [countDuration, setCountDuration] = createSignal(600)
const [countMask, setCountMask] = createSignal(18)
const [countMaskHeight, setCountMaskHeight] = createSignal(0)
const [countWidthDuration, setCountWidthDuration] = createSignal(560)
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()
setOpen(true)
frame = requestAnimationFrame(() => {
pin()
frame = undefined
})
}
const closeDock = () => {
clear()
setOpen(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 = () => {
setStep((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={() => setStep(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) => setDockOpenDuration(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) => setDockOpenBounce(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) => setDockCloseDuration(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) => setDockCloseBounce(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) => setDrawerExpandDuration(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) => setDrawerExpandBounce(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) => setDrawerCollapseDuration(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) => setDrawerCollapseBounce(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) => setSubtitleDuration(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) => setSubtitleAuto(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) => setSubtitleTravel(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) => setSubtitleEdge(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) => setCountDuration(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) => setCountMask(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) => setCountMaskHeight(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) => setCountWidthDuration(event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countWidthDuration())}ms
</span>
</label>
</div>
</div>
)
},
}

View file

@ -27,10 +27,10 @@
grid-template-columns: 0fr;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
overflow: hidden;
overflow: clip;
transform: translateX(-0.04em);
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration: 250ms, 250ms, 250ms, 250ms;
transition-duration: 800ms, 400ms, 400ms, 800ms;
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));
@ -45,7 +45,7 @@
[data-slot="tool-count-label-suffix-inner"] {
min-width: 0;
overflow: hidden;
overflow: clip;
white-space: pre;
}
}

View file

@ -1,5 +1,6 @@
import { createMemo } from "solid-js"
import { AnimatedNumber } from "./animated-number"
import { commonPrefix } from "./text-utils"
function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
@ -11,35 +12,23 @@ function split(text: string) {
}
}
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 suffix = createMemo(() => commonPrefix(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 stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after))
const tail = createMemo(() => {
if (!splitSuffix()) return ""
if (singular()) return suffix().one
return suffix().other
if (singular()) return suffix().aSuffix
return suffix().bSuffix
})
const showTail = createMemo(() => splitSuffix() && tail().length > 0)

View file

@ -10,12 +10,12 @@
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
overflow: hidden;
overflow: clip;
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);
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-spring-ms, 800ms);
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));
@ -35,12 +35,12 @@
opacity: 0;
filter: blur(var(--tool-motion-blur, 2px));
transform: translateY(0.06em) scale(0.985);
overflow: hidden;
overflow: clip;
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);
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-spring-ms, 800ms);
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));
@ -55,7 +55,7 @@
[data-slot="tool-count-summary-empty-inner"] {
min-width: 0;
overflow: hidden;
overflow: clip;
white-space: nowrap;
}
@ -63,7 +63,7 @@
display: inline-flex;
align-items: baseline;
min-width: 0;
overflow: hidden;
overflow: clip;
white-space: nowrap;
}
@ -75,12 +75,12 @@
margin-right: 0;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
overflow: hidden;
overflow: clip;
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);
var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-fade-ms, 400ms);
transition-timing-function: ease-out, ease-out, ease-out;
}

View file

@ -18,9 +18,8 @@
[data-slot="tool-status-swap"],
[data-slot="tool-status-tail"] {
display: inline-grid;
overflow: hidden;
overflow: clip;
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"],
@ -31,8 +30,8 @@
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);
var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8),
calc(var(--tool-motion-fade-ms, 400ms) * 0.8);
transition-timing-function: ease-out, ease-out, ease-out;
}

View file

@ -1,17 +1,8 @@
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
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(""),
}
}
import { commonPrefix } from "./text-utils"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
function contentWidth(el: HTMLSpanElement | undefined) {
if (!el) return 0
@ -27,25 +18,58 @@ export function ToolStatusTitle(props: {
class?: string
split?: boolean
}) {
const split = createMemo(() => common(props.activeText, props.doneText))
const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
const suffix = createMemo(
() => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
() => (props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.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 activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText))
const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText))
const [width, setWidth] = createSignal("auto")
const [ready, setReady] = createSignal(false)
let activeRef: HTMLSpanElement | undefined
let doneRef: HTMLSpanElement | undefined
let swapRef: HTMLSpanElement | undefined
let tailRef: HTMLSpanElement | undefined
let frame: number | undefined
let readyFrame: number | undefined
let widthAnim: AnimationPlaybackControls | undefined
const node = () => (suffix() ? tailRef : swapRef)
const reduce = prefersReducedMotion
const setNodeWidth = (width: string) => {
if (swapRef) swapRef.style.width = width
if (tailRef) tailRef.style.width = width
}
const measure = () => {
const target = props.active ? activeRef : doneRef
const px = contentWidth(target)
if (px > 0) setWidth(`${px}px`)
const next = contentWidth(target)
if (next <= 0) return
const ref = node()
if (!ref || !ready() || reduce()) {
widthAnim?.stop()
setNodeWidth(`${next}px`)
return
}
const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width))
if (Math.abs(next - prev) < 1) {
ref.style.width = `${next}px`
return
}
ref.style.width = `${prev}px`
widthAnim?.stop()
widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING)
widthAnim.finished.then(() => {
const el = node()
if (!el) return
el.style.width = `${next}px`
})
}
const schedule = () => {
@ -90,6 +114,7 @@ export function ToolStatusTitle(props: {
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
widthAnim?.stop()
})
return (
@ -104,7 +129,7 @@ export function ToolStatusTitle(props: {
<Show
when={suffix()}
fallback={
<span data-slot="tool-status-swap" style={{ width: width() }}>
<span data-slot="tool-status-swap" ref={swapRef}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={0} />
</span>
@ -118,7 +143,7 @@ export function ToolStatusTitle(props: {
<span data-slot="tool-status-prefix">
<TextShimmer text={split().prefix} active={props.active} offset={0} />
</span>
<span data-slot="tool-status-tail" style={{ width: width() }}>
<span data-slot="tool-status-tail" ref={tailRef}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
</span>

View file

@ -0,0 +1,154 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import type { ToolPart } from "@opencode-ai/sdk/v2"
export const TEXT_RENDER_THROTTLE_MS = 100
export function createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
let last = 0
createEffect(() => {
const next = getValue()
const now = Date.now()
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
last = now
setValue(next)
return
}
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
last = Date.now()
setValue(next)
timeout = undefined
}, remaining)
})
onCleanup(() => {
if (timeout) clearTimeout(timeout)
})
return value
}
export function busy(status: string | undefined) {
return status === "pending" || status === "running"
}
export function hold(state: () => boolean, wait = 2000) {
const [live, setLive] = createSignal(state())
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
if (state()) {
if (timer) clearTimeout(timer)
timer = undefined
setLive(true)
return
}
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
setLive(false)
}, wait)
})
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return live
}
export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
const [settled, setSettled] = createSignal(false)
createEffect(() => {
if (!anyRunning() && !working?.()) setSettled(true)
})
return createMemo(() => !settled() && (!!working?.() || anyRunning()))
}
export function useToolFade(
ref: () => HTMLElement | undefined,
options?: { delay?: number; wipe?: boolean; animate?: boolean },
) {
let anim: AnimationPlaybackControls | undefined
let frame: number | undefined
const delay = options?.delay ?? 0
const wipe = options?.wipe ?? false
const active = options?.animate !== false
onMount(() => {
if (!active) return
const el = ref()
if (!el || typeof window === "undefined") return
if (prefersReducedMotion()) return
const mask =
wipe &&
typeof CSS !== "undefined" &&
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
el.style.opacity = "0"
el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
if (mask) {
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
}
frame = requestAnimationFrame(() => {
frame = undefined
const node = ref()
if (!node) return
anim = wipe
? mask
? animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
{ ...GROW_SPRING, delay },
)
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
anim?.finished.then(() => {
const value = ref()
if (!value) return
clearFadeStyles(value)
if (mask) clearMaskStyles(value)
})
})
})
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
anim?.stop()
})
}

View file

@ -1,6 +1,8 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { animate, type AnimationPlaybackControls } from "motion"
import { FAST_SPRING } from "../components/motion"
export interface AutoScrollOptions {
working: () => boolean
@ -9,13 +11,28 @@ export interface AutoScrollOptions {
bottomThreshold?: number
}
const SETTLE_MS = 500
const AUTO_SCROLL_GRACE_MS = 120
const AUTO_SCROLL_EPSILON = 0.5
const MANUAL_ANCHOR_MS = 3000
const MANUAL_ANCHOR_QUIET_FRAMES = 24
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let autoTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let auto: { top: number; time: number } | undefined
let programmaticUntil = 0
let scrollAnim: AnimationPlaybackControls | undefined
let hold:
| {
el: HTMLElement
top: number
until: number
quiet: number
frame: number | undefined
}
| undefined
const threshold = () => options.bottomThreshold ?? 10
@ -27,77 +44,160 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight - el.scrollTop
// With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
return Math.abs(el.scrollTop)
}
const canScroll = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight > 1
}
// Browsers can dispatch scroll events asynchronously. If new content arrives
// between us calling `scrollTo()` and the subsequent `scroll` event firing,
// the handler can see a non-zero `distanceFromBottom` and incorrectly assume
// the user scrolled.
const markAuto = (el: HTMLElement) => {
auto = {
top: Math.max(0, el.scrollHeight - el.clientHeight),
time: Date.now(),
}
if (autoTimer) clearTimeout(autoTimer)
autoTimer = setTimeout(() => {
auto = undefined
autoTimer = undefined
}, 1500)
const markProgrammatic = () => {
programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
}
const isAuto = (el: HTMLElement) => {
const a = auto
if (!a) return false
const clearHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) cancelAnimationFrame(next.frame)
hold = undefined
}
if (Date.now() - a.time > 1500) {
auto = undefined
const tickHold = () => {
const next = hold
const el = scroll
if (!next || !el) return false
if (Date.now() > next.until) {
clearHold()
return false
}
if (!next.el.isConnected) {
clearHold()
return false
}
return Math.abs(el.scrollTop - a.top) < 2
}
const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
markAuto(el)
if (behavior === "smooth") {
el.scrollTo({ top: el.scrollHeight, behavior })
return
const current = next.el.getBoundingClientRect().top
if (!Number.isFinite(current)) {
clearHold()
return false
}
// `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
el.scrollTop = el.scrollHeight
const delta = current - next.top
if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
next.quiet += 1
if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
clearHold()
return false
}
return true
}
next.quiet = 0
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
el.scrollTop += delta
markProgrammatic()
return true
}
const scheduleHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) return
next.frame = requestAnimationFrame(() => {
const value = hold
if (!value) return
value.frame = undefined
if (!tickHold()) return
scheduleHold()
})
}
const preserve = (target: HTMLElement) => {
const el = scroll
if (!el) return
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const top = target.getBoundingClientRect().top
if (!Number.isFinite(top)) return
clearHold()
hold = {
el: target,
top,
until: Date.now() + MANUAL_ANCHOR_MS,
quiet: 0,
frame: undefined,
}
scheduleHold()
}
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
clearHold()
if (force && store.userScrolled) setStore("userScrolled", false)
const el = scroll
if (!el) return
if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
const distance = distanceFromBottom(el)
if (distance < 2) {
markAuto(el)
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
// For auto-following content we prefer immediate updates to avoid
// visible "catch up" animations while content is still settling.
scrollToBottomNow("auto")
el.scrollTop = 0
markProgrammatic()
}
const stop = () => {
const cancelSmooth = () => {
if (scrollAnim) {
scrollAnim.stop()
scrollAnim = undefined
}
}
const smoothScrollToBottom = () => {
const el = scroll
if (!el) return
cancelSmooth()
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
scrollAnim = animate(el.scrollTop, 0, {
...FAST_SPRING,
onUpdate: (v) => {
markProgrammatic()
el.scrollTop = v
},
onComplete: () => {
scrollAnim = undefined
markProgrammatic()
},
})
}
const stop = (input?: { hold?: boolean }) => {
if (input?.hold !== false) clearHold()
const el = scroll
if (!el) return
if (!canScroll(el)) {
@ -106,15 +206,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
if (store.userScrolled) return
markProgrammatic()
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) clearHold()
if (e.deltaY > 0) {
const el = scroll
if (!el) return
if (distanceFromBottom(el) >= threshold()) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (e.deltaY >= 0) return
// If the user is scrolling within a nested scrollable region (tool output,
// code block, etc), don't treat it as leaving the "follow bottom" mode.
// Those regions opt in via `data-scrollable`.
cancelSmooth()
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
@ -126,23 +236,27 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
if (hold) {
if (Date.now() < programmaticUntil) return
clearHold()
}
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (distanceFromBottom(el) < threshold()) {
if (Date.now() < programmaticUntil) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
// Ignore scroll events triggered by our own scrollToBottom calls.
if (!store.userScrolled && isAuto(el)) {
scrollToBottom(false)
return
}
if (!store.userScrolled && Date.now() < programmaticUntil) return
stop()
stop({ hold: false })
}
const handleInteraction = () => {
@ -154,6 +268,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const updateOverflowAnchor = (el: HTMLElement) => {
if (hold) {
el.style.overflowAnchor = "none"
return
}
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
@ -173,15 +292,17 @@ export function createAutoScroll(options: AutoScrollOptions) {
() => store.contentRef,
() => {
const el = scroll
if (hold) {
scheduleHold()
return
}
if (el && !canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (!active()) return
if (store.userScrolled) return
// ResizeObserver fires after layout, before paint.
// Keep the bottom locked in the same frame to avoid visible
// "jump up then catch up" artifacts while streaming content.
scrollToBottom(false)
},
)
@ -200,13 +321,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
settling = true
settleTimer = setTimeout(() => {
settling = false
}, 300)
}, SETTLE_MS)
}),
)
createEffect(() => {
// Track `userScrolled` even before `scrollRef` is attached, so we can
// update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
@ -215,7 +334,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
if (autoTimer) clearTimeout(autoTimer)
clearHold()
cancelSmooth()
if (cleanup) cleanup()
})
@ -228,8 +348,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
scroll = el
if (!el) return
if (!el) {
clearHold()
return
}
markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
@ -240,13 +364,18 @@ export function createAutoScroll(options: AutoScrollOptions) {
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
preserve,
pause: stop,
resume: () => {
if (store.userScrolled) setStore("userScrolled", false)
scrollToBottom(true)
},
scrollToBottom: () => scrollToBottom(false),
forceScrollToBottom: () => scrollToBottom(true),
smoothScrollToBottom,
snapToBottom: () => {
const el = scroll
if (!el) return
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
el.scrollTop = 0
markProgrammatic()
},
userScrolled: () => store.userScrolled,
}
}

View file

@ -1,2 +1,5 @@
export * from "./use-filtered-list"
export * from "./create-auto-scroll"
export * from "./use-element-height"
export * from "./use-reduced-motion"
export * from "./use-page-visible"

View file

@ -0,0 +1,25 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
/**
* Tracks an element's height via ResizeObserver.
* Returns a reactive signal that updates whenever the element resizes.
*/
export function useElementHeight(
ref: Accessor<HTMLElement | undefined> | (() => HTMLElement | undefined),
initial = 0,
): Accessor<number> {
const [height, setHeight] = createSignal(initial)
createEffect(() => {
const el = ref()
if (!el) return
setHeight(el.getBoundingClientRect().height)
const observer = new ResizeObserver(() => {
setHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return height
}

View file

@ -0,0 +1,11 @@
import { createSignal } from "solid-js"
export const pageVisible = /* @__PURE__ */ (() => {
const [visible, setVisible] = createSignal(true)
if (typeof document !== "undefined") {
const sync = () => setVisible(document.visibilityState !== "hidden")
sync()
document.addEventListener("visibilitychange", sync)
}
return visible
})()

View file

@ -0,0 +1,9 @@
import { createSignal } from "solid-js"
export const prefersReducedMotion = /* @__PURE__ */ (() => {
if (typeof window === "undefined") return () => false
const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
const [reduced, setReduced] = createSignal(mql.matches)
mql.addEventListener("change", () => setReduced(mql.matches))
return reduced
})()

View file

@ -101,7 +101,8 @@ export const dict = {
"ui.tool.todos": "المهام",
"ui.tool.todos.read": "قراءة المهام",
"ui.tool.questions": "أسئلة",
"ui.tool.agent": "وكيل {{type}}",
"ui.tool.skill": "مهارة",
"ui.tool.agent": "وكيل",
"ui.common.file.one": "ملف",
"ui.common.file.other": "ملفات",

View file

@ -101,7 +101,8 @@ export const dict = {
"ui.tool.todos": "Tarefas",
"ui.tool.todos.read": "Ler tarefas",
"ui.tool.questions": "Perguntas",
"ui.tool.agent": "Agente {{type}}",
"ui.tool.skill": "Habilidade",
"ui.tool.agent": "Agente",
"ui.common.file.one": "arquivo",
"ui.common.file.other": "arquivos",

View file

@ -105,7 +105,8 @@ export const dict = {
"ui.tool.todos": "Lista zadataka",
"ui.tool.todos.read": "Čitanje liste zadataka",
"ui.tool.questions": "Pitanja",
"ui.tool.agent": "{{type}} agent",
"ui.tool.skill": "Vještina",
"ui.tool.agent": "Agent",
"ui.common.file.one": "datoteka",
"ui.common.file.other": "datoteke",

View file

@ -100,7 +100,8 @@ export const dict = {
"ui.tool.todos": "Opgaver",
"ui.tool.todos.read": "Læs opgaver",
"ui.tool.questions": "Spørgsmål",
"ui.tool.agent": "{{type}} Agent",
"ui.tool.skill": "Færdighed",
"ui.tool.agent": "Agent",
"ui.common.file.one": "fil",
"ui.common.file.other": "filer",

View file

@ -106,7 +106,8 @@ export const dict = {
"ui.tool.todos": "Aufgaben",
"ui.tool.todos.read": "Aufgaben lesen",
"ui.tool.questions": "Fragen",
"ui.tool.agent": "{{type}} Agent",
"ui.tool.skill": "Fähigkeit",
"ui.tool.agent": "Agent",
"ui.common.file.one": "Datei",
"ui.common.file.other": "Dateien",

View file

@ -102,7 +102,8 @@ export const dict: Record<string, string> = {
"ui.tool.todos": "To-dos",
"ui.tool.todos.read": "Read to-dos",
"ui.tool.questions": "Questions",
"ui.tool.agent": "{{type}} Agent",
"ui.tool.skill": "Skill",
"ui.tool.agent": "Agent",
"ui.common.file.one": "file",
"ui.common.file.other": "files",
@ -117,6 +118,42 @@ export const dict: Record<string, string> = {
"ui.common.close": "Close",
"ui.common.next": "Next",
"ui.common.submit": "Submit",
"ui.common.expand": "Expand",
"ui.common.collapse": "Collapse",
"ui.prompt.placeholder.simple": "Ask anything...",
"ui.prompt.placeholder.shell": "Enter shell command...",
"ui.prompt.dropzone.attach": "Drop images or PDFs here",
"ui.prompt.dropzone.mention": "Drop to @mention file",
"ui.prompt.toast.pasteUnsupported.title": "Unsupported paste",
"ui.prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.",
"ui.prompt.context.removeFile": "Remove context item",
"ui.prompt.attachment.remove": "Remove attachment",
"ui.prompt.attachment.add": "Add attachment",
"ui.prompt.send": "Send",
"ui.prompt.stop": "Stop",
"ui.prompt.autoAccept": "Auto-accept",
"ui.prompt.list.noCommands": "No commands",
"ui.prompt.control.agent": "Agent",
"ui.prompt.control.model": "Model",
"ui.prompt.control.variant": "Variant",
"ui.prompt.control.shell": "Shell",
"ui.prompt.model.select": "Select model",
"ui.prompt.model.connect": "Connect provider",
"ui.prompt.model.search": "Search models",
"ui.prompt.model.none": "No models",
"ui.prompt.model.manage": "Manage models",
"ui.prompt.model.group.default": "Models",
"ui.todo.progress": "{{done}} of {{total}} tasks completed",
"ui.todo.word.of": "of",
"ui.todo.word.completed": "tasks completed",
"ui.todo.expand": "Expand",
"ui.todo.collapse": "Collapse",
"ui.question.progress": "{{index}} of {{total}} questions",
"ui.permission.title": "Permission required",
"ui.permission.deny": "Deny",
"ui.permission.allowAlways": "Allow always",

View file

@ -101,7 +101,8 @@ export const dict = {
"ui.tool.todos": "Tareas",
"ui.tool.todos.read": "Leer tareas",
"ui.tool.questions": "Preguntas",
"ui.tool.agent": "Agente {{type}}",
"ui.tool.skill": "Habilidad",
"ui.tool.agent": "Agente",
"ui.common.file.one": "archivo",
"ui.common.file.other": "archivos",

View file

@ -101,7 +101,8 @@ export const dict = {
"ui.tool.todos": "Tâches",
"ui.tool.todos.read": "Lire les tâches",
"ui.tool.questions": "Questions",
"ui.tool.agent": "Agent {{type}}",
"ui.tool.skill": "Compétence",
"ui.tool.agent": "Agent",
"ui.common.file.one": "fichier",
"ui.common.file.other": "fichiers",

View file

@ -100,7 +100,8 @@ export const dict = {
"ui.tool.todos": "Todo",
"ui.tool.todos.read": "Todo読み込み",
"ui.tool.questions": "質問",
"ui.tool.agent": "{{type}}エージェント",
"ui.tool.skill": "スキル",
"ui.tool.agent": "エージェント",
"ui.common.file.one": "ファイル",
"ui.common.file.other": "ファイル",

View file

@ -101,7 +101,8 @@ export const dict = {
"ui.tool.todos": "할 일",
"ui.tool.todos.read": "할 일 읽기",
"ui.tool.questions": "질문",
"ui.tool.agent": "{{type}} 에이전트",
"ui.tool.skill": "스킬",
"ui.tool.agent": "에이전트",
"ui.common.file.one": "파일",
"ui.common.file.other": "파일",

View file

@ -104,7 +104,8 @@ export const dict: Record<Keys, string> = {
"ui.tool.todos": "Gjøremål",
"ui.tool.todos.read": "Les gjøremål",
"ui.tool.questions": "Spørsmål",
"ui.tool.agent": "{{type}}-agent",
"ui.tool.skill": "Ferdighet",
"ui.tool.agent": "Agent",
"ui.common.file.one": "fil",
"ui.common.file.other": "filer",

View file

@ -100,7 +100,8 @@ export const dict = {
"ui.tool.todos": "Zadania",
"ui.tool.todos.read": "Czytaj zadania",
"ui.tool.questions": "Pytania",
"ui.tool.agent": "Agent {{type}}",
"ui.tool.skill": "Umiejętność",
"ui.tool.agent": "Agent",
"ui.common.file.one": "plik",
"ui.common.file.other": "pliki",

View file

@ -100,7 +100,8 @@ export const dict = {
"ui.tool.todos": "Задачи",
"ui.tool.todos.read": "Читать задачи",
"ui.tool.questions": "Вопросы",
"ui.tool.agent": "Агент {{type}}",
"ui.tool.skill": "Навык",
"ui.tool.agent": "Агент",
"ui.common.file.one": "файл",
"ui.common.file.other": "файлов",

View file

@ -102,7 +102,8 @@ export const dict = {
"ui.tool.todos": "รายการงาน",
"ui.tool.todos.read": "อ่านรายการงาน",
"ui.tool.questions": "คำถาม",
"ui.tool.agent": "เอเจนต์ {{type}}",
"ui.tool.skill": "ทักษะ",
"ui.tool.agent": "เอเจนต์",
"ui.common.file.one": "ไฟล์",
"ui.common.file.other": "ไฟล์",

View file

@ -97,7 +97,8 @@ export const dict = {
"ui.tool.todos": "Görevler",
"ui.tool.todos.read": "Görevleri oku",
"ui.tool.questions": "Sorular",
"ui.tool.agent": "{{type}} Ajan",
"ui.tool.skill": "Beceri",
"ui.tool.agent": "Ajan",
"ui.common.file.one": "dosya",
"ui.common.file.other": "dosya",

View file

@ -105,7 +105,8 @@ export const dict = {
"ui.tool.todos": "待办",
"ui.tool.todos.read": "读取待办",
"ui.tool.questions": "问题",
"ui.tool.agent": "{{type}} 智能体",
"ui.tool.skill": "技能",
"ui.tool.agent": "智能体",
"ui.common.file.one": "个文件",
"ui.common.file.other": "个文件",

View file

@ -105,7 +105,8 @@ export const dict = {
"ui.tool.todos": "待辦",
"ui.tool.todos.read": "讀取待辦",
"ui.tool.questions": "問題",
"ui.tool.agent": "{{type}} 代理程式",
"ui.tool.skill": "技能",
"ui.tool.agent": "代理程式",
"ui.common.file.one": "個檔案",
"ui.common.file.other": "個檔案",

View file

@ -40,6 +40,7 @@
@import "../components/progress-circle.css" layer(components);
@import "../components/radio-group.css" layer(components);
@import "../components/resize-handle.css" layer(components);
@import "../components/rolling-results.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);