summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-15 15:05:40 -0600
committerAdam <[email protected]>2026-01-19 10:55:57 -0600
commitbefd0f16362678dcd99cd9118cbcb044997c9511 (patch)
tree46affcaa30de5927f5036c608c3eb2eea8804deb
parent1f11a8a6ea46867e2ad199c987bf14696a1b91d8 (diff)
downloadopencode-befd0f16362678dcd99cd9118cbcb044997c9511.tar.gz
opencode-befd0f16362678dcd99cd9118cbcb044997c9511.zip
feat(app): new session layout
-rw-r--r--packages/app/src/pages/layout.tsx53
-rw-r--r--packages/app/src/pages/session.tsx4
-rw-r--r--packages/enterprise/src/routes/share/[shareID].tsx4
-rw-r--r--packages/ui/src/components/session-turn.css28
-rw-r--r--packages/ui/src/components/session-turn.tsx96
5 files changed, 107 insertions, 78 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 2f71570f4..5312ff0a2 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -1296,7 +1296,13 @@ export default function Layout(props: ParentProps) {
)
}
- const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
+ const SessionItem = (props: {
+ session: Session
+ slug: string
+ mobile?: boolean
+ dense?: boolean
+ popover?: boolean
+ }): JSX.Element => {
const notification = useNotification()
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
@@ -1335,6 +1341,7 @@ export default function Layout(props: ParentProps) {
)
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
+ const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const messageLabel = (message: Message) => {
@@ -1370,23 +1377,14 @@ export default function Layout(props: ParentProps) {
</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>
+ <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
+ />
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -1396,7 +1394,7 @@ export default function Layout(props: ParentProps) {
</Show>
</div>
</A>
- ))
+ )
return (
<div
@@ -1405,8 +1403,12 @@ export default function Layout(props: ParentProps) {
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show
- when={hoverAllowed()}
- fallback={item}
+ when={hoverEnabled()}
+ fallback={
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
+ {item}
+ </Tooltip>
+ }
>
<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>}>
@@ -1730,6 +1732,7 @@ export default function Layout(props: ParentProps) {
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
+ popover={false}
/>
)}
</For>
@@ -1746,7 +1749,13 @@ export default function Layout(props: ParentProps) {
</div>
<For each={sessions(directory)}>
{(session) => (
- <SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
+ <SessionItem
+ session={session}
+ slug={base64Encode(directory)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ />
)}
</For>
</div>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index b1c844f0c..5f282ac85 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1233,6 +1233,7 @@ export default function Page() {
>
<SessionTurn
sessionID={params.id!}
+ sessionTitle={info()?.title}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.expanded[message.id] ?? false}
@@ -1241,8 +1242,7 @@ export default function Page() {
}
classes={{
root: "min-w-0 w-full relative",
- content:
- "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
+ content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
/>
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx
index d657ddc12..483db4d93 100644
--- a/packages/enterprise/src/routes/share/[shareID].tsx
+++ b/packages/enterprise/src/routes/share/[shareID].tsx
@@ -295,13 +295,13 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
+ sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
- content:
- "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
+ content: "flex flex-col justify-between !overflow-visible",
container: "px-4",
}}
/>
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 1e3cc0b29..f7ab97179 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -29,23 +29,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;
@@ -71,7 +54,7 @@
[data-slot="session-turn-response-trigger"] {
position: sticky;
- top: 32px;
+ top: calc(var(--sticky-header-height, 0px));
background-color: var(--background-stronger);
z-index: 20;
width: calc(100% + 9px);
@@ -88,10 +71,17 @@
}
[data-slot="session-turn-message-content"] {
- margin-top: -18px;
+ margin-top: 0;
max-width: 100%;
}
+ [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);
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index ae1321bac..8b807af82 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -119,6 +119,7 @@ function AssistantMessageItem(props: {
export function SessionTurn(
props: ParentProps<{
sessionID: string
+ sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
@@ -330,7 +331,9 @@ export function SessionTurn(
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
- const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
+ 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())
const [responseCopied, setResponseCopied] = createSignal(false)
@@ -376,6 +379,7 @@ export function SessionTurn(
diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
+ titleShown: false,
})
createEffect(
@@ -390,6 +394,18 @@ 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)
@@ -482,40 +498,53 @@ export function SessionTurn(
<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>
+ <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">
+ <Switch>
+ <Match when={working()}>
+ <Typewriter as="h1" text={sessionTitle()} data-slot="session-turn-typewriter" />
+ </Match>
+ <Match when={true}>
+ <h1>{sessionTitle()}</h1>
+ </Match>
+ </Switch>
+ </div>
</div>
</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()} />
</div>
+
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
@@ -612,7 +641,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 +687,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 +701,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>