Prechádzať zdrojové kódy

feat(app): restyle revert dock for v2 (#35560)

usrnk1 1 mesiac pred
rodič
commit
4f9207daac

+ 118 - 0
packages/app/src/pages/session/composer/session-revert-dock.stories.tsx

@@ -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),
+}

+ 146 - 57
packages/app/src/pages/session/composer/session-revert-dock.tsx

@@ -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 }[]
@@ -12,6 +16,7 @@ export function SessionRevertDock(props: {
   onRestore: (id: string) => void
 }) {
   const language = useLanguage()
+  const settings = useSettings()
   const [store, setStore] = createStore({
     collapsed: true,
   })
@@ -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 - 0
packages/storybook/.storybook/mocks/app/context/language.ts

@@ -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...",

+ 4 - 0
packages/ui/src/v2/components/icon.tsx

@@ -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"/>`,