Merge branch 'dev' into desktop-poilsh-styles-ui-ux

This commit is contained in:
Aaron Iker 2026-01-19 20:14:40 +01:00
commit 457c246f10
47 changed files with 1173 additions and 490 deletions

View file

@ -1,5 +1,7 @@
[data-slot="hover-card-trigger"] {
display: inline-flex;
display: flex;
width: 100%;
min-width: 0;
}
[data-component="hover-card-content"] {
@ -8,6 +10,7 @@
max-width: 320px;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
pointer-events: auto;
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;

View file

@ -64,6 +64,8 @@ const icons = {
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {

View file

@ -36,6 +36,25 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
mouseActive: false,
})
const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
const containerRect = container.getBoundingClientRect()
const nodeRect = node.getBoundingClientRect()
const top = nodeRect.top - containerRect.top + container.scrollTop
const bottom = top + nodeRect.height
const viewTop = container.scrollTop
const viewBottom = viewTop + container.clientHeight
const target =
block === "center"
? top - container.clientHeight / 2 + nodeRect.height / 2
: top < viewTop
? top
: bottom > viewBottom
? bottom - container.clientHeight
: viewTop
const max = Math.max(0, container.scrollHeight - container.clientHeight)
container.scrollTop = Math.max(0, Math.min(target, max))
}
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
const searchProps = () => (typeof props.search === "object" ? props.search : {})
@ -66,24 +85,31 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
)
createEffect(() => {
if (!scrollRef()) return
const scroll = scrollRef()
if (!scroll) return
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`)
element?.scrollIntoView({ block: "center" })
const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
if (!(element instanceof HTMLElement)) return
scrollIntoView(scroll, element, "center")
})
})
createEffect(() => {
const all = flat()
if (store.mouseActive || all.length === 0) return
const scroll = scrollRef()
if (!scroll) return
if (active() === props.key(all[0])) {
scrollRef()?.scrollTo(0, 0)
scroll.scrollTo(0, 0)
return
}
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`)
element?.scrollIntoView({ block: "center" })
const key = active()
if (!key) return
const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
if (!(element instanceof HTMLElement)) return
scrollIntoView(scroll, element, "center")
})
createEffect(() => {
@ -213,6 +239,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
setActive(props.key(item))
}}
onMouseLeave={() => {
if (!store.mouseActive) return
setActive(null)
}}
>

View file

@ -48,6 +48,10 @@
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08);
}
&[data-size="compact"] {
width: 24px;
}
}
[data-slot="message-nav-item-button"] {

View file

@ -96,8 +96,16 @@ const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: H
}
}
export const MessageNav = (props: MessageNavProps) => {
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
export function MessageNav(
props: ComponentProps<"nav"> & {
messages: UserMessage[]
current?: UserMessage
size: "normal" | "compact"
onMessageSelect: (message: UserMessage) => void
getLabel?: (message: UserMessage) => string | undefined
},
) {
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
let navRef: HTMLElement | undefined
let listRef: HTMLUListElement | undefined

View file

@ -46,6 +46,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
interface Diagnostic {
range: {
@ -297,6 +298,23 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
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,
@ -304,6 +322,11 @@ 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(() =>
@ -335,7 +358,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}
return (
<div data-component="user-message">
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@ -365,8 +388,16 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</div>
</Show>
<Show when={text()}>
<div data-slot="user-message-text">
<div data-slot="user-message-text" ref={(el) => (textRef = el)}>
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<button
data-slot="user-message-expand"
type="button"
aria-label={expanded() ? "Collapse message" : "Expand message"}
onClick={() => setExpanded((v) => !v)}
>
<Icon name="chevron-down" size="small" />
</button>
<div data-slot="user-message-copy-wrapper">
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />

View file

@ -1,29 +0,0 @@
[data-slot="session-message-rail-anchor"] {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40px;
height: -webkit-fill-available;
pointer-events: none;
}
[data-slot="session-message-rail-portal"] {
z-index: 100;
margin-left: 12px;
}
[data-component="session-message-rail"] {
display: contents;
position: relative;
}
[data-component="session-message-rail"][data-wide] {
margin-top: 0.125rem;
left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem);
transform: translateX(-100%);
}
[data-component="session-message-rail"]:not([data-wide]) {
margin-top: 0.625rem;
}

View file

@ -1,70 +0,0 @@
import { UserMessage } from "@opencode-ai/sdk/v2"
import { ComponentProps, Show, splitProps, createSignal, onMount, onCleanup } from "solid-js"
import { MessageNav } from "./message-nav"
import "./session-message-rail.css"
import { Portal } from "solid-js/web"
export interface SessionMessageRailProps extends ComponentProps<"div"> {
messages: UserMessage[]
current?: UserMessage
wide?: boolean
onMessageSelect: (message: UserMessage) => void
}
export function SessionMessageRail(props: SessionMessageRailProps) {
const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"])
let anchorRef: HTMLDivElement | undefined
const [position, setPosition] = createSignal({ top: 0, left: 0, height: 0 })
const updatePosition = () => {
if (anchorRef) {
const rect = anchorRef.getBoundingClientRect()
setPosition({ top: rect.top, left: rect.left, height: rect.height })
}
}
onMount(() => {
updatePosition()
window.addEventListener("scroll", updatePosition, true)
window.addEventListener("resize", updatePosition)
})
onCleanup(() => {
window.removeEventListener("scroll", updatePosition, true)
window.removeEventListener("resize", updatePosition)
})
return (
<Show when={(local.messages?.length ?? 0) > 1}>
<div
{...others}
data-component="session-message-rail"
data-wide={local.wide ? "" : undefined}
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
<div ref={(el) => (anchorRef = el)} data-slot="session-message-rail-anchor" />
<Portal mount={document.body}>
<div
data-slot="session-message-rail-portal"
style={{
position: "fixed",
top: `${position().top}px`,
left: `${position().left}px`,
height: `${position().height}px`,
}}
>
<MessageNav
messages={local.messages}
current={local.current}
onMessageSelect={local.onMessageSelect}
size={local.wide ? "normal" : "compact"}
/>
</div>
</Portal>
</div>
</Show>
)
}

View file

@ -1,4 +1,6 @@
[data-component="session-turn"] {
--session-turn-sticky-height: 0px;
--sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 12px);
/* flex: 1; */
height: 100%;
min-height: 0;
@ -29,23 +31,6 @@
gap: 28px;
overflow-anchor: none;
[data-slot="session-turn-user-badges"] {
position: absolute;
right: 0;
display: flex;
gap: 6px;
padding-left: 16px;
background: linear-gradient(to right, transparent, var(--background-stronger) 12px);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
&:hover [data-slot="session-turn-user-badges"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
@ -61,23 +46,39 @@
}
}
[data-slot="session-turn-sticky-title"] {
[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: 0;
top: var(--session-title-height, 0px);
z-index: 20;
background-color: var(--background-stronger);
z-index: 21;
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;
}
}
[data-slot="session-turn-response-trigger"] {
position: sticky;
top: 32px;
background-color: var(--background-stronger);
z-index: 20;
width: calc(100% + 9px);
margin-left: -9px;
padding-left: 9px;
padding-bottom: 8px;
width: fit-content;
}
[data-slot="session-turn-message-header"] {
@ -88,10 +89,72 @@
}
[data-slot="session-turn-message-content"] {
margin-top: -18px;
margin-top: 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-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);
@ -276,10 +339,9 @@
}
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 40px);
top: var(--sticky-header-height, 0px);
&[data-expanded]::before {
top: calc(-1 * var(--sticky-header-height, 40px));
top: calc(-1 * var(--sticky-header-height, 0px));
}
}

View file

@ -1,5 +1,6 @@
import {
AssistantMessage,
FilePart,
Message as MessageType,
Part as PartType,
type PermissionRequest,
@ -13,17 +14,13 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { ProviderIcon } from "./provider-icon"
import type { IconName } from "./provider-icons/types"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"
import { Card } from "./card"
@ -33,6 +30,7 @@ import { Spinner } from "./spinner"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
function computeStatusFromPart(part: PartType | undefined): string | undefined {
if (!part) return undefined
@ -79,6 +77,12 @@ function same<T>(a: readonly T[], b: readonly T[]) {
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 AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
@ -119,6 +123,7 @@ function AssistantMessageItem(props: {
export function SessionTurn(
props: ParentProps<{
sessionID: string
sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
@ -136,6 +141,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
@ -183,6 +189,19 @@ export function SessionTurn(
return 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()
@ -330,10 +349,19 @@ export function SessionTurn(
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [responseCopied, setResponseCopied] = createSignal(false)
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`)
}
const handleCopyResponse = async () => {
const content = response()
if (!content) return
@ -364,13 +392,28 @@ export function SessionTurn(
onUserInteracted: props.onUserInteracted,
})
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 diffInit = 20
const diffBatch = 20
const [store, setStore] = createStore({
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
stickyHeaderHeight: 0,
retrySeconds: 0,
diffsOpen: [] as string[],
diffLimit: diffInit,
@ -404,22 +447,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
createResizeObserver(
() => store.stickyTitleRef,
({ height }) => {
const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
setStore("stickyHeaderHeight", height + triggerHeight + 8)
},
)
createResizeObserver(
() => store.stickyTriggerRef,
({ height }) => {
const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
setStore("stickyHeaderHeight", titleHeight + height + 8)
},
)
createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
@ -460,7 +487,7 @@ export function SessionTurn(
})
return (
<div data-component="session-turn" class={props.classes?.root}>
<div data-component="session-turn" class={props.classes?.root} ref={setRootRef}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
@ -475,86 +502,63 @@ export function SessionTurn(
data-message={msg().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
>
<Switch>
<Match when={isShellMode()}>
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
{/* Title (sticky) */}
<div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<Switch>
<Match when={working()}>
<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />
</Match>
<Match when={true}>
<h1>{msg().summary?.title}</h1>
</Match>
</Switch>
</div>
<div data-slot="session-turn-user-badges">
<Show when={(msg() as UserMessage).agent}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
</Show>
<Show when={(msg() as UserMessage).model?.modelID}>
<span data-slot="session-turn-badge" class="inline-flex items-center gap-1">
<ProviderIcon
id={(msg() as UserMessage).model!.providerID as IconName}
class="size-3.5 shrink-0"
/>
{(msg() as UserMessage).model?.modelID}
</span>
</Show>
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant || "default"}</span>
</div>
</div>
</div>
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} />
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div ref={(el) => setStore("stickyTriggerRef", el)} 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 ?? (() => {})}
>
<Show when={working()}>
<Spinner />
</Show>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
</Switch>
<span>·</span>
<span>{store.duration}</span>
<Show when={assistantMessages().length > 0}>
<Icon name="chevron-grabber-vertical" size="small" />
</Show>
</Button>
<Show when={attachmentParts().length > 0}>
<div data-slot="session-turn-attachments">
<Message message={msg()} parts={attachmentParts()} />
</div>
</Show>
<div data-slot="session-turn-sticky" ref={setStickyRef}>
{/* User Message */}
<div data-slot="session-turn-message-content">
<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 ?? (() => {})}
>
<Show when={working()}>
<Spinner />
</Show>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
</Switch>
<span>·</span>
<span>{store.duration}</span>
<Show when={assistantMessages().length > 0}>
<Icon name="chevron-grabber-vertical" size="small" />
</Show>
</Button>
</div>
</Show>
</div>
{/* Response */}
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner">
@ -612,7 +616,7 @@ export function SessionTurn(
setStore("diffsOpen", value)
}}
>
<For each={(msg().summary?.diffs ?? []).slice(0, store.diffLimit)}>
<For each={(data.store.session_diff?.[props.sessionID] ?? []).slice(0, store.diffLimit)}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
@ -658,13 +662,13 @@ export function SessionTurn(
)}
</For>
</Accordion>
<Show when={(msg().summary?.diffs?.length ?? 0) > store.diffLimit}>
<Show when={(data.store.session_diff?.[props.sessionID]?.length ?? 0) > store.diffLimit}>
<Button
data-slot="session-turn-accordion-more"
variant="ghost"
size="small"
onClick={() => {
const total = msg().summary?.diffs?.length ?? 0
const total = data.store.session_diff?.[props.sessionID]?.length ?? 0
setStore("diffLimit", (limit) => {
const next = limit + diffBatch
if (next > total) return total
@ -672,7 +676,8 @@ export function SessionTurn(
})
}}
>
Show more changes ({(msg().summary?.diffs?.length ?? 0) - store.diffLimit})
Show more changes (
{(data.store.session_diff?.[props.sessionID]?.length ?? 0) - store.diffLimit})
</Button>
</Show>
</div>

View file

@ -52,6 +52,7 @@
background: var(--input-base);
&:focus-within {
border-color: transparent;
/* border/shadow-xs/select */
box-shadow:
0 0 0 3px var(--border-weak-selected),