Просмотр исходного кода

fix(app): move session share into options menu

Remove the title bar share control and surface sharing from the session options dropdown, opening the share popover in-place after selection to avoid flicker and keep consistent dismissal behavior.
David Hill 1 месяц назад
Родитель
Сommit
5ca2dc87b7

+ 1 - 210
packages/app/src/components/session/session-header.tsx

@@ -4,23 +4,19 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Keybind } from "@opencode-ai/ui/keybind"
-import { Popover } from "@opencode-ai/ui/popover"
 import { Spinner } from "@opencode-ai/ui/spinner"
-import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { getFilename } from "@opencode-ai/util/path"
 import { useParams } from "@solidjs/router"
 import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
 import { useCommand } from "@/context/command"
-import { useGlobalSDK } from "@/context/global-sdk"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useServer } from "@/context/server"
-import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { focusTerminalById } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
@@ -136,99 +132,11 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
   })
 }
 
-function useSessionShare(args: {
-  globalSDK: ReturnType<typeof useGlobalSDK>
-  currentSession: () =>
-    | {
-        share?: {
-          url?: string
-        }
-      }
-    | undefined
-  sessionID: () => string | undefined
-  projectDirectory: () => string
-  platform: ReturnType<typeof usePlatform>
-}) {
-  const [state, setState] = createStore({
-    share: false,
-    unshare: false,
-    copied: false,
-    timer: undefined as number | undefined,
-  })
-  const shareUrl = createMemo(() => args.currentSession()?.share?.url)
-
-  createEffect(() => {
-    const url = shareUrl()
-    if (url) return
-    if (state.timer) window.clearTimeout(state.timer)
-    setState({ copied: false, timer: undefined })
-  })
-
-  onCleanup(() => {
-    if (state.timer) window.clearTimeout(state.timer)
-  })
-
-  const shareSession = () => {
-    const sessionID = args.sessionID()
-    if (!sessionID || state.share) return
-    setState("share", true)
-    args.globalSDK.client.session
-      .share({ sessionID, directory: args.projectDirectory() })
-      .catch((error) => {
-        console.error("Failed to share session", error)
-      })
-      .finally(() => {
-        setState("share", false)
-      })
-  }
-
-  const unshareSession = () => {
-    const sessionID = args.sessionID()
-    if (!sessionID || state.unshare) return
-    setState("unshare", true)
-    args.globalSDK.client.session
-      .unshare({ sessionID, directory: args.projectDirectory() })
-      .catch((error) => {
-        console.error("Failed to unshare session", error)
-      })
-      .finally(() => {
-        setState("unshare", false)
-      })
-  }
-
-  const copyLink = (onError: (error: unknown) => void) => {
-    const url = shareUrl()
-    if (!url) return
-    navigator.clipboard
-      .writeText(url)
-      .then(() => {
-        if (state.timer) window.clearTimeout(state.timer)
-        setState("copied", true)
-        const timer = window.setTimeout(() => {
-          setState("copied", false)
-          setState("timer", undefined)
-        }, 3000)
-        setState("timer", timer)
-      })
-      .catch(onError)
-  }
-
-  const viewShare = () => {
-    const url = shareUrl()
-    if (!url) return
-    args.platform.openLink(url)
-  }
-
-  return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
-}
-
 export function SessionHeader() {
-  const globalSDK = useGlobalSDK()
   const layout = useLayout()
   const params = useParams()
   const command = useCommand()
   const server = useServer()
-  const sync = useSync()
   const platform = usePlatform()
   const language = useLanguage()
   const terminal = useTerminal()
@@ -246,9 +154,6 @@ export function SessionHeader() {
   })
   const hotkey = createMemo(() => command.keybind("file.open"))
 
-  const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
-  const showShare = createMemo(() => shareEnabled() && !!params.id)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
   const os = createMemo(() => detectOS(platform))
@@ -361,14 +266,6 @@ export function SessionHeader() {
       .catch((err: unknown) => showRequestError(language, err))
   }
 
-  const share = useSessionShare({
-    globalSDK,
-    currentSession,
-    sessionID: () => params.id,
-    projectDirectory,
-    platform,
-  })
-
   const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
   const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
 
@@ -523,112 +420,6 @@ export function SessionHeader() {
                   </Show>
                 </div>
               </Show>
-              <Show when={showShare()}>
-                <div class="flex items-center">
-                  <Popover
-                    title={language.t("session.share.popover.title")}
-                    description={
-                      share.shareUrl()
-                        ? language.t("session.share.popover.description.shared")
-                        : language.t("session.share.popover.description.unshared")
-                    }
-                    gutter={4}
-                    placement="bottom-end"
-                    shift={-64}
-                    class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
-                    triggerAs={Button}
-                    triggerProps={{
-                      variant: "ghost",
-                      class:
-                        "rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
-                      classList: {
-                        "rounded-r-none": share.shareUrl() !== undefined,
-                        "border-r-0": share.shareUrl() !== undefined,
-                      },
-                      style: { scale: 1 },
-                    }}
-                    trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
-                  >
-                    <div class="flex flex-col gap-2">
-                      <Show
-                        when={share.shareUrl()}
-                        fallback={
-                          <div class="flex">
-                            <Button
-                              size="large"
-                              variant="primary"
-                              class="w-1/2"
-                              onClick={share.shareSession}
-                              disabled={share.state.share}
-                            >
-                              {share.state.share
-                                ? language.t("session.share.action.publishing")
-                                : language.t("session.share.action.publish")}
-                            </Button>
-                          </div>
-                        }
-                      >
-                        <div class="flex flex-col gap-2">
-                          <TextField
-                            value={share.shareUrl() ?? ""}
-                            readOnly
-                            copyable
-                            copyKind="link"
-                            tabIndex={-1}
-                            class="w-full"
-                          />
-                          <div class="grid grid-cols-2 gap-2">
-                            <Button
-                              size="large"
-                              variant="secondary"
-                              class="w-full shadow-none border border-border-weak-base"
-                              onClick={share.unshareSession}
-                              disabled={share.state.unshare}
-                            >
-                              {share.state.unshare
-                                ? language.t("session.share.action.unpublishing")
-                                : language.t("session.share.action.unpublish")}
-                            </Button>
-                            <Button
-                              size="large"
-                              variant="primary"
-                              class="w-full"
-                              onClick={share.viewShare}
-                              disabled={share.state.unshare}
-                            >
-                              {language.t("session.share.action.view")}
-                            </Button>
-                          </div>
-                        </div>
-                      </Show>
-                    </div>
-                  </Popover>
-                  <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
-                    <Tooltip
-                      value={
-                        share.state.copied
-                          ? language.t("session.share.copy.copied")
-                          : language.t("session.share.copy.copyLink")
-                      }
-                      placement="top"
-                      gutter={8}
-                    >
-                      <IconButton
-                        icon={share.state.copied ? "check" : "link"}
-                        variant="ghost"
-                        class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
-                        onClick={() => share.copyLink((error) => showRequestError(language, error))}
-                        disabled={share.state.unshare}
-                        aria-label={
-                          share.state.copied
-                            ? language.t("session.share.copy.copied")
-                            : language.t("session.share.copy.copyLink")
-                        }
-                      />
-                    </Tooltip>
-                  </Show>
-                </div>
-              </Show>
               <div class="flex items-center gap-1">
                 <TooltipKeybind
                   title={language.t("command.terminal.toggle")}

+ 190 - 6
packages/app/src/pages/session/message-timeline.tsx

@@ -11,14 +11,18 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { TextField } from "@opencode-ai/ui/text-field"
 import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
 import { showToast } from "@opencode-ai/ui/toast"
 import { Binary } from "@opencode-ai/util/binary"
 import { getFilename } from "@opencode-ai/util/path"
+import { Popover as KobaltePopover } from "@kobalte/core/popover"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useLanguage } from "@/context/language"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
@@ -209,11 +213,13 @@ export function MessageTimeline(props: {
 
   const params = useParams()
   const navigate = useNavigate()
+  const globalSDK = useGlobalSDK()
   const sdk = useSDK()
   const sync = useSync()
   const settings = useSettings()
   const dialog = useDialog()
   const language = useLanguage()
+  const platform = usePlatform()
 
   const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -292,6 +298,8 @@ export function MessageTimeline(props: {
     return sync.session.get(id)
   })
   const titleValue = createMemo(() => info()?.title)
+  const shareUrl = createMemo(() => info()?.share?.url)
+  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
   const parentID = createMemo(() => info()?.parentID)
   const showHeader = createMemo(() => !!(titleValue() || parentID()))
   const stageCfg = { init: 1, batch: 3 }
@@ -308,9 +316,55 @@ export function MessageTimeline(props: {
     saving: false,
     menuOpen: false,
     pendingRename: false,
+    pendingShare: false,
   })
   let titleRef: HTMLInputElement | undefined
 
+  const [share, setShare] = createStore({
+    open: false,
+    dismiss: null as "escape" | "outside" | null,
+  })
+
+  let more: HTMLButtonElement | undefined
+
+  const [req, setReq] = createStore({ share: false, unshare: false })
+
+  const shareSession = () => {
+    const id = sessionID()
+    if (!id || req.share) return
+    if (!shareEnabled()) return
+    setReq("share", true)
+    globalSDK.client.session
+      .share({ sessionID: id, directory: sdk.directory })
+      .catch((error) => {
+        console.error("Failed to share session", error)
+      })
+      .finally(() => {
+        setReq("share", false)
+      })
+  }
+
+  const unshareSession = () => {
+    const id = sessionID()
+    if (!id || req.unshare) return
+    if (!shareEnabled()) return
+    setReq("unshare", true)
+    globalSDK.client.session
+      .unshare({ sessionID: id, directory: sdk.directory })
+      .catch((error) => {
+        console.error("Failed to unshare session", error)
+      })
+      .finally(() => {
+        setReq("unshare", false)
+      })
+  }
+
+  const viewShare = () => {
+    const url = shareUrl()
+    if (!url) return
+    platform.openLink(url)
+  }
+
   const errorMessage = (err: unknown) => {
     if (err && typeof err === "object" && "data" in err) {
       const data = (err as { data?: { message?: string } }).data
@@ -323,7 +377,15 @@ export function MessageTimeline(props: {
   createEffect(
     on(
       sessionKey,
-      () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+      () =>
+        setTitle({
+          draft: "",
+          editing: false,
+          saving: false,
+          menuOpen: false,
+          pendingRename: false,
+          pendingShare: false,
+        }),
       { defer: true },
     ),
   )
@@ -672,7 +734,10 @@ export function MessageTimeline(props: {
                           gutter={4}
                           placement="bottom-end"
                           open={title.menuOpen}
-                          onOpenChange={(open) => setTitle("menuOpen", open)}
+                          onOpenChange={(open) => {
+                            setTitle("menuOpen", open)
+                            if (open) return
+                          }}
                         >
                           <DropdownMenu.Trigger
                             as={IconButton}
@@ -680,15 +745,25 @@ export function MessageTimeline(props: {
                             variant="ghost"
                             class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
                             aria-label={language.t("common.moreOptions")}
+                            ref={(el: HTMLButtonElement) => {
+                              more = el
+                            }}
                           />
                           <DropdownMenu.Portal>
                             <DropdownMenu.Content
                               style={{ "min-width": "104px" }}
                               onCloseAutoFocus={(event) => {
-                                if (!title.pendingRename) return
-                                event.preventDefault()
-                                setTitle("pendingRename", false)
-                                openTitleEditor()
+                                if (title.pendingRename) {
+                                  event.preventDefault()
+                                  setTitle("pendingRename", false)
+                                  openTitleEditor()
+                                  return
+                                }
+                                if (title.pendingShare) {
+                                  event.preventDefault()
+                                  setTitle("pendingShare", false)
+                                  requestAnimationFrame(() => setShare({ open: true, dismiss: null }))
+                                }
                               }}
                             >
                               <DropdownMenu.Item
@@ -699,6 +774,17 @@ export function MessageTimeline(props: {
                               >
                                 <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
+                              <Show when={shareEnabled()}>
+                                <DropdownMenu.Item
+                                  onSelect={() => {
+                                    setTitle({ pendingShare: true, menuOpen: false })
+                                  }}
+                                >
+                                  <DropdownMenu.ItemLabel>
+                                    {language.t("session.share.action.share")}
+                                  </DropdownMenu.ItemLabel>
+                                </DropdownMenu.Item>
+                              </Show>
                               <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
                                 <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
@@ -711,6 +797,104 @@ export function MessageTimeline(props: {
                             </DropdownMenu.Content>
                           </DropdownMenu.Portal>
                         </DropdownMenu>
+
+                        <KobaltePopover
+                          open={share.open}
+                          anchorRef={() => more}
+                          placement="bottom-end"
+                          gutter={4}
+                          modal={false}
+                          onOpenChange={(open) => {
+                            if (open) setShare("dismiss", null)
+                            setShare("open", open)
+                          }}
+                        >
+                          <KobaltePopover.Portal>
+                            <KobaltePopover.Content
+                              data-component="popover-content"
+                              style={{ "min-width": "320px" }}
+                              onEscapeKeyDown={(event) => {
+                                setShare({ dismiss: "escape", open: false })
+                                event.preventDefault()
+                                event.stopPropagation()
+                              }}
+                              onPointerDownOutside={() => {
+                                setShare({ dismiss: "outside", open: false })
+                              }}
+                              onFocusOutside={() => {
+                                setShare({ dismiss: "outside", open: false })
+                              }}
+                              onCloseAutoFocus={(event) => {
+                                if (share.dismiss === "outside") event.preventDefault()
+                                setShare("dismiss", null)
+                              }}
+                            >
+                              <div class="flex flex-col p-3">
+                                <div class="flex flex-col gap-1">
+                                  <div class="text-13-medium text-text-strong">
+                                    {language.t("session.share.popover.title")}
+                                  </div>
+                                  <div class="text-12-regular text-text-weak">
+                                    {shareUrl()
+                                      ? language.t("session.share.popover.description.shared")
+                                      : language.t("session.share.popover.description.unshared")}
+                                  </div>
+                                </div>
+                                <div class="mt-3 flex flex-col gap-2">
+                                  <Show
+                                    when={shareUrl()}
+                                    fallback={
+                                      <Button
+                                        size="large"
+                                        variant="primary"
+                                        class="w-full"
+                                        onClick={shareSession}
+                                        disabled={req.share}
+                                      >
+                                        {req.share
+                                          ? language.t("session.share.action.publishing")
+                                          : language.t("session.share.action.publish")}
+                                      </Button>
+                                    }
+                                  >
+                                    <div class="flex flex-col gap-2">
+                                      <TextField
+                                        value={shareUrl() ?? ""}
+                                        readOnly
+                                        copyable
+                                        copyKind="link"
+                                        tabIndex={-1}
+                                        class="w-full"
+                                      />
+                                      <div class="grid grid-cols-2 gap-2">
+                                        <Button
+                                          size="large"
+                                          variant="secondary"
+                                          class="w-full shadow-none border border-border-weak-base"
+                                          onClick={unshareSession}
+                                          disabled={req.unshare}
+                                        >
+                                          {req.unshare
+                                            ? language.t("session.share.action.unpublishing")
+                                            : language.t("session.share.action.unpublish")}
+                                        </Button>
+                                        <Button
+                                          size="large"
+                                          variant="primary"
+                                          class="w-full"
+                                          onClick={viewShare}
+                                          disabled={req.unshare}
+                                        >
+                                          {language.t("session.share.action.view")}
+                                        </Button>
+                                      </div>
+                                    </div>
+                                  </Show>
+                                </div>
+                              </div>
+                            </KobaltePopover.Content>
+                          </KobaltePopover.Portal>
+                        </KobaltePopover>
                       </div>
                     )}
                   </Show>