summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-15 13:32:15 -0600
committerAdam <[email protected]>2026-01-19 10:55:57 -0600
commit1f11a8a6ea46867e2ad199c987bf14696a1b91d8 (patch)
tree64a1a83c5d576a9592229d67815e76359b399a03
parentd5ae8e0bef991f2b2ad9766b9ae2f1c903badab3 (diff)
downloadopencode-1f11a8a6ea46867e2ad199c987bf14696a1b91d8.tar.gz
opencode-1f11a8a6ea46867e2ad199c987bf14696a1b91d8.zip
feat(app): improved session layout
-rw-r--r--packages/app/src/pages/layout.tsx140
-rw-r--r--packages/app/src/pages/session.tsx20
-rw-r--r--packages/enterprise/src/routes/share/[shareID].tsx21
-rw-r--r--packages/ui/src/components/hover-card.css5
-rw-r--r--packages/ui/src/components/message-nav.css4
-rw-r--r--packages/ui/src/components/message-nav.tsx24
-rw-r--r--packages/ui/src/components/session-message-rail.css44
-rw-r--r--packages/ui/src/components/session-message-rail.tsx46
8 files changed, 121 insertions, 183 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 39ca39b67..2f71570f4 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -28,13 +28,14 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
-import { Session } from "@opencode-ai/sdk/v2/client"
+import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store"
import {
@@ -1329,63 +1330,104 @@ export default function Layout(props: ParentProps) {
return agent?.color
})
+ const hoverMessages = createMemo(() =>
+ sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+ )
+ const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
+ const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
+ const isActive = createMemo(() => props.session.id === params.id)
+
+ const messageLabel = (message: Message) => {
+ const parts = sessionStore.part[message.id] ?? []
+ const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
+ return text?.text
+ }
+
+ const item = (
+ <A
+ href={`${props.slug}/session/${props.session.id}`}
+ class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+ onMouseEnter={() => prefetchSession(props.session, "high")}
+ onFocus={() => prefetchSession(props.session, "high")}
+ >
+ <div class="flex items-center gap-1 w-full">
+ <div
+ class="shrink-0 size-6 flex items-center justify-center"
+ style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+ >
+ <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+ <Match when={isWorking()}>
+ <Spinner class="size-[15px]" />
+ </Match>
+ <Match when={hasPermissions()}>
+ <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
+ <Match when={hasError()}>
+ <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={notifications().length > 0}>
+ <div class="size-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ </Switch>
+ </div>
+ <Tooltip
+ inactive={hoverAllowed()}
+ placement="top-start"
+ value={props.session.title}
+ gutter={0}
+ openDelay={3000}
+ class="grow-1 min-w-0"
+ >
+ <InlineEditor
+ id={`session:${props.session.id}`}
+ value={() => props.session.title}
+ onSave={(next) => renameSession(props.session, next)}
+ class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
+ displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
+ stopPropagation
+ />
+ </Tooltip>
+ <Show when={props.session.summary}>
+ {(summary) => (
+ <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+ <DiffChanges changes={summary()} />
+ </div>
+ )}
+ </Show>
+ </div>
+ </A>
+ ))
+
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
- <A
- href={`${props.slug}/session/${props.session.id}`}
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
- onMouseEnter={() => prefetchSession(props.session, "high")}
- onFocus={() => prefetchSession(props.session, "high")}
+ <Show
+ when={hoverAllowed()}
+ fallback={item}
>
- <div class="flex items-center gap-1 w-full">
- <div
- class="shrink-0 size-6 flex items-center justify-center"
- style={{ color: tint() ?? "var(--icon-interactive-base)" }}
- >
- <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
- <Match when={isWorking()}>
- <Spinner class="size-[15px]" />
- </Match>
- <Match when={hasPermissions()}>
- <div class="size-1.5 rounded-full bg-surface-warning-strong" />
- </Match>
- <Match when={hasError()}>
- <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
- </Match>
- <Match when={notifications().length > 0}>
- <div class="size-1.5 rounded-full bg-text-interactive-base" />
- </Match>
- </Switch>
- </div>
- <Tooltip
- placement="top-start"
- value={props.session.title}
- gutter={0}
- openDelay={3000}
- class="grow-1 min-w-0"
- >
- <InlineEditor
- id={`session:${props.session.id}`}
- value={() => props.session.title}
- onSave={(next) => renameSession(props.session, next)}
- class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
- displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
- stopPropagation
+ <HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
+ <Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
+ <MessageNav
+ messages={hoverMessages() ?? []}
+ current={undefined}
+ getLabel={messageLabel}
+ onMessageSelect={(message) => {
+ if (!isActive()) {
+ navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
+ return
+ }
+ window.location.hash = `message-${message.id}`
+ window.dispatchEvent(new HashChangeEvent("hashchange"))
+ }}
+ size="normal"
+ class="w-60"
/>
- </Tooltip>
- <Show when={props.session.summary}>
- {(summary) => (
- <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
- <DiffChanges changes={summary()} />
- </div>
- )}
</Show>
- </div>
- </A>
+ </HoverCard>
+ </Show>
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index f063ce35b..b1c844f0c 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -18,7 +18,6 @@ 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 { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -1163,17 +1162,6 @@ export default function Page() {
}
>
<div class="relative w-full h-full min-w-0">
- <Show when={isDesktop()}>
- <div class="absolute inset-0 pointer-events-none z-10">
- <SessionMessageRail
- messages={visibleUserMessages()}
- current={activeMessage()}
- onMessageSelect={scrollToMessage}
- wide={!showTabs()}
- class="pointer-events-auto"
- />
- </div>
- </Show>
<div
ref={setScrollRef}
onScroll={(e) => {
@@ -1255,13 +1243,7 @@ export default function Page() {
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
- container:
- "px-4 md:px-6 " +
- (!showTabs()
- ? "md:max-w-200 md:mx-auto"
- : visibleUserMessages().length > 1
- ? "md:pr-6 md:pl-18"
- : ""),
+ container: "w-full px-4 md:px-6",
}}
/>
</div>
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx
index 776b42264..d657ddc12 100644
--- a/packages/enterprise/src/routes/share/[shareID].tsx
+++ b/packages/enterprise/src/routes/share/[shareID].tsx
@@ -16,7 +16,6 @@ import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
import { DateTime } from "luxon"
-import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
@@ -353,26 +352,16 @@ export default function () {
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
- "mx-auto max-w-200": !wide(),
}}
>
<div
classList={{
- "w-full flex justify-start items-start min-w-0": true,
- "max-w-200 mx-auto px-6": wide(),
- "pr-6 pl-18": !wide() && messages().length > 1,
- "px-6": !wide() && messages().length === 1,
+ "w-full flex justify-start items-start min-w-0 px-6": true,
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
- <SessionMessageRail
- messages={messages()}
- current={activeMessage()}
- onMessageSelect={setActiveMessage}
- wide={wide()}
- />
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
@@ -386,13 +375,7 @@ export default function () {
classes={{
root: "grow",
content: "flex flex-col justify-between",
- container:
- "w-full pb-20 " +
- (wide()
- ? "max-w-200 mx-auto px-6"
- : messages().length > 1
- ? "pr-6 pl-18"
- : "px-6"),
+ container: "w-full pb-20 px-6",
}}
>
<div
diff --git a/packages/ui/src/components/hover-card.css b/packages/ui/src/components/hover-card.css
index 43a26c98f..f1172dfc7 100644
--- a/packages/ui/src/components/hover-card.css
+++ b/packages/ui/src/components/hover-card.css
@@ -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;
diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css
index 465bd66fe..9c2116b90 100644
--- a/packages/ui/src/components/message-nav.css
+++ b/packages/ui/src/components/message-nav.css
@@ -10,6 +10,10 @@
width: 240px;
gap: 4px;
}
+
+ &[data-size="compact"] {
+ width: 24px;
+ }
}
[data-slot="message-nav-item"] {
diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx
index 7416cfd93..0dd7c42b0 100644
--- a/packages/ui/src/components/message-nav.tsx
+++ b/packages/ui/src/components/message-nav.tsx
@@ -9,9 +9,10 @@ export function MessageNav(
current?: UserMessage
size: "normal" | "compact"
onMessageSelect: (message: UserMessage) => void
+ getLabel?: (message: UserMessage) => string | undefined
},
) {
- const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
+ const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
const content = () => (
<ul role="list" data-component="message-nav" data-size={local.size} {...others}>
@@ -19,23 +20,36 @@ export function MessageNav(
{(message) => {
const handleClick = () => local.onMessageSelect(message)
+ const handleKeyPress = (event: KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return
+ event.preventDefault()
+ local.onMessageSelect(message)
+ }
+
return (
<li data-slot="message-nav-item">
<Switch>
<Match when={local.size === "compact"}>
- <div data-slot="message-nav-tick-button" data-active={message.id === local.current?.id || undefined}>
+ <div
+ data-slot="message-nav-tick-button"
+ data-active={message.id === local.current?.id || undefined}
+ role="button"
+ tabindex={0}
+ onClick={handleClick}
+ onKeyDown={handleKeyPress}
+ >
<div data-slot="message-nav-tick-line" />
</div>
</Match>
<Match when={local.size === "normal"}>
- <button data-slot="message-nav-message-button" onClick={handleClick}>
+ <button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
<div
data-slot="message-nav-title-preview"
data-active={message.id === local.current?.id || undefined}
>
- <Show when={message.summary?.title} fallback="New message">
- {message.summary?.title}
+ <Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
+ {local.getLabel?.(message) ?? message.summary?.title}
</Show>
</div>
</button>
diff --git a/packages/ui/src/components/session-message-rail.css b/packages/ui/src/components/session-message-rail.css
deleted file mode 100644
index 9f248bed2..000000000
--- a/packages/ui/src/components/session-message-rail.css
+++ /dev/null
@@ -1,44 +0,0 @@
-[data-component="session-message-rail"] {
- display: contents;
-}
-
-[data-slot="session-message-rail-compact"],
-[data-slot="session-message-rail-full"] {
- position: absolute;
- left: 1.5rem;
- margin-top: 0.625rem;
- top: 0;
- bottom: 8rem;
- overflow-y: auto;
-}
-
-[data-slot="session-message-rail-compact"] {
- display: flex;
-}
-
-[data-slot="session-message-rail-full"] {
- display: none;
-}
-
-@container (min-width: 88rem) {
- [data-slot="session-message-rail-compact"] {
- display: none;
- }
- [data-slot="session-message-rail-full"] {
- display: flex;
- }
-}
-
-[data-component="session-message-rail"] [data-slot="session-message-rail-full"] {
- transform: none;
-}
-
-[data-component="session-message-rail"][data-wide] [data-slot="session-message-rail-full"] {
- margin-top: 0.125rem;
- left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem);
- transform: translateX(-100%);
-}
-
-[data-component="session-message-rail"]:not([data-wide]) [data-slot="session-message-rail-full"] {
- margin-top: 0.625rem;
-}
diff --git a/packages/ui/src/components/session-message-rail.tsx b/packages/ui/src/components/session-message-rail.tsx
deleted file mode 100644
index 1935a4f93..000000000
--- a/packages/ui/src/components/session-message-rail.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { UserMessage } from "@opencode-ai/sdk/v2"
-import { ComponentProps, Show, splitProps } from "solid-js"
-import { MessageNav } from "./message-nav"
-import "./session-message-rail.css"
-
-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"])
-
- 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 data-slot="session-message-rail-compact">
- <MessageNav
- messages={local.messages}
- current={local.current}
- onMessageSelect={local.onMessageSelect}
- size="compact"
- />
- </div>
- <div data-slot="session-message-rail-full">
- <MessageNav
- messages={local.messages}
- current={local.current}
- onMessageSelect={local.onMessageSelect}
- size={local.wide ? "normal" : "compact"}
- />
- </div>
- </div>
- </Show>
- )
-}