Przeglądaj źródła

fix(app): move session options to the session page

Adam 2 tygodni temu
rodzic
commit
c277ee8cbf
2 zmienionych plików z 134 dodań i 226 usunięć
  1. 19 151
      packages/app/src/pages/layout.tsx
  2. 115 75
      packages/app/src/pages/session.tsx

+ 19 - 151
packages/app/src/pages/layout.tsx

@@ -1000,69 +1000,6 @@ export default function Layout(props: ParentProps) {
     }
   }
 
-  async function deleteSession(session: Session) {
-    const [store, setStore] = globalSync.child(session.directory)
-    const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
-    const index = sessions.findIndex((s) => s.id === session.id)
-    const nextSession = sessions[index + 1] ?? sessions[index - 1]
-
-    const result = await globalSDK.client.session
-      .delete({ directory: session.directory, sessionID: session.id })
-      .then((x) => x.data)
-      .catch((err) => {
-        showToast({
-          title: language.t("session.delete.failed.title"),
-          description: errorMessage(err),
-        })
-        return false
-      })
-
-    if (!result) return
-
-    setStore(
-      produce((draft) => {
-        const removed = new Set<string>([session.id])
-
-        const byParent = new Map<string, string[]>()
-        for (const item of draft.session) {
-          const parentID = item.parentID
-          if (!parentID) continue
-          const existing = byParent.get(parentID)
-          if (existing) {
-            existing.push(item.id)
-            continue
-          }
-          byParent.set(parentID, [item.id])
-        }
-
-        const stack = [session.id]
-        while (stack.length) {
-          const parentID = stack.pop()
-          if (!parentID) continue
-
-          const children = byParent.get(parentID)
-          if (!children) continue
-
-          for (const child of children) {
-            if (removed.has(child)) continue
-            removed.add(child)
-            stack.push(child)
-          }
-        }
-
-        draft.session = draft.session.filter((s) => !removed.has(s.id))
-      }),
-    )
-
-    if (session.id === params.id) {
-      if (nextSession) {
-        navigate(`/${params.dir}/session/${nextSession.id}`)
-      } else {
-        navigate(`/${params.dir}/session`)
-      }
-    }
-  }
-
   command.register(() => {
     const commands: CommandOption[] = [
       {
@@ -1316,15 +1253,6 @@ export default function Layout(props: ParentProps) {
     globalSync.project.meta(project.worktree, { name })
   }
 
-  async function renameSession(session: Session, next: string) {
-    if (next === session.title) return
-    await globalSDK.client.session.update({
-      directory: session.directory,
-      sessionID: session.id,
-      title: next,
-    })
-  }
-
   const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
     const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
     if (current === next) return
@@ -1475,33 +1403,6 @@ export default function Layout(props: ParentProps) {
     })
   }
 
-  function DialogDeleteSession(props: { session: Session }) {
-    const handleDelete = async () => {
-      await deleteSession(props.session)
-      dialog.close()
-    }
-
-    return (
-      <Dialog title={language.t("session.delete.title")} fit>
-        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
-          <div class="flex flex-col gap-1">
-            <span class="text-14-regular text-text-strong">
-              {language.t("session.delete.confirm", { name: props.session.title })}
-            </span>
-          </div>
-          <div class="flex justify-end gap-2">
-            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
-              {language.t("common.cancel")}
-            </Button>
-            <Button variant="primary" size="large" onClick={handleDelete}>
-              {language.t("session.delete.button")}
-            </Button>
-          </div>
-        </div>
-      </Dialog>
-    )
-  }
-
   function DialogDeleteWorkspace(props: { root: string; directory: string }) {
     const name = createMemo(() => getFilename(props.directory))
     const [data, setData] = createStore({
@@ -1855,10 +1756,6 @@ export default function Layout(props: ParentProps) {
     const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
     const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
     const isActive = createMemo(() => props.session.id === params.id)
-    const [menu, setMenu] = createStore({
-      open: false,
-      pendingRename: false,
-    })
 
     const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
     const cancelHoverPrefetch = () => {
@@ -1885,7 +1782,7 @@ export default function Layout(props: ParentProps) {
     const item = (
       <A
         href={`${props.slug}/session/${props.session.id}`}
-        class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+        class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
         onPointerEnter={scheduleHoverPrefetch}
         onPointerLeave={cancelHoverPrefetch}
         onMouseEnter={scheduleHoverPrefetch}
@@ -1917,14 +1814,9 @@ export default function Layout(props: ParentProps) {
               </Match>
             </Switch>
           </div>
-          <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
-          />
+          <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+            {props.session.title}
+          </span>
           <Show when={props.session.summary}>
             {(summary) => (
               <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -1989,49 +1881,25 @@ export default function Layout(props: ParentProps) {
         <div
           class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
           classList={{
-            "opacity-100 pointer-events-auto": menu.open,
-            "opacity-0 pointer-events-none": !menu.open,
+            "opacity-100 pointer-events-auto": !!props.mobile,
+            "opacity-0 pointer-events-none": !props.mobile,
             "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
             "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
           }}
         >
-          <DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
-            <Tooltip value={language.t("common.moreOptions")} placement="top">
-              <DropdownMenu.Trigger
-                as={IconButton}
-                icon="dot-grid"
-                variant="ghost"
-                class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
-                aria-label={language.t("common.moreOptions")}
-              />
-            </Tooltip>
-            <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
-              <DropdownMenu.Content
-                onCloseAutoFocus={(event) => {
-                  if (!menu.pendingRename) return
-                  event.preventDefault()
-                  setMenu("pendingRename", false)
-                  openEditor(`session:${props.session.id}`, props.session.title)
-                }}
-              >
-                <DropdownMenu.Item
-                  onSelect={() => {
-                    setMenu("pendingRename", true)
-                    setMenu("open", false)
-                  }}
-                >
-                  <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
-                </DropdownMenu.Item>
-                <DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
-                  <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
-                </DropdownMenu.Item>
-                <DropdownMenu.Separator />
-                <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
-                  <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
-                </DropdownMenu.Item>
-              </DropdownMenu.Content>
-            </DropdownMenu.Portal>
-          </DropdownMenu>
+          <Tooltip value={language.t("common.archive")} placement="top">
+            <IconButton
+              icon="archive"
+              variant="ghost"
+              class="size-6 rounded-md"
+              aria-label={language.t("common.archive")}
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                void archiveSession(props.session)
+              }}
+            />
+          </Tooltip>
         </div>
       </div>
     )

+ 115 - 75
packages/app/src/pages/session.tsx

@@ -25,7 +25,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { TextField } from "@opencode-ai/ui/text-field"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -440,6 +440,15 @@ export default function Page() {
     return sync.session.history.loading(id)
   })
 
+  const [title, setTitle] = createStore({
+    draft: "",
+    editing: false,
+    saving: false,
+    menuOpen: false,
+    pendingRename: false,
+  })
+  let titleRef: HTMLInputElement | undefined
+
   const errorMessage = (err: unknown) => {
     if (err && typeof err === "object" && "data" in err) {
       const data = (err as { data?: { message?: string } }).data
@@ -449,6 +458,60 @@ export default function Page() {
     return language.t("common.requestFailed")
   }
 
+  createEffect(
+    on(
+      () => params.id,
+      () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+      { defer: true },
+    ),
+  )
+
+  const openTitleEditor = () => {
+    if (!params.id) return
+    setTitle({ editing: true, draft: info()?.title ?? "" })
+    requestAnimationFrame(() => {
+      titleRef?.focus()
+      titleRef?.select()
+    })
+  }
+
+  const closeTitleEditor = () => {
+    if (title.saving) return
+    setTitle({ editing: false, saving: false })
+  }
+
+  const saveTitleEditor = async () => {
+    const sessionID = params.id
+    if (!sessionID) return
+    if (title.saving) return
+
+    const next = title.draft.trim()
+    if (!next || next === (info()?.title ?? "")) {
+      setTitle({ editing: false, saving: false })
+      return
+    }
+
+    setTitle("saving", true)
+    await sdk.client.session
+      .update({ sessionID, title: next })
+      .then(() => {
+        sync.set(
+          produce((draft) => {
+            const index = draft.session.findIndex((s) => s.id === sessionID)
+            if (index !== -1) draft.session[index].title = next
+          }),
+        )
+        setTitle({ editing: false, saving: false })
+      })
+      .catch((err) => {
+        setTitle("saving", false)
+        showToast({
+          title: language.t("common.requestFailed"),
+          description: errorMessage(err),
+        })
+      })
+  }
+
   async function archiveSession(sessionID: string) {
     const session = sync.session.get(sessionID)
     if (!session) return
@@ -555,74 +618,6 @@ export default function Page() {
     return true
   }
 
-  function DialogRenameSession(props: { sessionID: string }) {
-    const [data, setData] = createStore({
-      title: sync.session.get(props.sessionID)?.title ?? "",
-      saving: false,
-    })
-
-    const submit = (event: Event) => {
-      event.preventDefault()
-      if (data.saving) return
-
-      const title = data.title.trim()
-      if (!title) {
-        dialog.close()
-        return
-      }
-
-      const current = sync.session.get(props.sessionID)?.title ?? ""
-      if (title === current) {
-        dialog.close()
-        return
-      }
-
-      setData("saving", true)
-      void sdk.client.session
-        .update({ sessionID: props.sessionID, title })
-        .then(() => {
-          sync.set(
-            produce((draft) => {
-              const index = draft.session.findIndex((s) => s.id === props.sessionID)
-              if (index !== -1) draft.session[index].title = title
-            }),
-          )
-          dialog.close()
-        })
-        .catch((err) => {
-          showToast({
-            title: language.t("common.requestFailed"),
-            description: errorMessage(err),
-          })
-        })
-        .finally(() => {
-          setData("saving", false)
-        })
-    }
-
-    return (
-      <Dialog title={language.t("common.rename")} fit>
-        <form onSubmit={submit} class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
-          <TextField
-            autofocus
-            type="text"
-            label={language.t("common.rename")}
-            value={data.title}
-            onChange={(value) => setData("title", value)}
-          />
-          <div class="flex justify-end gap-2">
-            <Button type="button" variant="ghost" size="large" disabled={data.saving} onClick={() => dialog.close()}>
-              {language.t("common.cancel")}
-            </Button>
-            <Button type="submit" variant="primary" size="large" disabled={data.saving || !data.title.trim()}>
-              {language.t("common.save")}
-            </Button>
-          </div>
-        </form>
-      </Dialog>
-    )
-  }
-
   function DialogDeleteSession(props: { sessionID: string }) {
     const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
     const handleDelete = async () => {
@@ -2208,7 +2203,7 @@ export default function Page() {
                             }}
                           >
                             <div class="h-10 w-full flex items-center justify-between gap-2">
-                              <div class="flex items-center gap-1 min-w-0">
+                              <div class="flex items-center gap-1 min-w-0 flex-1">
                                 <Show when={info()?.parentID}>
                                   <IconButton
                                     tabIndex={-1}
@@ -2220,14 +2215,50 @@ export default function Page() {
                                     aria-label={language.t("common.goBack")}
                                   />
                                 </Show>
-                                <Show when={info()?.title}>
-                                  <h1 class="text-16-medium text-text-strong truncate min-w-0">{info()?.title}</h1>
+                                <Show when={info()?.title || title.editing}>
+                                  <Show
+                                    when={title.editing}
+                                    fallback={
+                                      <h1
+                                        class="text-16-medium text-text-strong truncate min-w-0"
+                                        onDblClick={openTitleEditor}
+                                      >
+                                        {info()?.title}
+                                      </h1>
+                                    }
+                                  >
+                                    <InlineInput
+                                      ref={(el) => {
+                                        titleRef = el
+                                      }}
+                                      value={title.draft}
+                                      disabled={title.saving}
+                                      class="text-16-medium text-text-strong grow-1 min-w-0"
+                                      onInput={(event) => setTitle("draft", event.currentTarget.value)}
+                                      onKeyDown={(event) => {
+                                        event.stopPropagation()
+                                        if (event.key === "Enter") {
+                                          event.preventDefault()
+                                          void saveTitleEditor()
+                                          return
+                                        }
+                                        if (event.key === "Escape") {
+                                          event.preventDefault()
+                                          closeTitleEditor()
+                                        }
+                                      }}
+                                      onBlur={() => closeTitleEditor()}
+                                    />
+                                  </Show>
                                 </Show>
                               </div>
                               <Show when={params.id}>
                                 {(id) => (
                                   <div class="shrink-0 flex items-center">
-                                    <DropdownMenu>
+                                    <DropdownMenu
+                                      open={title.menuOpen}
+                                      onOpenChange={(open) => setTitle("menuOpen", open)}
+                                    >
                                       <Tooltip value={language.t("common.moreOptions")} placement="top">
                                         <DropdownMenu.Trigger
                                           as={IconButton}
@@ -2238,9 +2269,18 @@ export default function Page() {
                                         />
                                       </Tooltip>
                                       <DropdownMenu.Portal>
-                                        <DropdownMenu.Content>
+                                        <DropdownMenu.Content
+                                          onCloseAutoFocus={(event) => {
+                                            if (!title.pendingRename) return
+                                            event.preventDefault()
+                                            setTitle("pendingRename", false)
+                                            openTitleEditor()
+                                          }}
+                                        >
                                           <DropdownMenu.Item
-                                            onSelect={() => dialog.show(() => <DialogRenameSession sessionID={id()} />)}
+                                            onSelect={() => {
+                                              setTitle({ pendingRename: true, menuOpen: false })
+                                            }}
                                           >
                                             <DropdownMenu.ItemLabel>
                                               {language.t("common.rename")}