Merge branch 'dev' into llm-centralization
commit
0ef477a613
5
bun.lock
5
bun.lock
|
|
@ -382,6 +382,8 @@
|
|||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
|
|
@ -398,6 +400,7 @@
|
|||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
|
@ -1552,6 +1555,8 @@
|
|||
|
||||
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
|
||||
|
||||
"@solid-primitives/bounds": ["@solid-primitives/bounds@0.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="],
|
||||
|
||||
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
|
||||
|
||||
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"nodeModules": "sha256-3CG0wAMQp2E6ghPUXbYaYifJorp9b1WvCtHD+o8Nhck="
|
||||
"nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ="
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
|
@ -36,6 +37,8 @@
|
|||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ export interface BasicToolProps {
|
|||
trigger: TriggerTitle | JSX.Element
|
||||
children?: JSX.Element
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const resolved = children(() => props.children)
|
||||
return (
|
||||
<Collapsible>
|
||||
<Collapsible defaultOpen={props.defaultOpen}>
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
|
|
|
|||
|
|
@ -100,6 +100,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: 4px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Icon, IconProps } from "./icon"
|
|||
export interface ButtonProps
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "normal" | "large"
|
||||
size?: "small" | "normal" | "large"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
icon?: IconProps["name"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-component="reasoning-part"] {
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 24px;
|
||||
font-style: italic !important;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool-error"] {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import { GenericTool } from "./basic-tool"
|
||||
|
|
@ -16,27 +17,34 @@ import { Icon } from "./icon"
|
|||
import { Checkbox } from "./checkbox"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Markdown } from "./markdown"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { sanitizePart } from "@opencode-ai/util/sanitize"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageType
|
||||
parts: PartType[]
|
||||
sanitize?: RegExp
|
||||
}
|
||||
|
||||
export interface MessagePartProps {
|
||||
part: PartType
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
sanitize?: RegExp
|
||||
}
|
||||
|
||||
export type PartComponent = Component<MessagePartProps>
|
||||
|
||||
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
|
||||
|
||||
function relativizeProjectPaths(text: string, directory?: string) {
|
||||
if (!text) return ""
|
||||
if (!directory) return text
|
||||
return text.split(directory).join("")
|
||||
}
|
||||
|
||||
function getDirectory(path: string | undefined) {
|
||||
const data = useData()
|
||||
return relativizeProjectPaths(_getDirectory(path), data.directory)
|
||||
}
|
||||
|
||||
export function registerPartComponent(type: string, component: PartComponent) {
|
||||
PART_MAPPING[type] = component
|
||||
}
|
||||
|
|
@ -49,27 +57,20 @@ export function Message(props: MessageProps) {
|
|||
</Match>
|
||||
<Match when={props.message.role === "assistant" && props.message}>
|
||||
{(assistantMessage) => (
|
||||
<AssistantMessageDisplay
|
||||
message={assistantMessage() as AssistantMessage}
|
||||
parts={props.parts}
|
||||
sanitize={props.sanitize}
|
||||
/>
|
||||
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
|
||||
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
|
||||
const filteredParts = createMemo(() => {
|
||||
return props.parts?.filter((x) => {
|
||||
if (x.type === "reasoning") return false
|
||||
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
|
||||
})
|
||||
})
|
||||
return (
|
||||
<For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
|
||||
)
|
||||
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
|
||||
}
|
||||
|
||||
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
|
||||
|
|
@ -84,10 +85,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||
|
||||
export function Part(props: MessagePartProps) {
|
||||
const component = createMemo(() => PART_MAPPING[props.part.type])
|
||||
const part = createMemo(() => sanitizePart(unwrap(props.part), props.sanitize))
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic component={component()} part={part()} message={props.message} hideDetails={props.hideDetails} />
|
||||
<Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -175,12 +175,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||
}
|
||||
|
||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const data = useData()
|
||||
const part = props.part as TextPart
|
||||
const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(unwrap(part), props.sanitize) as TextPart) : part))
|
||||
const content = createMemo(() => (part.text ?? "").trim())
|
||||
const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory))
|
||||
|
||||
return (
|
||||
<Show when={part.text.trim()}>
|
||||
<Show when={displayText()}>
|
||||
<div data-component="text-part">
|
||||
<Markdown text={sanitized().text.trim()} />
|
||||
<Markdown text={displayText()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
|
@ -318,6 +321,7 @@ ToolRegistry.register({
|
|||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
defaultOpen
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
|
|
@ -340,6 +344,7 @@ ToolRegistry.register({
|
|||
const diffComponent = useDiffComponent()
|
||||
return (
|
||||
<BasicTool
|
||||
defaultOpen
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
[data-component="message-progress"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-component="message-progress"] [data-slot="message-progress-status"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 20px;
|
||||
padding-left: 12px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-component="message-progress"] [data-slot="message-progress-status-text"] {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-component="message-progress"] [data-slot="message-progress-list-container"] {
|
||||
height: 120px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
padding-bottom: 4px;
|
||||
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%);
|
||||
}
|
||||
|
||||
[data-component="message-progress"] [data-slot="message-progress-list"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
gap: 8px;
|
||||
padding-top: 32px;
|
||||
padding-bottom: 32px;
|
||||
|
||||
transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-component="message-progress"] [data-slot="message-progress-item"] {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Part } from "./message-part"
|
||||
import { Spinner } from "./spinner"
|
||||
import { useData } from "../context/data"
|
||||
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export interface MessageProgressProps {
|
||||
assistantMessages: () => AssistantMessageType[]
|
||||
done?: boolean
|
||||
}
|
||||
|
||||
export function MessageProgress(props: MessageProgressProps) {
|
||||
const data = useData()
|
||||
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
|
||||
const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const done = createMemo(() => props.done ?? false)
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
parts().findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
p.tool === "task" &&
|
||||
p.state &&
|
||||
"metadata" in p.state &&
|
||||
p.state.metadata &&
|
||||
p.state.metadata.sessionId &&
|
||||
p.state.status === "running",
|
||||
) as ToolPart,
|
||||
)
|
||||
const resolvedParts = createMemo(() => {
|
||||
let resolved = parts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
|
||||
(m) => m.role === "assistant",
|
||||
)
|
||||
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? parts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
|
||||
const eligibleItems = createMemo(() => {
|
||||
return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
|
||||
})
|
||||
const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
|
||||
<div data-slot="message-progress-item" />,
|
||||
<div data-slot="message-progress-item" />,
|
||||
<div data-slot="message-progress-item" />,
|
||||
...eligibleItems(),
|
||||
...(done()
|
||||
? [
|
||||
<div data-slot="message-progress-item" />,
|
||||
<div data-slot="message-progress-item" />,
|
||||
<div data-slot="message-progress-item" />,
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
const delay = createMemo(() => (done() ? 220 : 400))
|
||||
const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
|
||||
|
||||
createEffect(() => {
|
||||
const total = finishedItems().length
|
||||
if (total > visibleCount()) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleCount((prev) => prev + 1)
|
||||
}, delay())
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
} else if (total < visibleCount()) {
|
||||
setVisibleCount(total)
|
||||
}
|
||||
})
|
||||
|
||||
const translateY = createMemo(() => {
|
||||
const total = visibleCount()
|
||||
if (total < 2) return "0px"
|
||||
return `-${(total - 2) * 40 - 8}px`
|
||||
})
|
||||
|
||||
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const last = lastPart()
|
||||
if (!last) return undefined
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
case "task":
|
||||
return "Delegating work..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning next steps..."
|
||||
case "read":
|
||||
return "Gathering context..."
|
||||
case "list":
|
||||
case "grep":
|
||||
case "glob":
|
||||
return "Searching the codebase..."
|
||||
case "webfetch":
|
||||
return "Searching the web..."
|
||||
case "edit":
|
||||
case "write":
|
||||
return "Making edits..."
|
||||
case "bash":
|
||||
return "Running commands..."
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
return "Thinking..."
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts..."
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const [status, setStatus] = createSignal(rawStatus())
|
||||
let lastStatusChange = Date.now()
|
||||
let statusTimeout: number | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const newStatus = rawStatus()
|
||||
if (newStatus === status() || !newStatus) return
|
||||
|
||||
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||
|
||||
if (timeSinceLastChange >= 1500) {
|
||||
setStatus(newStatus)
|
||||
lastStatusChange = Date.now()
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = undefined
|
||||
}
|
||||
} else {
|
||||
if (statusTimeout) clearTimeout(statusTimeout)
|
||||
statusTimeout = setTimeout(() => {
|
||||
setStatus(rawStatus())
|
||||
lastStatusChange = Date.now()
|
||||
statusTimeout = undefined
|
||||
}, 1000 - timeSinceLastChange) as unknown as number
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="message-progress">
|
||||
<div data-slot="message-progress-status">
|
||||
<Spinner /> <span data-slot="message-progress-status-text">{status() ?? "Considering next steps..."}</span>
|
||||
</div>
|
||||
<Show when={eligibleItems().length > 0}>
|
||||
<div data-slot="message-progress-list-container">
|
||||
<div data-slot="message-progress-list" style={{ transform: `translateY(${translateY()})` }}>
|
||||
<For each={finishedItems()}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={part && typeof part === "object" && "type" in part && part}>
|
||||
{(p) => {
|
||||
const part = p() as ToolPart
|
||||
const message = createMemo(() =>
|
||||
data.store.message[part.sessionID].find((m) => m.id === part.messageID),
|
||||
)
|
||||
return (
|
||||
<div data-slot="message-progress-item">
|
||||
<Part message={message()!} part={part} sanitize={sanitizer()} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div data-slot="message-progress-item">{part as JSXElement}</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,20 +29,33 @@
|
|||
gap: 32px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-sticky-header"] {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: 20;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-content"] {
|
||||
margin-top: -24px;
|
||||
/* [data-slot="session-turn-message-content"] { */
|
||||
/* } */
|
||||
|
||||
[data-slot="session-turn-response-trigger"] {
|
||||
width: calc(100% + 9px);
|
||||
margin-left: -9px;
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-title"] {
|
||||
|
|
@ -202,10 +215,10 @@
|
|||
}
|
||||
|
||||
[data-component="sticky-accordion-header"] {
|
||||
top: 40px;
|
||||
top: var(--sticky-header-height, 40px);
|
||||
|
||||
&[data-expanded]::before {
|
||||
top: -40px;
|
||||
top: calc(-1 * var(--sticky-header-height, 40px));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,26 +283,35 @@
|
|||
}
|
||||
|
||||
[data-slot="session-turn-response-section"] {
|
||||
width: 100%;
|
||||
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"] {
|
||||
color: var(--text-weak);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
color: var(--text-weak);
|
||||
margin-left: -9px;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
[data-component="icon"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-details-text"] {
|
||||
|
|
@ -308,5 +330,9 @@
|
|||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
|
||||
> :first-child > [data-component="markdown"]:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
ParentProps,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Typewriter } from "./typewriter"
|
||||
import { Message } from "./message-part"
|
||||
|
|
@ -13,16 +25,11 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { Card } from "./card"
|
||||
import { MessageProgress } from "./message-progress"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
// Track animation state per message ID - persists across re-renders
|
||||
// "empty" = first saw with no value (should animate when value arrives)
|
||||
// "animating" = currently animating (keep returning true)
|
||||
// "done" = already animated or first saw with value (never animate)
|
||||
const titleAnimationState = new Map<string, "empty" | "animating" | "done">()
|
||||
const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
|
||||
import { Button } from "./button"
|
||||
import { Spinner } from "./spinner"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime, DurationUnit, Interval } from "luxon"
|
||||
|
||||
export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
|
|
@ -37,18 +44,13 @@ export function SessionTurn(
|
|||
) {
|
||||
const data = useData()
|
||||
const diffComponent = useDiffComponent()
|
||||
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
|
||||
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(-1)
|
||||
})
|
||||
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
|
||||
|
||||
const status = createMemo(
|
||||
() =>
|
||||
data.store.session_status[props.sessionID] ?? {
|
||||
|
|
@ -57,241 +59,346 @@ export function SessionTurn(
|
|||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
const [stickyHeaderRef, setStickyHeaderRef] = createSignal<HTMLDivElement>()
|
||||
const [userScrolled, setUserScrolled] = createSignal(false)
|
||||
const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0)
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
if (!atBottom && working()) {
|
||||
setUserScrolled(true)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInteraction() {
|
||||
if (working()) {
|
||||
setUserScrolled(true)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!working()) {
|
||||
setUserScrolled(false)
|
||||
}
|
||||
})
|
||||
|
||||
createResizeObserver(contentRef, () => {
|
||||
if (!scrollRef || userScrolled() || !working()) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
})
|
||||
|
||||
createResizeObserver(stickyHeaderRef, ({ height }) => {
|
||||
setStickyHeaderHeight(height + 8)
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div data-slot="session-turn-content" class={props.classes?.content}>
|
||||
<Show when={message()}>
|
||||
{(msg) => {
|
||||
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
|
||||
|
||||
// Animation logic: only animate if we witness the value transition from empty to non-empty
|
||||
// Track in module-level Maps keyed by message ID so it persists across re-renders
|
||||
|
||||
// Initialize animation state for current message (reactive - runs when msg().id changes)
|
||||
createEffect(() => {
|
||||
const id = msg().id
|
||||
if (!titleAnimationState.has(id)) {
|
||||
titleAnimationState.set(id, msg().summary?.title ? "done" : "empty")
|
||||
}
|
||||
if (!summaryAnimationState.has(id)) {
|
||||
const assistantMsgs = messages()?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == id,
|
||||
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
|
||||
<div ref={setContentRef} onClick={handleInteraction}>
|
||||
<Show when={message()}>
|
||||
{(message) => {
|
||||
const assistantMessages = createMemo(() => {
|
||||
return messages()?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message().id,
|
||||
) as AssistantMessage[]
|
||||
const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id])
|
||||
const lastText = parts?.filter((p) => p?.type === "text")?.at(-1)
|
||||
const summaryValue = msg().summary?.body ?? lastText?.text
|
||||
summaryAnimationState.set(id, summaryValue ? "done" : "empty")
|
||||
})
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
|
||||
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const parts = createMemo(() => data.store.part[message().id])
|
||||
const lastTextPart = createMemo(() =>
|
||||
assistantMessageParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
?.at(-1),
|
||||
)
|
||||
const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
|
||||
const lastTextPartShown = createMemo(
|
||||
() => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
assistantParts().findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
p.tool === "task" &&
|
||||
p.state &&
|
||||
"metadata" in p.state &&
|
||||
p.state.metadata &&
|
||||
p.state.metadata.sessionId &&
|
||||
p.state.status === "running",
|
||||
) as ToolPart,
|
||||
)
|
||||
const resolvedParts = createMemo(() => {
|
||||
let resolved = assistantParts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
|
||||
(m) => m.role === "assistant",
|
||||
)
|
||||
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const last = lastPart()
|
||||
if (!last) return undefined
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
case "task":
|
||||
return "Delegating work"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning next steps"
|
||||
case "read":
|
||||
return "Gathering context"
|
||||
case "list":
|
||||
case "grep":
|
||||
case "glob":
|
||||
return "Searching the codebase"
|
||||
case "webfetch":
|
||||
return "Searching the web"
|
||||
case "edit":
|
||||
case "write":
|
||||
return "Making edits"
|
||||
case "bash":
|
||||
return "Running commands"
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
return "Thinking"
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts"
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function duration() {
|
||||
const completed = lastAssistantMessage()?.time.completed
|
||||
const from = DateTime.fromMillis(message()!.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"]
|
||||
|
||||
return interval.toDuration(unit).normalize().toHuman({
|
||||
notation: "compact",
|
||||
unitDisplay: "narrow",
|
||||
compactDisplay: "short",
|
||||
showZeros: false,
|
||||
})
|
||||
}
|
||||
|
||||
// When message changes or component unmounts, mark any "animating" states as "done"
|
||||
onCleanup(() => {
|
||||
if (titleAnimationState.get(id) === "animating") {
|
||||
titleAnimationState.set(id, "done")
|
||||
}
|
||||
if (summaryAnimationState.get(id) === "animating") {
|
||||
summaryAnimationState.set(id, "done")
|
||||
const [store, setStore] = createStore({
|
||||
status: rawStatus(),
|
||||
stepsExpanded: true,
|
||||
duration: duration(),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setStore("duration", duration())
|
||||
}, 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const assistantMessages = createMemo(() => {
|
||||
return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
|
||||
})
|
||||
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const parts = createMemo(() => data.store.part[msg().id])
|
||||
const lastTextPart = createMemo(() =>
|
||||
assistantMessageParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
?.at(-1),
|
||||
)
|
||||
const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool"))
|
||||
const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
|
||||
const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
|
||||
const [completed, setCompleted] = createSignal(initialCompleted)
|
||||
const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
|
||||
const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
|
||||
createEffect((prev) => {
|
||||
const isWorking = working()
|
||||
if (prev && !isWorking && !userScrolled()) {
|
||||
setStore("stepsExpanded", false)
|
||||
}
|
||||
return isWorking
|
||||
}, working())
|
||||
|
||||
// Should animate: state is "empty" AND value now exists, or state is "animating"
|
||||
// Transition: empty -> animating -> done (done happens on cleanup)
|
||||
const animateTitle = createMemo(() => {
|
||||
const id = msg().id
|
||||
const state = titleAnimationState.get(id)
|
||||
const title = msg().summary?.title
|
||||
if (state === "animating") {
|
||||
return true
|
||||
}
|
||||
if (state === "empty" && title) {
|
||||
titleAnimationState.set(id, "animating")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
const animateSummary = createMemo(() => {
|
||||
const id = msg().id
|
||||
const state = summaryAnimationState.get(id)
|
||||
const value = summary()
|
||||
if (state === "animating") {
|
||||
return true
|
||||
}
|
||||
if (state === "empty" && value) {
|
||||
summaryAnimationState.set(id, "animating")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const done = !messageWorking()
|
||||
setTimeout(() => setCompleted(done), 1200)
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
|
||||
{/* Title */}
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Show
|
||||
when={!animateTitle()}
|
||||
fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
|
||||
>
|
||||
<h1>{msg().summary?.title}</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={msg()} parts={parts()} sanitize={sanitizer()} />
|
||||
</div>
|
||||
{/* Summary */}
|
||||
<Show when={completed()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">
|
||||
return (
|
||||
<div
|
||||
data-message={message().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }}
|
||||
>
|
||||
{/* Sticky Header */}
|
||||
<div ref={setStickyHeaderRef} data-slot="session-turn-sticky-header">
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Switch>
|
||||
<Match when={msg().summary?.diffs?.length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
<Match when={working()}>
|
||||
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<h1>{message().summary?.title}</h1>
|
||||
</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
data-slot="session-turn-markdown"
|
||||
data-diffs={!!msg().summary?.diffs?.length}
|
||||
data-fade={!msg().summary?.diffs?.length && animateSummary()}
|
||||
text={summary()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={message()} parts={parts()} />
|
||||
</div>
|
||||
<div data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
|
||||
<Match when={store.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!store.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<Show when={store.stepsExpanded}>
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
|
||||
const last = createMemo(() =>
|
||||
parts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
|
||||
<Message
|
||||
message={assistantMessage}
|
||||
parts={parts().filter((p) => p?.id !== last()?.id)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Message message={assistantMessage} parts={parts()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion data-slot="session-turn-accordion" multiple>
|
||||
<For each={msg().summary?.diffs ?? []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-accordion-trigger-content">
|
||||
<div data-slot="session-turn-file-info">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
data-slot="session-turn-file-icon"
|
||||
/>
|
||||
<div data-slot="session-turn-file-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
||||
</Show>
|
||||
{/* Summary */}
|
||||
<Show when={!working()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">
|
||||
<Switch>
|
||||
<Match when={message().summary?.diffs?.length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
data-slot="session-turn-markdown"
|
||||
data-diffs={!!message().summary?.diffs?.length}
|
||||
text={summary()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion data-slot="session-turn-accordion" multiple>
|
||||
<For each={message().summary?.diffs ?? []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-accordion-trigger-content">
|
||||
<div data-slot="session-turn-file-info">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
data-slot="session-turn-file-icon"
|
||||
/>
|
||||
<div data-slot="session-turn-file-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-accordion-actions">
|
||||
<DiffChanges changes={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-accordion-actions">
|
||||
<DiffChanges changes={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-turn-accordion-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
cacheKey: checksum(diff.before!),
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
cacheKey: checksum(diff.after!),
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !detailsExpanded()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div data-slot="session-turn-response-section">
|
||||
<Switch>
|
||||
<Match when={!completed()}>
|
||||
<MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
|
||||
</Match>
|
||||
<Match when={completed() && hasToolPart()}>
|
||||
<Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
|
||||
<Collapsible.Trigger>
|
||||
<div data-slot="session-turn-collapsible-trigger-content">
|
||||
<div data-slot="session-turn-details-text">
|
||||
<Switch>
|
||||
<Match when={detailsExpanded()}>Hide details</Match>
|
||||
<Match when={!detailsExpanded()}>Show details</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => data.store.part[assistantMessage.id])
|
||||
const last = createMemo(() =>
|
||||
parts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
|
||||
return (
|
||||
<Message
|
||||
message={assistantMessage}
|
||||
parts={parts().filter((p) => p?.id !== last()?.id)}
|
||||
sanitize={sanitizer()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-turn-accordion-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
cacheKey: checksum(diff.before!),
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
cacheKey: checksum(diff.after!),
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !store.stepsExpanded}>
|
||||
<Card variant="error" class="error-card">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
{props.children}
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { ComponentProps, For } from "solid-js"
|
||||
|
||||
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
|
||||
const squares = Array.from({ length: 16 }, (_, i) => ({
|
||||
id: i,
|
||||
x: (i % 4) * 4,
|
||||
y: Math.floor(i / 4) * 4,
|
||||
delay: Math.random() * 3,
|
||||
duration: 2 + Math.random() * 2,
|
||||
}))
|
||||
const outerIndices = new Set([0, 1, 2, 3, 4, 7, 8, 11, 12, 13, 14, 15])
|
||||
const squares = Array.from({ length: 16 }, (_, i) => ({
|
||||
id: i,
|
||||
x: (i % 4) * 4,
|
||||
y: Math.floor(i / 4) * 4,
|
||||
delay: Math.random() * 1.5,
|
||||
duration: 1 + Math.random() * 1,
|
||||
outer: outerIndices.has(i),
|
||||
}))
|
||||
|
||||
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
|
|
@ -28,7 +30,7 @@ export function Spinner(props: { class?: string; classList?: ComponentProps<"div
|
|||
height="3"
|
||||
rx="1"
|
||||
style={{
|
||||
animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
|
||||
animation: `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`,
|
||||
"animation-delay": `${square.delay}s`,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, Show, type ValidComponent } from "solid-js"
|
||||
import { createEffect, onCleanup, Show, type ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
|
|||
if (!text) return
|
||||
|
||||
let i = 0
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
setStore("typing", true)
|
||||
setStore("displayed", "")
|
||||
setStore("cursor", true)
|
||||
|
|
@ -29,14 +30,18 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
|
|||
if (i < text.length) {
|
||||
setStore("displayed", text.slice(0, i + 1))
|
||||
i++
|
||||
setTimeout(type, getTypingDelay())
|
||||
timeouts.push(setTimeout(type, getTypingDelay()))
|
||||
} else {
|
||||
setStore("typing", false)
|
||||
setTimeout(() => setStore("cursor", false), 2000)
|
||||
timeouts.push(setTimeout(() => setStore("cursor", false), 2000))
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(type, 200)
|
||||
timeouts.push(setTimeout(type, 200))
|
||||
|
||||
onCleanup(() => {
|
||||
for (const timeout of timeouts) clearTimeout(timeout)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity-dim {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@
|
|||
@import "../components/logo.css" layer(components);
|
||||
@import "../components/markdown.css" layer(components);
|
||||
@import "../components/message-part.css" layer(components);
|
||||
@import "../components/message-progress.css" layer(components);
|
||||
@import "../components/message-nav.css" layer(components);
|
||||
@import "../components/progress-circle.css" layer(components);
|
||||
@import "../components/resize-handle.css" layer(components);
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import type { Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? ""
|
||||
|
||||
export const sanitizePart = (part: Part, remove: RegExp | undefined) => {
|
||||
if (part.type === "text") {
|
||||
part.text = sanitize(part.text, remove)
|
||||
} else if (part.type === "reasoning") {
|
||||
part.text = sanitize(part.text, remove)
|
||||
} else if (part.type === "tool") {
|
||||
if (part.state.status === "completed" || part.state.status === "error") {
|
||||
for (const key in part.state.metadata) {
|
||||
if (typeof part.state.metadata[key] === "string") {
|
||||
part.state.metadata[key] = sanitize(part.state.metadata[key] as string, remove)
|
||||
}
|
||||
}
|
||||
for (const key in part.state.input) {
|
||||
if (typeof part.state.input[key] === "string") {
|
||||
part.state.input[key] = sanitize(part.state.input[key] as string, remove)
|
||||
}
|
||||
}
|
||||
if ("error" in part.state) {
|
||||
part.state.error = sanitize(part.state.error as string, remove)
|
||||
}
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
Loading…
Reference in New Issue