diff options
| author | Adam <[email protected]> | 2026-01-18 05:26:24 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-19 10:55:57 -0600 |
| commit | 7811e01c8efc57d56b91547463c707baf2eb6815 (patch) | |
| tree | 8a7a715722cb07b95e0ae56b708913af38fa2146 | |
| parent | befd0f16362678dcd99cd9118cbcb044997c9511 (diff) | |
| download | opencode-7811e01c8efc57d56b91547463c707baf2eb6815.tar.gz opencode-7811e01c8efc57d56b91547463c707baf2eb6815.zip | |
fix(app): new layout improvements
| -rw-r--r-- | packages/app/src/pages/session.tsx | 135 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 35 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.css | 95 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 161 |
4 files changed, 239 insertions, 187 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5f282ac85..31f9eac9c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -18,6 +18,7 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" +import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -787,17 +788,14 @@ export default function Page() { .filter((tab) => tab !== "context"), ) - const reviewTab = createMemo(() => hasReview() || tabs().active() === "review") - const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review") + const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review") - const showTabs = createMemo( - () => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()), - ) + const showTabs = createMemo(() => view().reviewPanel.opened()) const activeTab = createMemo(() => { const active = tabs().active() if (active) return active - if (reviewTab()) return "review" + if (hasReview()) return "review" const first = openedTabs()[0] if (first) return first @@ -1095,8 +1093,8 @@ export default function Page() { <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <SessionHeader /> <div class="flex-1 min-h-0 flex flex-col md:flex-row"> - {/* Mobile tab bar - only shown on mobile when there are diffs */} - <Show when={!isDesktop() && hasReview()}> + {/* Mobile tab bar - only shown on mobile when user opened review */} + <Show when={!isDesktop() && view().reviewPanel.opened()}> <Tabs class="h-auto"> <Tabs.List> <Tabs.Trigger @@ -1113,7 +1111,10 @@ export default function Page() { classes={{ button: "w-full" }} onClick={() => setStore("mobileTab", "review")} > - {reviewCount()} Files Changed + <Switch> + <Match when={hasReview()}>{reviewCount()} Files Changed</Match> + <Match when={true}>Review</Match> + </Switch> </Tabs.Trigger> </Tabs.List> </Tabs> @@ -1138,26 +1139,36 @@ export default function Page() { when={!mobileReview()} fallback={ <div class="relative h-full overflow-hidden"> - <Show - when={diffsReady()} - fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>} - > - <SessionReviewTab - diffs={diffs} - view={view} - diffStyle="unified" - onViewFile={(path) => { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+24px)]", - header: "px-4", - container: "px-4", - }} - /> - </Show> + <Switch> + <Match when={hasReview()}> + <Show + when={diffsReady()} + fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>} + > + <SessionReviewTab + diffs={diffs} + view={view} + diffStyle="unified" + onViewFile={(path) => { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + classes={{ + root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + header: "px-4", + container: "px-4", + }} + /> + </Show> + </Match> + <Match when={true}> + <div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3"> + <Mark class="w-6 opacity-40" /> + <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div> + </div> + </Match> + </Switch> </div> } > @@ -1170,11 +1181,29 @@ export default function Page() { }} onClick={autoScroll.handleInteraction} class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar" + style={{ "--session-title-height": info()?.title ? "40px" : "0px" }} > + <Show when={info()?.title}> + <div + classList={{ + "sticky top-0 z-30 bg-background-stronger": true, + "w-full": true, + "px-4 md:px-6": true, + "md:max-w-200 md:mx-auto": !showTabs(), + }} + > + <div class="h-10 flex items-center"> + <h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1> + </div> + </div> + </Show> + <div ref={autoScroll.contentRef} class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ + "w-full": true, + "md:max-w-200 md:mx-auto": !showTabs(), "mt-0.5": !showTabs(), "mt-0": showTabs(), }} @@ -1225,6 +1254,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, + "md:max-w-200": !showTabs(), "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]": platform.platform !== "desktop", "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]": @@ -1233,7 +1263,6 @@ export default function Page() { > <SessionTurn sessionID={params.id!} - sessionTitle={info()?.title} messageID={message.id} lastUserMessageID={lastUserMessage()?.id} stepsExpanded={store.expanded[message.id] ?? false} @@ -1333,7 +1362,7 @@ export default function Page() { <Tabs value={activeTab()} onChange={openTab}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List> - <Show when={reviewTab()}> + <Show when={true}> <Tabs.Trigger value="review"> <div class="flex items-center gap-3"> <Show when={diffs()}> @@ -1386,26 +1415,36 @@ export default function Page() { </div> </Tabs.List> </div> - <Show when={reviewTab()}> + <Show when={true}> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Show when={activeTab() === "review"}> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <Show - when={diffsReady()} - fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>} - > - <SessionReviewTab - diffs={diffs} - view={view} - diffStyle={layout.review.diffStyle()} - onDiffStyleChange={layout.review.setDiffStyle} - onViewFile={(path) => { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - </Show> + <Switch> + <Match when={hasReview()}> + <Show + when={diffsReady()} + fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>} + > + <SessionReviewTab + diffs={diffs} + view={view} + diffStyle={layout.review.diffStyle()} + onDiffStyleChange={layout.review.setDiffStyle} + onViewFile={(path) => { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + /> + </Show> + </Match> + <Match when={true}> + <div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3"> + <Mark class="w-6 opacity-40" /> + <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div> + </div> + </Match> + </Switch> </div> </Show> </Tabs.Content> diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 47403786b..b3fd01c2d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -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} /> diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index f7ab97179..a3c87c576 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -44,23 +44,33 @@ } } - [data-slot="session-turn-sticky-title"] { - width: 100%; + [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: calc(var(--sticky-header-height, 0px)); - 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"] { @@ -75,6 +85,61 @@ 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; @@ -266,11 +331,7 @@ } [data-component="sticky-accordion-header"] { - top: var(--sticky-header-height, 40px); - - &[data-expanded]::before { - top: calc(-1 * var(--sticky-header-height, 40px)); - } + position: static; } [data-slot="session-turn-accordion-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8b807af82..e5fe4ba1c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -13,17 +13,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" @@ -331,8 +327,6 @@ export function SessionTurn( const response = createMemo(() => lastTextPart()?.text) const responsePartId = createMemo(() => lastTextPart()?.id) - const sessionInfo = createMemo(() => data.store.session.find((item) => item.id === props.sessionID)) - const sessionTitle = createMemo(() => props.sessionTitle ?? sessionInfo()?.title) const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0) const hideResponsePart = createMemo(() => !working() && !!responsePartId()) @@ -371,15 +365,11 @@ export function SessionTurn( 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, status: rawStatus(), duration: duration(), - titleShown: false, }) createEffect( @@ -394,18 +384,6 @@ export function SessionTurn( ) createEffect(() => { - if (!sessionTitle()) { - setStore("titleShown", false) - return - } - if (store.titleShown) return - const first = allMessages().find((item) => item?.role === "user") - if (!first) return - if (first.id !== props.messageID) return - setStore("titleShown", true) - }) - - createEffect(() => { const r = retry() if (!r) { setStore("retrySeconds", 0) @@ -420,22 +398,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()) @@ -491,99 +453,58 @@ 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}> - <Show when={sessionTitle() && store.titleShown}> - <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"> + <div data-slot="session-turn-sticky"> + {/* User Message */} + <div data-slot="session-turn-message-content"> + <Message message={msg()} parts={parts()} /> + </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={working()}> - <Typewriter as="h1" text={sessionTitle()} data-slot="session-turn-typewriter" /> - </Match> - <Match when={true}> - <h1>{sessionTitle()}</h1> + <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> - </div> + <span>·</span> + <span>{store.duration}</span> + <Show when={assistantMessages().length > 0}> + <Icon name="chevron-grabber-vertical" size="small" /> + </Show> + </Button> </div> - </div> - </Show> - - <Show - when={ - (msg() as UserMessage).agent || - (msg() as UserMessage).model?.modelID || - (msg() as UserMessage).variant - } - > - <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> - <Show when={(msg() as UserMessage).variant}> - <span data-slot="session-turn-badge">{(msg() as UserMessage).variant}</span> - </Show> - </div> - </Show> - {/* User Message */} - <div data-slot="session-turn-message-content"> - <Message message={msg()} parts={parts()} /> + </Show> </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> - </div> - </Show> {/* Response */} <Show when={props.stepsExpanded && assistantMessages().length > 0}> <div data-slot="session-turn-collapsible-content-inner"> |
