Procházet zdrojové kódy

feat: add session menu back

Aaron Iker před 3 měsíci
rodič
revize
24ff7de33d

+ 1 - 0
packages/app/src/components/session/index.ts

@@ -1,3 +1,4 @@
+export { SessionHeader } from "./session-header"
 export { SessionContextTab } from "./session-context-tab"
 export { SortableTab, FileVisual } from "./session-sortable-tab"
 export { SortableTerminalTab } from "./session-sortable-terminal-tab"

+ 133 - 0
packages/app/src/components/session/session-header.tsx

@@ -0,0 +1,133 @@
+import { createMemo, Show } from "solid-js"
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useLayout } from "@/context/layout"
+import { useCommand } from "@/context/command"
+import { useSync } from "@/context/sync"
+import { getFilename } from "@opencode-ai/util/path"
+import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { Select } from "@opencode-ai/ui/select"
+import type { Session } from "@opencode-ai/sdk/v2/client"
+import { same } from "@/utils/same"
+
+export function SessionHeader() {
+  const layout = useLayout()
+  const params = useParams()
+  const navigate = useNavigate()
+  const command = useCommand()
+  const sync = useSync()
+
+  const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+
+  const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
+  const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
+  const parentSession = createMemo(() => {
+    const current = currentSession()
+    if (!current?.parentID) return undefined
+    return sync.data.session.find((s) => s.id === current.parentID)
+  })
+  const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
+
+  function navigateToProject(directory: string) {
+    navigate(`/${base64Encode(directory)}`)
+  }
+
+  function navigateToSession(session: Session | undefined) {
+    if (!session) return
+    // Only navigate if we're actually changing to a different session
+    if (session.id === params.id) return
+    navigate(`/${params.dir}/session/${session.id}`)
+  }
+
+  return (
+    <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
+      <button
+        type="button"
+        class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
+        onClick={layout.mobileSidebar.toggle}
+      >
+        <Icon name="menu" size="small" />
+      </button>
+      <div class="px-4 flex items-center justify-between gap-3 w-full">
+        <div class="flex items-center gap-3 min-w-0">
+          <div class="flex items-center gap-2 min-w-0">
+            <div class="hidden xl:flex items-center gap-2">
+              <Select
+                options={worktrees()}
+                current={sync.project?.worktree ?? projectDirectory()}
+                label={(x) => getFilename(x)}
+                onSelect={(x) => (x ? navigateToProject(x) : undefined)}
+                class="text-14-regular text-text-base"
+                variant="ghost"
+              >
+                {/* @ts-ignore */}
+                {(i) => (
+                  <div class="flex items-center gap-2">
+                    <Icon name="folder" size="small" />
+                    <div class="text-text-strong">{getFilename(i)}</div>
+                  </div>
+                )}
+              </Select>
+              <div class="text-text-weaker">/</div>
+            </div>
+            <Show
+              when={parentSession()}
+              fallback={
+                <>
+                  <Select
+                    options={sessions()}
+                    current={currentSession()}
+                    placeholder="New session"
+                    label={(x) => x.title}
+                    value={(x) => x.id}
+                    onSelect={navigateToSession}
+                    class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
+                    variant="ghost"
+                  />
+                </>
+              }
+            >
+              <div class="flex items-center gap-2 min-w-0">
+                <Select
+                  options={sessions()}
+                  current={parentSession()}
+                  placeholder="Back to parent session"
+                  label={(x) => x.title}
+                  value={(x) => x.id}
+                  onSelect={(session) => {
+                    // Only navigate if selecting a different session than current parent
+                    const currentParent = parentSession()
+                    if (session && currentParent && session.id !== currentParent.id) {
+                      navigateToSession(session)
+                    }
+                  }}
+                  class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
+                  variant="ghost"
+                />
+                <div class="text-text-weaker">/</div>
+                <div class="flex items-center gap-1.5 min-w-0">
+                  <Tooltip value="Back to parent session">
+                    <button
+                      type="button"
+                      class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
+                      onClick={() => navigateToSession(parentSession())}
+                    >
+                      <Icon name="arrow-left" size="small" class="text-icon-base" />
+                    </button>
+                  </Tooltip>
+                </div>
+              </div>
+            </Show>
+          </div>
+          <Show when={currentSession() && !parentSession()}>
+            <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
+              <IconButton as={A} href={`/${params.dir}/session`} icon="plus-small" variant="ghost" />
+            </TooltipKeybind>
+          </Show>
+        </div>
+      </div>
+    </header>
+  )
+}

+ 1 - 14
packages/app/src/components/toolbar/index.tsx

@@ -8,7 +8,6 @@ import { usePlatform } from "@/context/platform"
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
 
-// ID for the portal mount target
 export const TOOLBAR_PORTAL_ID = "toolbar-content-portal"
 
 export const Toolbar: Component<ComponentProps<"div">> = ({ class: className, ...props }) => {
@@ -21,7 +20,7 @@ export const Toolbar: Component<ComponentProps<"div">> = ({ class: className, ..
       classList={{
         "pl-[80px]": IS_MAC && platform.platform !== "web",
         "pl-2": !IS_MAC || platform.platform === "web",
-        "py-2 mx-px bg-background-base border-b border-border-weak-base flex items-center justify-between w-full border-box relative": true,
+        "py-2 min-h-[41px] mx-px bg-background-base border-b border-border-weak-base flex items-center justify-between w-full border-box relative": true,
         ...(className ? { [className]: true } : {}),
       }}
       data-tauri-drag-region
@@ -59,21 +58,9 @@ export const Toolbar: Component<ComponentProps<"div">> = ({ class: className, ..
         </Button>
       </TooltipKeybind>
 
-      <Button
-        variant="ghost"
-        size="normal"
-        class="group/sidebar-toggle shrink-0 text-left justify-center align-middle rounded-lg px-1.5 xl:hidden relative z-10"
-        onClick={layout.mobileSidebar.toggle}
-      >
-        <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-          <Icon name="menu" size="small" />
-        </div>
-      </Button>
-
       <div id={TOOLBAR_PORTAL_ID} class="contents" />
     </div>
   )
 }
 
-// Re-export for use in DirectoryLayout
 export { ToolbarSession } from "./session"

+ 96 - 205
packages/app/src/components/toolbar/session.tsx

@@ -1,256 +1,147 @@
-import { ComponentProps, createMemo, createResource, Show, Component } from "solid-js"
-import { A, useNavigate, useParams } from "@solidjs/router"
+import { createMemo, createResource, Show, Component, type ComponentProps } from "solid-js"
+import { useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
 import { useCommand } from "@/context/command"
 import { useServer } from "@/context/server"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useSync } from "@/context/sync"
 import { useGlobalSDK } from "@/context/global-sdk"
-import { getFilename } from "@opencode-ai/util/path"
-import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
+import { base64Decode } from "@opencode-ai/util/encode"
 import { iife } from "@opencode-ai/util/iife"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { Select } from "@opencode-ai/ui/select"
 import { Popover } from "@opencode-ai/ui/popover"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { DialogSelectServer } from "@/components/dialog-select-server"
 import { SessionLspIndicator } from "@/components/session-lsp-indicator"
 import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
-import type { Session } from "@opencode-ai/sdk/v2/client"
-import { same } from "@/utils/same"
 
-export const ToolbarSession: Component<ComponentProps<"header">> = ({ class: className, ...props }) => {
+export const ToolbarSession: Component<ComponentProps<"div">> = ({ class: className, ...props }) => {
   const globalSDK = useGlobalSDK()
   const layout = useLayout()
   const params = useParams()
-  const navigate = useNavigate()
   const command = useCommand()
   const server = useServer()
   const dialog = useDialog()
   const sync = useSync()
 
   const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
-
-  const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
   const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
-  const parentSession = createMemo(() => {
-    const current = currentSession()
-    if (!current?.parentID) return undefined
-    return sync.data.session.find((s) => s.id === current.parentID)
-  })
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
-  const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
-
-  function navigateToProject(directory: string) {
-    navigate(`/${base64Encode(directory)}`)
-  }
-
-  function navigateToSession(session: Session | undefined) {
-    if (!session) return
-    // Only navigate if we're actually changing to a different session
-    if (session.id === params.id) return
-    navigate(`/${params.dir}/session/${session.id}`)
-  }
 
   return (
-    <header class={`flex absolute inset-0 ${className}`} {...props}>
-      <div class="flex items-center justify-between gap-4 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
-        <div class="flex items-center gap-2 min-w-0">
-          <div class="flex items-center gap-2 min-w-0">
-            <div class="hidden xl:flex items-center gap-2">
-              <Select
-                options={worktrees()}
-                current={sync.project?.worktree ?? projectDirectory()}
-                label={(x) => getFilename(x)}
-                onSelect={(x) => (x ? navigateToProject(x) : undefined)}
-                class="text-14-regular text-text-base"
-                variant="ghost"
-              >
-                {/* @ts-ignore */}
-                {(i) => (
-                  <div class="flex items-center gap-2">
-                    <Icon name="folder" size="small" />
-                    <div class="text-text-strong">{getFilename(i)}</div>
-                  </div>
-                )}
-              </Select>
-              <div class="text-text-weaker">/</div>
-            </div>
-            <Show
-              when={parentSession()}
-              fallback={
-                <>
-                  <Select
-                    options={sessions()}
-                    current={currentSession()}
-                    placeholder="New session"
-                    label={(x) => x.title}
-                    value={(x) => x.id}
-                    onSelect={navigateToSession}
-                    class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
-                    variant="ghost"
-                  />
-                </>
-              }
-            >
-              <div class="flex items-center gap-2 min-w-0">
-                <Select
-                  options={sessions()}
-                  current={parentSession()}
-                  placeholder="Back to parent session"
-                  label={(x) => x.title}
-                  value={(x) => x.id}
-                  onSelect={(session) => {
-                    // Only navigate if selecting a different session than current parent
-                    const currentParent = parentSession()
-                    if (session && currentParent && session.id !== currentParent.id) {
-                      navigateToSession(session)
-                    }
-                  }}
-                  class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
-                  variant="ghost"
-                />
-                <div class="text-text-weaker">/</div>
-                <div class="flex items-center gap-1.5 min-w-0">
-                  <Tooltip value="Back to parent session">
-                    <button
-                      type="button"
-                      class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
-                      onClick={() => navigateToSession(parentSession())}
-                    >
-                      <Icon name="arrow-left" size="small" class="text-icon-base" />
-                    </button>
-                  </Tooltip>
-                </div>
-              </div>
-            </Show>
-          </div>
-          <Show when={currentSession() && !parentSession()}>
-            <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
-              <IconButton as={A} href={`/${params.dir}/session`} icon="plus-small" variant="ghost" />
-            </TooltipKeybind>
-          </Show>
-        </div>
-      </div>
-
-      <div class="flex items-center gap-3 absolute right-6 top-1/2 -translate-y-1/2">
-        <div class="hidden md:flex items-center gap-1">
-          <Button
-            size="small"
-            variant="ghost"
-            class="flex gap-2 items-center justify-center"
-            onClick={() => {
-              dialog.show(() => <DialogSelectServer />)
+    <div class={`flex items-center gap-3 absolute right-5 top-1/2 -translate-y-1/2 ${className ?? ""}`} {...props}>
+      <div class="hidden md:flex items-center gap-1">
+        <Button
+          size="small"
+          variant="ghost"
+          class="flex gap-2 items-center justify-center"
+          onClick={() => {
+            dialog.show(() => <DialogSelectServer />)
+          }}
+        >
+          <div
+            classList={{
+              "size-1.5 rounded-full": true,
+              "bg-icon-success-base": server.healthy() === true,
+              "bg-icon-critical-base": server.healthy() === false,
+              "bg-border-weak-base": server.healthy() === undefined,
             }}
-          >
-            <div
-              classList={{
-                "size-1.5 rounded-full": true,
-                "bg-icon-success-base": server.healthy() === true,
-                "bg-icon-critical-base": server.healthy() === false,
-                "bg-border-weak-base": server.healthy() === undefined,
-              }}
-            />
-            <Icon name="server" size="small" class="text-icon-weak" />
-            <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
-          </Button>
-          <SessionLspIndicator />
-          <SessionMcpIndicator />
-        </div>
-        <div class="flex items-center gap-1">
-          <Show when={currentSession()?.summary?.files}>
-            <TooltipKeybind
-              class="hidden md:block shrink-0"
-              title="Toggle review"
-              keybind={command.keybind("review.toggle")}
-            >
-              <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
-                <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                  <Icon
-                    name={layout.review.opened() ? "layout-right" : "layout-left"}
-                    size="small"
-                    class="group-hover/review-toggle:hidden"
-                  />
-                  <Icon
-                    name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
-                    size="small"
-                    class="hidden group-hover/review-toggle:inline-block"
-                  />
-                  <Icon
-                    name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
-                    size="small"
-                    class="hidden group-active/review-toggle:inline-block"
-                  />
-                </div>
-              </Button>
-            </TooltipKeybind>
-          </Show>
+          />
+          <Icon name="server" size="small" class="text-icon-weak" />
+          <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
+        </Button>
+        <SessionLspIndicator />
+        <SessionMcpIndicator />
+      </div>
+      <div class="flex items-center gap-1">
+        <Show when={currentSession()?.summary?.files}>
           <TooltipKeybind
             class="hidden md:block shrink-0"
-            title="Toggle terminal"
-            keybind={command.keybind("terminal.toggle")}
+            title="Toggle review"
+            keybind={command.keybind("review.toggle")}
           >
-            <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+            <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
               <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
                 <Icon
+                  name={layout.review.opened() ? "layout-right" : "layout-left"}
                   size="small"
-                  name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
-                  class="group-hover/terminal-toggle:hidden"
+                  class="group-hover/review-toggle:hidden"
                 />
                 <Icon
+                  name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
                   size="small"
-                  name="layout-bottom-partial"
-                  class="hidden group-hover/terminal-toggle:inline-block"
+                  class="hidden group-hover/review-toggle:inline-block"
                 />
                 <Icon
+                  name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
                   size="small"
-                  name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
-                  class="hidden group-active/terminal-toggle:inline-block"
+                  class="hidden group-active/review-toggle:inline-block"
                 />
               </div>
             </Button>
           </TooltipKeybind>
-        </div>
-        <Show when={shareEnabled() && currentSession()}>
-          <Popover
-            title="Share session"
-            trigger={
-              <Tooltip class="shrink-0" value="Share session">
-                <IconButton icon="share" variant="ghost" class="" />
-              </Tooltip>
-            }
-          >
-            {iife(() => {
-              const [url] = createResource(
-                () => currentSession(),
-                async (session) => {
-                  if (!session) return
-                  let shareURL = session.share?.url
-                  if (!shareURL) {
-                    shareURL = await globalSDK.client.session
-                      .share({ sessionID: session.id, directory: projectDirectory() })
-                      .then((r) => r.data?.share?.url)
-                      .catch((e) => {
-                        console.error("Failed to share session", e)
-                        return undefined
-                      })
-                  }
-                  return shareURL
-                },
-                { initialValue: "" },
-              )
-              return (
-                <Show when={url.latest}>
-                  {(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
-                </Show>
-              )
-            })}
-          </Popover>
         </Show>
+        <TooltipKeybind
+          class="hidden md:block shrink-0"
+          title="Toggle terminal"
+          keybind={command.keybind("terminal.toggle")}
+        >
+          <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+            <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+              <Icon
+                size="small"
+                name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+                class="group-hover/terminal-toggle:hidden"
+              />
+              <Icon size="small" name="layout-bottom-partial" class="hidden group-hover/terminal-toggle:inline-block" />
+              <Icon
+                size="small"
+                name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+                class="hidden group-active/terminal-toggle:inline-block"
+              />
+            </div>
+          </Button>
+        </TooltipKeybind>
       </div>
-    </header>
+      <Show when={shareEnabled() && currentSession()}>
+        <Popover
+          title="Share session"
+          trigger={
+            <Tooltip class="shrink-0" value="Share session">
+              <IconButton icon="share" variant="ghost" class="" />
+            </Tooltip>
+          }
+        >
+          {iife(() => {
+            const [url] = createResource(
+              () => currentSession(),
+              async (session) => {
+                if (!session) return
+                let shareURL = session.share?.url
+                if (!shareURL) {
+                  shareURL = await globalSDK.client.session
+                    .share({ sessionID: session.id, directory: projectDirectory() })
+                    .then((r) => r.data?.share?.url)
+                    .catch((e) => {
+                      console.error("Failed to share session", e)
+                      return undefined
+                    })
+                }
+                return shareURL
+              },
+              { initialValue: "" },
+            )
+            return (
+              <Show when={url.latest}>
+                {(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
+              </Show>
+            )
+          })}
+        </Popover>
+      </Show>
+    </div>
   )
 }

+ 9 - 1
packages/app/src/pages/session.tsx

@@ -40,7 +40,14 @@ import { extractPromptFromParts } from "@/utils/prompt"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { usePermission } from "@/context/permission"
 import { showToast } from "@opencode-ai/ui/toast"
-import { SessionContextTab, SortableTab, FileVisual, SortableTerminalTab, NewSessionView } from "@/components/session"
+import {
+  SessionHeader,
+  SessionContextTab,
+  SortableTab,
+  FileVisual,
+  SortableTerminalTab,
+  NewSessionView,
+} from "@/components/session"
 import { usePlatform } from "@/context/platform"
 import { same } from "@/utils/same"
 
@@ -769,6 +776,7 @@ export default function Page() {
 
   return (
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
+      <SessionHeader />
       <div class="flex-1 min-h-0 flex flex-col md:flex-row">
         {/* Mobile tab bar - only shown on mobile when there are diffs */}
         <Show when={!isDesktop() && diffs().length > 0}>