Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { For } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SettingsProvider, useSettings } from "@/context/settings"

export default {
title: "Composer/Revert Dock",
id: "composer-revert-dock",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Real \`SessionRevertDock\` from app code, rendered above a mock composer card.

### Source path
- \`packages/app/src/pages/session/composer/session-revert-dock.tsx\`

### Why the mock composer
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.

### Layout split
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.

### Notes
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
},
},
},
}

const messages = [
"update current branch with latest changes from dev and fix conflicts if any",
"investigate why the chat input loses focus after sending a message",
"Debug why streaming responses sometimes duplicate the last token",
"suggest a better title for this PR based on the diff",
"add a storybook story for the revert dock",
"reduce re-renders in the timeline component",
]

const btn = (accent?: boolean) =>
({
padding: "6px 12px",
"border-radius": "6px",
border: "1px solid var(--v2-border-border-base, #0000001a)",
background: accent ? "var(--v2-background-bg-contrast, #242424)" : "var(--v2-background-bg-base, #fff)",
color: accent ? "var(--v2-text-text-contrast, #fafafa)" : "var(--v2-text-text-base, #161616)",
cursor: "pointer",
"font-size": "13px",
}) as const

function Stage(props: { count: number }) {
const settings = useSettings()
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
const [store, setStore] = createStore({ items: seed() })

const v2 = () => settings.general.newLayoutDesigns()
const reset = () => setStore("items", seed())
const restore = (id: string) =>
setStore(
"items",
store.items.filter((item) => item.id !== id),
)

return (
<div style={{ display: "grid", gap: "16px", "max-width": "720px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<button style={btn()} onClick={reset}>
Reset ({props.count})
</button>
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
Layout: {v2() ? "v2" : "v1"}
</button>
</div>

{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
<div style={{ display: "flex", "flex-direction": "column" }}>
<SessionRevertDock items={store.items} onRestore={restore} />
<div
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
class="min-h-24 w-full rounded-[12px] border border-v2-border-border-base bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
>
Ask anything...
</div>
</div>

<div class="text-[12px] text-v2-text-text-faint">
Restored so far:{" "}
<For each={seed()}>
{(item) => <span>{store.items.some((current) => current.id === item.id) ? "" : `“${item.text}” `}</span>}
</For>
</div>
</div>
)
}

const story = (count: number) => () => (
<SettingsProvider>
<Stage count={count} />
</SettingsProvider>
)

export const OneMessage = {
name: "1 rolled back",
render: story(1),
}

export const ThreeMessages = {
name: "3 rolled back",
render: story(3),
}

export const ManyMessages = {
name: "6 rolled back (scrolls)",
render: story(6),
}
203 changes: 146 additions & 57 deletions packages/app/src/pages/session/composer/session-revert-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"

export function SessionRevertDock(props: {
items: { id: string; text: string }[]
Expand All @@ -12,6 +16,7 @@ export function SessionRevertDock(props: {
onRestore: (id: string) => void
}) {
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
collapsed: true,
})
Expand All @@ -31,69 +36,153 @@ export function SessionRevertDock(props: {
)
const preview = createMemo(() => props.items[0]?.text ?? "")

const onHeaderKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}

return (
<DockTray data-component="session-revert-dock">
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<DockTray data-component="session-revert-dock">
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>

<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>

<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
}
>
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
data-component="session-revert-dock"
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
>
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
<div
class="flex h-[42px] items-center gap-2 pl-4 pr-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<IconV2 name="outline-reset" size="normal" class="text-v2-icon-icon-muted" />
<span
classList={{
"font-[440] shrink-0 cursor-default text-[13px] leading-5 tracking-[-0.04px]": true,
"text-v2-text-text-base": !store.collapsed,
"text-v2-text-text-muted": store.collapsed,
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
>
{label()}
</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
{preview()}
</span>
</Show>
<div class="ml-auto shrink-0">
<IconButtonV2
icon={<IconV2 name="outline-chevron-down" size="small" />}
size="large"
variant="ghost-muted"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>
</div>

<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
{/* Sacrificial space the composer overlaps via its negative lift (18px), so the header stays fully visible */}
<Show when={store.collapsed}>
<div class="h-[18px]" aria-hidden="true" />
</Show>

<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
<Show when={!store.collapsed}>
{/* Scroll viewport ends above the composer; the 18px sacrificial below is what the composer overlaps */}
<div class="flex max-h-42 flex-col gap-2 overflow-y-auto px-4 pt-px pb-3 no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex h-6 min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate text-[13px] font-[400] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{item.text}
</span>
<ButtonV2
size="small"
variant="neutral"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</ButtonV2>
</div>
)}
</For>
</div>
<div class="h-[18px]" aria-hidden="true" />
</Show>
</div>
</Show>
)
}
5 changes: 5 additions & 0 deletions packages/storybook/.storybook/mocks/app/context/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const dict: Record<string, string> = {
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse todos",
"session.todo.expand": "Expand todos",
"session.revertDock.summary.one": "{{count}} rolled back message",
"session.revertDock.summary.other": "{{count}} rolled back messages",
"session.revertDock.collapse": "Collapse rolled back messages",
"session.revertDock.expand": "Expand rolled back messages",
"session.revertDock.restore": "Restore message",
"prompt.loading": "Loading prompt...",
"prompt.placeholder.normal": "Ask anything...",
"prompt.placeholder.simple": "Ask anything...",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/v2/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
},
"outline-reset": {
viewBox: "0 0 20 20",
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
},
archive: {
viewBox: "0 0 16 16",
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
Expand Down
Loading