Compare commits

...

4 Commits

Author SHA1 Message Date
Adam
0524e9f807 wip(app): timeline changes 2026-02-11 13:39:30 -06:00
Adam
ec7a3e2e44 wip(app): timeline changes 2026-02-11 13:17:04 -06:00
Adam
4f6dda5ff4 wip(app): timeline changes 2026-02-11 11:32:15 -06:00
Adam
76d43d9121 wip(app): timeline changes 2026-02-11 08:49:59 -06:00
11 changed files with 394 additions and 1274 deletions

View File

@@ -562,7 +562,6 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -740,7 +739,6 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session")
setUi("autoCreated", false)
},
@@ -759,12 +757,6 @@ export default function Page() {
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
@@ -937,10 +929,8 @@ export default function Page() {
status,
userMessages,
visibleUserMessages,
activeMessage,
showAllFiles,
navigateMessageByOffset,
setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage,
addSelectionToContext,
focusInput,
@@ -1650,8 +1640,6 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
expanded={store.expanded}
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/>
</Show>
</Match>

View File

@@ -57,8 +57,6 @@ export function MessageTimeline(props: {
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
}) {
let touchGesture: number | undefined
@@ -176,8 +174,9 @@ export function MessageTimeline(props: {
<Show when={props.showHeader}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
@@ -328,8 +327,6 @@ export function MessageTimeline(props: {
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
stepsExpanded={props.expanded[message.id] ?? false}
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",

View File

@@ -42,10 +42,8 @@ export const useSessionCommands = (input: {
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
activeMessage: () => UserMessage | undefined
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
@@ -161,20 +159,6 @@ export const useSessionCommands = (input: {
input.view().terminal.open()
},
},
{
id: "steps.toggle",
title: input.language.t("command.steps.toggle"),
description: input.language.t("command.steps.toggle.description"),
category: input.language.t("command.category.view"),
keybind: "mod+e",
slash: "steps",
disabled: !input.params.id,
onSelect: () => {
const msg = input.activeMessage()
if (!msg) return
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
},
},
])
const messageCommands = createMemo(() => [

View File

@@ -224,7 +224,6 @@ export default function () {
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
expandedSteps: {} as Record<string, boolean>,
})
const messages = createMemo(() =>
data().sessionID
@@ -296,10 +295,7 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
@@ -375,13 +371,6 @@ export default function () {
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
stepsExpanded={
store.expandedSteps[store.messageId ?? firstUserMessage()!.id!] ?? false
}
onStepsExpandedToggle={() => {
const id = store.messageId ?? firstUserMessage()!.id!
setStore("expandedSteps", id, (v) => !v)
}}
classes={{
root: "grow",
content: "flex flex-col justify-between",

View File

@@ -15,6 +15,20 @@
gap: 20px;
}
[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="icon-svg"] {
flex-shrink: 0;
}

View File

@@ -1,6 +1,7 @@
import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
import { Spinner } from "./spinner"
export type TriggerTitle = {
title: string
@@ -22,6 +23,7 @@ export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
@@ -31,6 +33,7 @@ export interface BasicToolProps {
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const pending = () => props.status === "pending" || props.status === "running"
createEffect(() => {
if (props.forceOpen) setOpen(true)
@@ -46,7 +49,11 @@ export function BasicTool(props: BasicToolProps) {
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<Icon name={props.icon} size="small" />
<div data-slot="basic-tool-tool-indicator">
<Show when={pending()} fallback={<Icon name={props.icon} size="small" />}>
<Spinner style={{ width: "16px" }} />
</Show>
</div>
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
@@ -113,6 +120,6 @@ export function BasicTool(props: BasicToolProps) {
)
}
export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" status={props.status} trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
}

View File

@@ -3,7 +3,7 @@
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
color: var(--text-base);
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: var(--line-height-x-large);

View File

@@ -17,12 +17,19 @@
color: var(--text-base);
display: flex;
flex-direction: column;
align-items: flex-end;
align-self: flex-end;
margin-left: auto;
width: fit-content;
max-width: min(82%, 64ch);
gap: 8px;
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 100%;
}
[data-slot="user-message-attachment"] {
@@ -71,8 +78,12 @@
}
}
[data-slot="user-message-body"] {
width: 100%;
max-width: 100%;
}
[data-slot="user-message-text"] {
position: relative;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
@@ -89,17 +100,24 @@
color: var(--syntax-type);
}
[data-slot="user-message-copy-wrapper"] {
position: absolute;
top: 7px;
right: 7px;
opacity: 0;
transition: opacity 0.15s ease;
}
max-width: 100%;
}
&:hover [data-slot="user-message-copy-wrapper"] {
opacity: 1;
}
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
[data-slot="user-message-body"]:hover [data-slot="user-message-copy-wrapper"],
[data-slot="user-message-body"]:focus-within [data-slot="user-message-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
@@ -115,21 +133,24 @@
width: 100%;
[data-slot="text-part-body"] {
position: relative;
margin-top: 32px;
margin-top: 0;
}
[data-slot="text-part-copy-wrapper"] {
position: absolute;
top: -28px;
right: 8px;
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 1;
}
[data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] {
&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
[data-component="markdown"] {
@@ -375,6 +396,20 @@
}
}
[data-slot="task-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="task-tool-title"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@@ -395,6 +430,50 @@
}
}
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
[data-slot="context-tool-group-title"] {
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-weak);
}
[data-slot="collapsible-arrow"] {
opacity: 0;
color: var(--icon-weaker);
transition: opacity 0.15s ease;
}
}
[data-slot="collapsible-trigger"]:hover [data-component="context-tool-group-trigger"] [data-slot="collapsible-arrow"],
[data-slot="collapsible-trigger"]:focus-visible
[data-component="context-tool-group-trigger"]
[data-slot="collapsible-arrow"] {
opacity: 1;
}
[data-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 8px 6px 12px;
}
}
[data-component="diagnostics"] {
display: flex;
flex-direction: column;

View File

@@ -37,6 +37,7 @@ import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Button } from "./button"
import { Card } from "./card"
import { Collapsible } from "./collapsible"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
@@ -47,8 +48,8 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { Spinner } from "./spinner"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
interface Diagnostic {
range: {
@@ -92,6 +93,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
export interface MessageProps {
message: MessageType
parts: PartType[]
showAssistantCopyPartID?: string
}
export interface MessagePartProps {
@@ -99,6 +101,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string
}
export type PartComponent = Component<MessagePartProps>
@@ -107,12 +110,6 @@ export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
const TEXT_RENDER_THROTTLE_MS = 100
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 createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
@@ -269,6 +266,75 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
}
function contextToolDetail(part: ToolPart): string | undefined {
const info = getToolInfo(part.tool, part.state.input ?? {})
if (info.subtitle) return info.subtitle
if (part.state.status === "error") return part.state.error
if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
return part.state.title
const description = part.state.input?.description
if (typeof description === "string") return description
return undefined
}
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
const input = (part.state.input ?? {}) as Record<string, unknown>
const path = typeof input.path === "string" ? input.path : "/"
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
const include = typeof input.include === "string" ? input.include : undefined
const offset = typeof input.offset === "number" ? input.offset : undefined
const limit = typeof input.limit === "number" ? input.limit : undefined
switch (part.tool) {
case "read": {
const args: string[] = []
if (offset !== undefined) args.push("offset=" + offset)
if (limit !== undefined) args.push("limit=" + limit)
return {
title: i18n.t("ui.tool.read"),
subtitle: filePath ? getFilename(filePath) : "",
args,
}
}
case "list":
return {
title: i18n.t("ui.tool.list"),
subtitle: getDirectory(path),
}
case "glob":
return {
title: i18n.t("ui.tool.glob"),
subtitle: getDirectory(path),
args: pattern ? ["pattern=" + pattern] : [],
}
case "grep": {
const args: string[] = []
if (pattern) args.push("pattern=" + pattern)
if (include) args.push("include=" + include)
return {
title: i18n.t("ui.tool.grep"),
subtitle: getDirectory(path),
args,
}
}
default: {
const info = getToolInfo(part.tool, input)
return {
title: info.title,
subtitle: info.subtitle || contextToolDetail(part),
args: [],
}
}
}
}
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -281,47 +347,112 @@ export function Message(props: MessageProps) {
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
<AssistantMessageDisplay
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)}
</Match>
</Switch>
)
}
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
const emptyParts: PartType[] = []
const filteredParts = createMemo(
() =>
props.parts.filter((x) => {
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
}),
emptyParts,
{ equals: same },
export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string
}) {
const grouped = createMemo(() =>
props.parts.reduce<({ type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] })[]>((acc, part) => {
if (!isContextGroupTool(part)) {
acc.push({ type: "part", part })
return acc
}
const last = acc[acc.length - 1]
if (last && last.type === "context") {
last.parts.push(part)
return acc
}
acc.push({ type: "context", parts: [part] })
return acc
}, []),
)
return (
<For each={grouped()}>
{(item) => {
if (item.type === "context") return <ContextToolGroup parts={item.parts} />
return <Part part={item.part} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
}}
</For>
)
}
function ContextToolGroup(props: { parts: ToolPart[] }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(() =>
props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<span data-slot="context-tool-group-title">{pending() ? "Gathering context" : "Gathered context"}</span>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-component="context-tool-group-list">
<For each={props.parts}>
{(part) => {
const info = getToolInfo(part.tool, part.state.input ?? {})
const trigger = contextToolTrigger(part, i18n)
const running = part.state.status === "pending" || part.state.status === "running"
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-indicator">
<Show when={running} fallback={<Icon name={info.icon} size="small" />}>
<Spinner style={{ width: "16px" }} />
</Show>
</div>
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">{trigger.title}</span>
<Show when={trigger.subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
</Show>
<Show when={trigger.args?.length}>
<For each={trigger.args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</Show>
</div>
</div>
</div>
</div>
</div>
</div>
)
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
)
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const [expanded, setExpanded] = createSignal(false)
const [canExpand, setCanExpand] = createSignal(false)
let textRef: HTMLDivElement | undefined
const updateCanExpand = () => {
const el = textRef
if (!el) return
if (expanded()) return
setCanExpand(el.scrollHeight > el.clientHeight + 2)
}
createResizeObserver(
() => textRef,
() => {
updateCanExpand()
},
)
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -329,11 +460,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const text = createMemo(() => textPart()?.text || "")
createEffect(() => {
text()
updateCanExpand()
})
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
const attachments = createMemo(() =>
@@ -364,13 +490,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
setTimeout(() => setCopied(false), 2000)
}
const toggleExpanded = () => {
if (!canExpand()) return
setExpanded((value) => !value)
}
return (
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
<div data-component="user-message">
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -404,19 +525,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</div>
</Show>
<Show when={text()}>
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<button
data-slot="user-message-expand"
type="button"
aria-label={expanded() ? i18n.t("ui.message.collapse") : i18n.t("ui.message.expand")}
onClick={(event) => {
event.stopPropagation()
toggleExpanded()
}}
>
<Icon name="chevron-down" size="small" />
</button>
<div data-slot="user-message-body">
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
<div data-slot="user-message-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
@@ -426,7 +538,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
@@ -491,6 +603,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
</Show>
)
@@ -672,6 +785,17 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const part = props.part as TextPart
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
const throttledText = createThrottledValue(displayText)
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1)
return last?.id === part.id
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID) return props.showAssistantCopyPartID === part.id
return isLastTextPart()
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
@@ -687,6 +811,8 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} />
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
@@ -696,14 +822,14 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</div>
</Show>
</div>
</Show>
)
@@ -953,7 +1079,7 @@ ToolRegistry.register({
const autoScroll = createAutoScroll({
working: () => true,
overflowAnchor: "auto",
overflowAnchor: "dynamic",
})
const childPermission = createMemo(() => {
@@ -1019,7 +1145,10 @@ ToolRegistry.register({
<Switch>
<Match when={childPermission()}>
<>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}>
<Show
when={childToolPart()}
fallback={<BasicTool icon="task" status={props.status} defaultOpen={true} trigger={trigger()} />}
>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
@@ -1038,7 +1167,7 @@ ToolRegistry.register({
</>
</Match>
<Match when={true}>
<BasicTool icon="task" defaultOpen={true} trigger={trigger()}>
<BasicTool icon="task" status={props.status} defaultOpen={true} trigger={trigger()}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
@@ -1057,7 +1186,14 @@ ToolRegistry.register({
})
return (
<div data-slot="task-tool-item">
<Icon name={info().icon} size="small" />
<div data-slot="task-tool-indicator">
<Show
when={item.state.status === "pending" || item.state.status === "running"}
fallback={<Icon name={info().icon} size="small" />}
>
<Spinner style={{ width: "16px" }} />
</Show>
</div>
<span data-slot="task-tool-title">{info().title}</span>
<Show when={subtitle()}>
<span data-slot="task-tool-subtitle">{subtitle()}</span>

View File

@@ -1,7 +1,5 @@
[data-component="session-turn"] {
--session-turn-sticky-height: 0px;
--sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 24px);
/* flex: 1; */
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@@ -30,525 +28,30 @@
min-width: 0;
gap: 18px;
overflow-anchor: none;
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
}
[data-slot="session-turn-attachments"] {
width: 100%;
min-width: 0;
align-self: stretch;
}
[data-slot="session-turn-sticky"] {
width: calc(100% + 9px);
position: sticky;
top: var(--session-title-height, 0px);
z-index: 20;
background-color: var(--background-stronger);
margin-left: -9px;
padding-left: 9px;
/* padding-bottom: 12px; */
display: flex;
flex-direction: column;
gap: 12px;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: var(--background-stronger);
z-index: -1;
}
&::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 32px;
background: linear-gradient(to bottom, var(--background-stronger), transparent);
pointer-events: none;
}
}
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
align-self: stretch;
height: 32px;
}
[data-slot="session-turn-message-content"] {
margin-top: 0;
width: 100%;
min-width: 0;
max-width: 100%;
}
[data-component="user-message"] [data-slot="user-message-text"] {
max-height: var(--user-message-collapsed-height, 64px);
}
[data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
max-height: none;
}
[data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
padding-right: 36px;
padding-bottom: 28px;
}
[data-component="user-message"][data-can-expand="true"]:not([data-expanded="true"])
[data-slot="user-message-text"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 8px;
bottom: 0px;
background:
linear-gradient(to bottom, transparent, var(--surface-weak)),
linear-gradient(to bottom, transparent, var(--surface-weak));
pointer-events: none;
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
display: none;
position: absolute;
bottom: 6px;
right: 6px;
padding: 0;
}
[data-component="user-message"][data-can-expand="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"],
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: 22px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--text-weak);
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
}
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"]
[data-slot="icon-svg"] {
transform: rotate(180deg);
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
background: var(--surface-raised-base);
color: var(--text-base);
}
[data-slot="session-turn-user-badges"] {
display: flex;
align-items: center;
gap: 6px;
padding-left: 16px;
}
[data-slot="session-turn-message-title"] {
width: 100%;
font-size: var(--font-size-large);
font-weight: 500;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-message-title"] h1 {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
font-size: inherit;
font-weight: inherit;
}
[data-slot="session-turn-typewriter"] {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-summary-section"] {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
align-self: stretch;
}
[data-slot="session-turn-summary-header"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
[data-slot="session-turn-summary-title-row"] {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-slot="session-turn-response"] {
width: 100%;
}
[data-slot="session-turn-response-copy-wrapper"] {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-response-copy-wrapper"],
&:focus-within [data-slot="session-turn-response-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
p {
font-size: var(--font-size-base);
line-height: var(--line-height-x-large);
}
}
[data-slot="session-turn-summary-title"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
color: var(--text-weak);
}
[data-slot="session-turn-markdown"],
[data-slot="session-turn-accordion"] [data-slot="accordion-content"] {
-webkit-user-select: text;
user-select: text;
}
[data-slot="session-turn-markdown"] {
&[data-diffs="true"] {
font-size: 15px;
}
&[data-fade="true"] > * {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
}
[data-slot="session-turn-summary-section"] {
position: relative;
[data-slot="session-turn-summary-copy"] {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-summary-copy"] {
opacity: 1;
}
}
[data-slot="session-turn-accordion"] {
width: 100%;
}
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 0px);
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: calc(-1 * var(--sticky-header-height, 0px));
}
[data-slot="session-turn-accordion-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
[data-expandable="false"] {
pointer-events: none;
}
}
[data-slot="session-turn-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="session-turn-file-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
}
[data-slot="session-turn-file-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="session-turn-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="session-turn-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="session-turn-accordion-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="session-turn-accordion-content"] {
max-height: 240px;
/* max-h-60 */
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-turn-response-section"] {
width: calc(100% + 9px);
min-width: 0;
margin-left: -9px;
padding-left: 9px;
}
[data-slot="session-turn-collapsible"] {
gap: 32px;
overflow: visible;
}
[data-slot="session-turn-collapsible-trigger-content"] {
max-width: 100%;
min-width: 0;
[data-slot="session-turn-thinking"] {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="session-turn-trigger-icon"] {
color: var(--icon-base);
}
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
min-height: 20px;
[data-component="spinner"] {
width: 12px;
height: 12px;
margin-right: 4px;
width: 16px;
height: 16px;
}
[data-component="icon"] {
width: 14px;
height: 14px;
}
}
[data-slot="session-turn-retry-message"] {
font-weight: 500;
color: var(--syntax-critical);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="session-turn-retry-seconds"] {
color: var(--text-weak);
}
[data-slot="session-turn-retry-attempt"] {
color: var(--text-weak);
}
[data-slot="session-turn-status-text"] {
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="session-turn-details-text"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
}
.error-card {
@@ -560,44 +63,16 @@
overflow-y: auto;
}
[data-slot="session-turn-collapsible-content-inner"] {
[data-slot="session-turn-assistant-content"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
margin-left: 12px;
padding-left: 12px;
padding-right: 12px;
border-left: 1px solid var(--border-base);
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
}
}
[data-slot="session-turn-permission-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-answered-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
}

View File

@@ -1,31 +1,12 @@
import {
AssistantMessage,
FilePart,
Message as MessageType,
Part as PartType,
type PermissionRequest,
type QuestionRequest,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2/client"
import { AssistantMessage, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { IconButton } from "./icon-button"
import { createMemo, For, ParentProps, Show } from "solid-js"
import { Message } from "./message-part"
import { Card } from "./card"
import { Button } from "./button"
import { Spinner } from "./spinner"
import { Tooltip } from "./tooltip"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
type Translator = (key: UiI18nKey, params?: UiI18nParams) => string
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
@@ -80,117 +61,29 @@ function unwrap(message: string) {
return message
}
function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined {
if (!part) return undefined
if (part.type === "tool") {
switch (part.tool) {
case "task":
return t("ui.sessionTurn.status.delegating")
case "todowrite":
case "todoread":
return t("ui.sessionTurn.status.planning")
case "read":
return t("ui.sessionTurn.status.gatheringContext")
case "list":
case "grep":
case "glob":
return t("ui.sessionTurn.status.searchingCodebase")
case "webfetch":
return t("ui.sessionTurn.status.searchingWeb")
case "edit":
case "write":
return t("ui.sessionTurn.status.makingEdits")
case "bash":
return t("ui.sessionTurn.status.runningCommands")
default:
return undefined
}
}
if (part.type === "reasoning") {
const text = part.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() })
return t("ui.sessionTurn.status.thinking")
}
if (part.type === "text") {
return t("ui.sessionTurn.status.gatheringThoughts")
}
return undefined
}
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 isAttachment(part: PartType | undefined) {
if (part?.type !== "file") return false
const mime = (part as FilePart).mime ?? ""
return mime.startsWith("image/") || mime === "application/pdf"
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
hidden?: () => readonly { messageID: string; callID: string }[]
}) {
function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string }) {
const data = useData()
const emptyParts: PartType[] = []
const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
const lastTextPart = createMemo(() => {
const parts = msgParts()
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (part?.type === "text") return part as TextPart
}
return undefined
})
const filteredParts = createMemo(() => {
let parts = msgParts()
if (props.hideReasoning) {
parts = parts.filter((part) => part?.type !== "reasoning")
}
if (props.hideResponsePart) {
const responsePartId = props.responsePartId
if (responsePartId && responsePartId === lastTextPart()?.id) {
parts = parts.filter((part) => part?.id !== responsePartId)
}
}
const hidden = props.hidden?.() ?? []
if (hidden.length === 0) return parts
const id = props.message.id
return parts.filter((part) => {
if (part?.type !== "tool") return true
const tool = part as ToolPart
return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
})
})
return <Message message={props.message} parts={filteredParts()} />
return <Message message={props.message} parts={msgParts()} showAssistantCopyPartID={props.showAssistantCopyPartID} />
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
onStepsExpandedToggle?: () => void
onUserInteracted?: () => void
classes?: {
root?: string
@@ -199,16 +92,11 @@ export function SessionTurn(
}
}>,
) {
const i18n = useI18n()
const data = useData()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyQuestions: QuestionRequest[] = []
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@@ -256,19 +144,6 @@ export function SessionTurn(
return list(data.store.part?.[msg.id], emptyParts)
})
const attachmentParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyFiles
return msgParts.filter((part) => isAttachment(part)) as FilePart[]
})
const stickyParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyParts
if (attachmentParts().length === 0) return msgParts
return msgParts.filter((part) => !isAttachment(part))
})
const assistantMessages = createMemo(
() => {
const msg = message()
@@ -291,9 +166,24 @@ export function SessionTurn(
{ equals: same },
)
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const showAssistantCopyPartID = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
}
}
return undefined
})
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -301,309 +191,23 @@ export function SessionTurn(
return unwrap(String(msg))
})
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
}
}
return undefined
})
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = list(data.store.part?.[m.id], emptyParts)
for (const p of msgParts) {
if (p?.type === "tool") return true
}
}
return false
})
const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions))
const nextPermission = createMemo(() => permissions()[0])
const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions))
const nextQuestion = createMemo(() => questions()[0])
const hidden = createMemo(() => {
const out: { messageID: string; callID: string }[] = []
const perm = nextPermission()
if (perm?.tool) out.push(perm.tool)
const question = nextQuestion()
if (question?.tool) out.push(question.tool)
return out
})
const answeredQuestionParts = createMemo(() => {
if (props.stepsExpanded) return emptyQuestionParts
if (questions().length > 0) return emptyQuestionParts
const result: { part: ToolPart; message: AssistantMessage }[] = []
for (const msg of assistantMessages()) {
const parts = list(data.store.part?.[msg.id], emptyParts)
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.tool !== "question") continue
// @ts-expect-error metadata may not exist on all tool states
const answers = tool.state?.metadata?.answers
if (answers && answers.length > 0) {
result.push({ part: tool, message: msg })
}
}
}
return result
})
const shellModePart = createMemo(() => {
const p = parts()
if (p.length === 0) return
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = list(data.store.part?.[msgs[0].id], emptyParts)
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart
})
const isShellMode = createMemo(() => !!shellModePart())
const rawStatus = createMemo(() => {
const msgs = assistantMessages()
let last: PartType | undefined
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
if (!last) last = part
if (
part.type === "tool" &&
part.tool === "task" &&
part.state &&
"metadata" in part.state &&
part.state.metadata?.sessionId &&
part.state.status === "running"
) {
currentTask = part as ToolPart
break
}
}
if (currentTask) break
}
const taskSessionId =
currentTask?.state && "metadata" in currentTask.state
? (currentTask.state.metadata?.sessionId as string | undefined)
: undefined
if (taskSessionId) {
const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages)
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = list(data.store.part?.[msg.id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part, i18n.t)
}
}
}
return computeStatusFromPart(last, i18n.t)
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
// session_status is session-scoped; only show retry on the active (last) turn
if (!isLastUserMessage()) return
const s = status()
if (s.type !== "retry") return
return s
})
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = response() ?? ""
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
const updateStickyHeight = (height: number) => {
const root = rootRef()
if (!root) return
const next = Math.ceil(height)
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
}
function duration() {
const msg = message()
if (!msg) return ""
const completed = lastAssistantMessage()?.time.completed
const from = DateTime.fromMillis(msg.time.created)
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
const locale = i18n.locale()
const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({
notation: "compact",
unitDisplay: "narrow",
compactDisplay: "short",
showZeros: false,
})
return locale.startsWith("zh") ? human.replaceAll("、", "") : human
}
const assistantPartCount = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
return count + parts.filter(Boolean).length
}, 0),
)
const autoScroll = createAutoScroll({
working,
onUserInteracted: props.onUserInteracted,
overflowAnchor: "auto",
})
createResizeObserver(
() => stickyRef(),
({ height }) => {
updateStickyHeight(height)
},
)
createEffect(() => {
const root = rootRef()
if (!root) return
const sticky = stickyRef()
if (!sticky) {
root.style.setProperty("--session-turn-sticky-height", "0px")
return
}
updateStickyHeight(sticky.getBoundingClientRect().height)
})
const [store, setStore] = createStore({
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
})
createEffect(() => {
const r = retry()
if (!r) {
setStore("retrySeconds", 0)
return
}
const updateSeconds = () => {
const next = r.next
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
}
updateSeconds()
const timer = setInterval(updateSeconds, 1000)
onCleanup(() => clearInterval(timer))
})
let retryLog = ""
createEffect(() => {
const r = retry()
if (!r) return
const key = `${r.attempt}:${r.next}:${r.message}`
if (key === retryLog) return
retryLog = key
console.warn("[session-turn] retry", {
sessionID: props.sessionID,
messageID: props.messageID,
attempt: r.attempt,
next: r.next,
raw: r.message,
parsed: unwrap(r.message),
})
})
let errorLog = ""
createEffect(() => {
const value = error()?.data?.message
if (value === undefined || value === null) return
const raw = typeof value === "string" ? value : String(value)
if (!raw) return
if (raw === errorLog) return
errorLog = raw
console.warn("[session-turn] assistant-error", {
sessionID: props.sessionID,
messageID: props.messageID,
raw,
parsed: unwrap(raw),
})
})
createEffect(() => {
const update = () => {
setStore("duration", duration())
}
update()
// Only keep ticking while the active (in-progress) turn is running.
if (!working()) return
const timer = setInterval(update, 1000)
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
}
})
onCleanup(() => {
if (!statusTimeout) return
clearTimeout(statusTimeout)
overflowAnchor: "dynamic",
})
return (
<div data-component="session-turn" class={props.classes?.root} ref={setRootRef}>
<div data-component="session-turn" class={props.classes?.root}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
@@ -619,185 +223,32 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<Switch>
<Match when={isShellMode()}>
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
<Show when={attachmentParts().length > 0}>
<div data-slot="session-turn-attachments" aria-live="off">
<Message message={msg()} parts={attachmentParts()} />
</div>
</Show>
<div data-slot="session-turn-sticky" ref={setStickyRef}>
{/* User Message */}
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={stickyParts()} />
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
aria-expanded={props.stepsExpanded}
>
<Switch>
<Match when={working()}>
<Spinner />
</Match>
<Match when={!props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-slot="session-turn-trigger-icon"
>
<path
d="M8.125 1.875H1.875L5 8.125L8.125 1.875Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
<Match when={props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-icon-base"
>
<path
d="M8.125 8.125H1.875L5 1.875L8.125 8.125Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
</Switch>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
const msg = unwrap(r.message)
return msg.length > 60 ? msg.slice(0, 60) + "..." : msg
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· {i18n.t("ui.sessionTurn.retry.retrying")}
{store.retrySeconds > 0
? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds })
: ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>
<span data-slot="session-turn-status-text">
{store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")}
</span>
</Match>
<Match when={props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.hide")}</span>
</Match>
<Match when={!props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.show")}</span>
</Match>
</Switch>
<span aria-hidden="true">·</span>
<span aria-live="off">{store.duration}</span>
</Button>
</div>
</Show>
</div>
{/* Response */}
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
hidden={hidden}
/>
)}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
</Show>
<Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
<div data-slot="session-turn-answered-question-parts">
<For each={answeredQuestionParts()}>
{({ part, message }) => <Part part={part} message={message} />}
</For>
</div>
</Show>
{/* Response */}
<div class="sr-only" aria-live="polite">
{!working() && response() ? response() : ""}
</div>
<Show when={!working() && response()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<div data-slot="session-turn-summary-title-row">
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
<Show when={response()}>
<div data-slot="session-turn-response-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
handleCopy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</Show>
</div>
<div data-slot="session-turn-response">
<Markdown
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={response() ?? ""}
cacheKey={responsePartId()}
/>
</div>
</div>
</div>
</Show>
<Show when={error() && !props.stepsExpanded}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</Match>
</Switch>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={parts()} />
</div>
<Show when={working() && assistantPartCount() === 0 && !error()}>
<div data-slot="session-turn-thinking">
<Spinner style={{ width: "16px" }} />
<span>Thinking</span>
</div>
</Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
showAssistantCopyPartID={showAssistantCopyPartID()}
/>
)}
</For>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
)}
</Show>