diff options
| author | Adam <[email protected]> | 2026-01-15 13:32:15 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-19 10:55:57 -0600 |
| commit | 1f11a8a6ea46867e2ad199c987bf14696a1b91d8 (patch) | |
| tree | 64a1a83c5d576a9592229d67815e76359b399a03 | |
| parent | d5ae8e0bef991f2b2ad9766b9ae2f1c903badab3 (diff) | |
| download | opencode-1f11a8a6ea46867e2ad199c987bf14696a1b91d8.tar.gz opencode-1f11a8a6ea46867e2ad199c987bf14696a1b91d8.zip | |
feat(app): improved session layout
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 140 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 20 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/share/[shareID].tsx | 21 | ||||
| -rw-r--r-- | packages/ui/src/components/hover-card.css | 5 | ||||
| -rw-r--r-- | packages/ui/src/components/message-nav.css | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/message-nav.tsx | 24 | ||||
| -rw-r--r-- | packages/ui/src/components/session-message-rail.css | 44 | ||||
| -rw-r--r-- | packages/ui/src/components/session-message-rail.tsx | 46 |
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> - ) -} |
