Merge branch 'dev' into desktop-poilsh-styles-ui-ux
This commit is contained in:
commit
457c246f10
47 changed files with 1173 additions and 490 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue