summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-18 05:26:24 -0600
committerAdam <[email protected]>2026-01-19 10:55:57 -0600
commit7811e01c8efc57d56b91547463c707baf2eb6815 (patch)
tree8a7a715722cb07b95e0ae56b708913af38fa2146
parentbefd0f16362678dcd99cd9118cbcb044997c9511 (diff)
downloadopencode-7811e01c8efc57d56b91547463c707baf2eb6815.tar.gz
opencode-7811e01c8efc57d56b91547463c707baf2eb6815.zip
fix(app): new layout improvements
-rw-r--r--packages/app/src/pages/session.tsx135
-rw-r--r--packages/ui/src/components/message-part.tsx35
-rw-r--r--packages/ui/src/components/session-turn.css95
-rw-r--r--packages/ui/src/components/session-turn.tsx161
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">