Przeglądaj źródła

wip(app): sidebar hover full

adamelmore 3 tygodni temu
rodzic
commit
af3d8c383e
1 zmienionych plików z 414 dodań i 354 usunięć
  1. 414 354
      packages/app/src/pages/layout.tsx

+ 414 - 354
packages/app/src/pages/layout.tsx

@@ -135,6 +135,21 @@ export default function Layout(props: ParentProps) {
   const editorRef = { current: undefined as HTMLInputElement | undefined }
 
   const [hoverSession, setHoverSession] = createSignal<string | undefined>()
+  const [hoverProject, setHoverProject] = createSignal<string | undefined>()
+
+  const sidebarHovering = createMemo(() => !layout.sidebar.opened() && hoverProject() !== undefined)
+  const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
+
+  const hoverProjectData = createMemo(() => {
+    const id = hoverProject()
+    if (!id) return
+    return layout.projects.list().find((project) => project.worktree === id)
+  })
+
+  createEffect(() => {
+    if (!layout.sidebar.opened()) return
+    setHoverProject(undefined)
+  })
 
   const autoselecting = createMemo(() => {
     if (params.dir) return false
@@ -1119,15 +1134,13 @@ export default function Layout(props: ParentProps) {
     return language.t("common.requestFailed")
   }
 
-  const deleteWorkspace = async (directory: string) => {
-    const current = currentProject()
-    if (!current) return
-    if (directory === current.worktree) return
+  const deleteWorkspace = async (root: string, directory: string) => {
+    if (directory === root) return
 
     setBusy(directory, true)
 
     const result = await globalSDK.client.worktree
-      .remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
+      .remove({ directory: root, worktreeRemoveInput: { directory } })
       .then((x) => x.data)
       .catch((err) => {
         showToast({
@@ -1142,17 +1155,15 @@ export default function Layout(props: ParentProps) {
     if (!result) return
 
     layout.projects.close(directory)
-    layout.projects.open(current.worktree)
+    layout.projects.open(root)
 
     if (params.dir && base64Decode(params.dir) === directory) {
-      navigateToProject(current.worktree)
+      navigateToProject(root)
     }
   }
 
-  const resetWorkspace = async (directory: string) => {
-    const current = currentProject()
-    if (!current) return
-    if (directory === current.worktree) return
+  const resetWorkspace = async (root: string, directory: string) => {
+    if (directory === root) return
     setBusy(directory, true)
 
     const progress = showToast({
@@ -1168,7 +1179,7 @@ export default function Layout(props: ParentProps) {
       .catch(() => [])
 
     const result = await globalSDK.client.worktree
-      .reset({ directory: current.worktree, worktreeResetInput: { directory } })
+      .reset({ directory: root, worktreeResetInput: { directory } })
       .then((x) => x.data)
       .catch((err) => {
         showToast({
@@ -1251,7 +1262,7 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  function DialogDeleteWorkspace(props: { directory: string }) {
+  function DialogDeleteWorkspace(props: { root: string; directory: string }) {
     const name = createMemo(() => getFilename(props.directory))
     const [data, setData] = createStore({
       status: "loading" as "loading" | "ready" | "error",
@@ -1259,12 +1270,6 @@ export default function Layout(props: ParentProps) {
     })
 
     onMount(() => {
-      const current = currentProject()
-      if (!current) {
-        setData({ status: "error", dirty: false })
-        return
-      }
-
       globalSDK.client.file
         .status({ directory: props.directory })
         .then((x) => {
@@ -1279,7 +1284,7 @@ export default function Layout(props: ParentProps) {
 
     const handleDelete = () => {
       dialog.close()
-      void deleteWorkspace(props.directory)
+      void deleteWorkspace(props.root, props.directory)
     }
 
     const description = () => {
@@ -1311,7 +1316,7 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  function DialogResetWorkspace(props: { directory: string }) {
+  function DialogResetWorkspace(props: { root: string; directory: string }) {
     const name = createMemo(() => getFilename(props.directory))
     const [state, setState] = createStore({
       status: "loading" as "loading" | "ready" | "error",
@@ -1329,12 +1334,6 @@ export default function Layout(props: ParentProps) {
     }
 
     onMount(() => {
-      const current = currentProject()
-      if (!current) {
-        setState({ status: "error", dirty: false })
-        return
-      }
-
       globalSDK.client.file
         .status({ directory: props.directory })
         .then((x) => {
@@ -1350,7 +1349,7 @@ export default function Layout(props: ParentProps) {
 
     const handleReset = () => {
       dialog.close()
-      void resetWorkspace(props.directory)
+      void resetWorkspace(props.root, props.directory)
     }
 
     const archivedCount = () => state.sessions.length
@@ -1444,6 +1443,7 @@ export default function Layout(props: ParentProps) {
   function handleDragStart(event: unknown) {
     const id = getDraggableId(event)
     if (!id) return
+    setHoverProject(undefined)
     setStore("activeProject", id)
   }
 
@@ -1483,6 +1483,13 @@ export default function Layout(props: ParentProps) {
     return [...merged, extra]
   }
 
+  const sidebarProject = createMemo(() => {
+    if (layout.sidebar.opened()) return currentProject()
+    const hovered = hoverProjectData()
+    if (hovered) return hovered
+    return currentProject()
+  })
+
   function handleWorkspaceDragStart(event: unknown) {
     const id = getDraggableId(event)
     if (!id) return
@@ -1493,7 +1500,7 @@ export default function Layout(props: ParentProps) {
     const { draggable, droppable } = event
     if (!draggable || !droppable) return
 
-    const project = currentProject()
+    const project = sidebarProject()
     if (!project) return
 
     const ids = workspaceIds(project)
@@ -1593,7 +1600,7 @@ export default function Layout(props: ParentProps) {
       sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
     )
     const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
-    const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
+    const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
     const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
     const isActive = createMemo(() => props.session.id === params.id)
     const [menuOpen, setMenuOpen] = createSignal(false)
@@ -1611,7 +1618,11 @@ export default function Layout(props: ParentProps) {
         class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menuOpen() ? "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"}`}
         onMouseEnter={() => prefetchSession(props.session, "high")}
         onFocus={() => prefetchSession(props.session, "high")}
-        onClick={() => setHoverSession(undefined)}
+        onClick={() => {
+          setHoverSession(undefined)
+          if (layout.sidebar.opened()) return
+          queueMicrotask(() => setHoverProject(undefined))
+        }}
       >
         <div class="flex items-center gap-1 w-full">
           <div
@@ -1753,13 +1764,17 @@ export default function Layout(props: ParentProps) {
 
   const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
     const label = language.t("command.session.new")
-    const tooltip = () => props.mobile || !layout.sidebar.opened()
+    const tooltip = () => props.mobile || !sidebarExpanded()
     const item = (
       <A
         href={`${props.slug}/session`}
         end
         class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
-        onClick={() => setHoverSession(undefined)}
+        onClick={() => {
+          setHoverSession(undefined)
+          if (layout.sidebar.opened()) return
+          queueMicrotask(() => setHoverProject(undefined))
+        }}
       >
         <div class="flex items-center gap-1 w-full">
           <div class="shrink-0 size-6 flex items-center justify-center">
@@ -1814,7 +1829,7 @@ export default function Layout(props: ParentProps) {
 
   const WorkspaceDragOverlay = (): JSX.Element => {
     const label = createMemo(() => {
-      const project = currentProject()
+      const project = sidebarProject()
       if (!project) return
       const directory = store.activeWorkspace
       if (!directory) return
@@ -1985,13 +2000,21 @@ export default function Layout(props: ParentProps) {
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                           disabled={local() || busy()}
-                          onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
+                          onSelect={() =>
+                            dialog.show(() => (
+                              <DialogResetWorkspace root={props.project.worktree} directory={props.directory} />
+                            ))
+                          }
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                           disabled={local() || busy()}
-                          onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
+                          onSelect={() =>
+                            dialog.show(() => (
+                              <DialogDeleteWorkspace root={props.project.worktree} directory={props.directory} />
+                            ))
+                          }
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
@@ -2005,9 +2028,7 @@ export default function Layout(props: ParentProps) {
 
           <Collapsible.Content>
             <nav class="flex flex-col gap-1 px-2">
-              <Show when={workspaceSetting()}>
-                <NewSessionItem slug={slug()} mobile={props.mobile} />
-              </Show>
+              <NewSessionItem slug={slug()} mobile={props.mobile} />
               <Show when={loading()}>
                 <SessionSkeleton />
               </Show>
@@ -2049,6 +2070,16 @@ export default function Layout(props: ParentProps) {
     )
     const [open, setOpen] = createSignal(false)
 
+    const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
+    const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
+    const active = createMemo(() => (preview() ? open() : overlay() && hoverProject() === props.project.worktree))
+
+    createEffect(() => {
+      if (preview()) return
+      if (!open()) return
+      setOpen(false)
+    })
+
     const label = (directory: string) => {
       const [data] = globalSync.child(directory, { bootstrap: false })
       const kind =
@@ -2087,8 +2118,20 @@ export default function Layout(props: ParentProps) {
           "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
           "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
           "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
-            !selected() && !open(),
-          "bg-surface-base-hover border border-border-weak-base": !selected() && open(),
+            !selected() && !active(),
+          "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+        }}
+        onMouseEnter={() => {
+          if (!overlay()) return
+          globalSync.child(props.project.worktree)
+          setHoverProject(props.project.worktree)
+          setHoverSession(undefined)
+        }}
+        onFocus={() => {
+          if (!overlay()) return
+          globalSync.child(props.project.worktree)
+          setHoverProject(props.project.worktree)
+          setHoverSession(undefined)
         }}
         onClick={() => navigateToProject(props.project.worktree)}
         onBlur={() => setOpen(false)}
@@ -2100,96 +2143,98 @@ export default function Layout(props: ParentProps) {
     return (
       // @ts-ignore
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
-        <HoverCard
-          open={open()}
-          openDelay={0}
-          closeDelay={0}
-          placement="right-start"
-          gutter={6}
-          trigger={trigger}
-          onOpenChange={(value) => {
-            setOpen(value)
-            if (value) setHoverSession(undefined)
-          }}
-        >
-          <div class="-m-3 p-2 flex flex-col w-72">
-            <div class="px-4 pt-2 pb-1 flex items-center gap-2">
-              <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
-              <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
-                <IconButton
-                  icon="circle-x"
+        <Show when={preview()} fallback={trigger}>
+          <HoverCard
+            open={open()}
+            openDelay={0}
+            closeDelay={0}
+            placement="right-start"
+            gutter={6}
+            trigger={trigger}
+            onOpenChange={(value) => {
+              setOpen(value)
+              if (value) setHoverSession(undefined)
+            }}
+          >
+            <div class="-m-3 p-2 flex flex-col w-72">
+              <div class="px-4 pt-2 pb-1 flex items-center gap-2">
+                <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
+                <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
+                  <IconButton
+                    icon="circle-x"
+                    variant="ghost"
+                    class="shrink-0"
+                    aria-label={language.t("common.close")}
+                    onClick={(event) => {
+                      event.stopPropagation()
+                      setOpen(false)
+                      closeProject(props.project.worktree)
+                    }}
+                  />
+                </Tooltip>
+              </div>
+              <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
+              <div class="px-2 pb-2 flex flex-col gap-2">
+                <Show
+                  when={workspaceEnabled()}
+                  fallback={
+                    <For each={projectSessions()}>
+                      {(session) => (
+                        <SessionItem
+                          session={session}
+                          slug={base64Encode(props.project.worktree)}
+                          dense
+                          mobile={props.mobile}
+                          popover={false}
+                        />
+                      )}
+                    </For>
+                  }
+                >
+                  <For each={workspaces()}>
+                    {(directory) => (
+                      <div class="flex flex-col gap-1">
+                        <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+                          <div class="shrink-0 size-6 flex items-center justify-center">
+                            <Icon name="branch" size="small" class="text-icon-base" />
+                          </div>
+                          <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
+                        </div>
+                        <For each={sessions(directory)}>
+                          {(session) => (
+                            <SessionItem
+                              session={session}
+                              slug={base64Encode(directory)}
+                              dense
+                              mobile={props.mobile}
+                              popover={false}
+                            />
+                          )}
+                        </For>
+                      </div>
+                    )}
+                  </For>
+                </Show>
+              </div>
+              <div class="px-2 py-2 border-t border-border-weak-base">
+                <Button
                   variant="ghost"
-                  class="shrink-0"
-                  aria-label={language.t("common.close")}
-                  onClick={(event) => {
-                    event.stopPropagation()
+                  class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
+                  onClick={() => {
+                    layout.sidebar.open()
                     setOpen(false)
-                    closeProject(props.project.worktree)
+                    if (selected()) {
+                      return
+                    }
+                    navigateToProject(props.project.worktree)
                   }}
-                />
-              </Tooltip>
-            </div>
-            <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
-            <div class="px-2 pb-2 flex flex-col gap-2">
-              <Show
-                when={workspaceEnabled()}
-                fallback={
-                  <For each={projectSessions()}>
-                    {(session) => (
-                      <SessionItem
-                        session={session}
-                        slug={base64Encode(props.project.worktree)}
-                        dense
-                        mobile={props.mobile}
-                        popover={false}
-                      />
-                    )}
-                  </For>
-                }
-              >
-                <For each={workspaces()}>
-                  {(directory) => (
-                    <div class="flex flex-col gap-1">
-                      <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
-                        <div class="shrink-0 size-6 flex items-center justify-center">
-                          <Icon name="branch" size="small" class="text-icon-base" />
-                        </div>
-                        <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
-                      </div>
-                      <For each={sessions(directory)}>
-                        {(session) => (
-                          <SessionItem
-                            session={session}
-                            slug={base64Encode(directory)}
-                            dense
-                            mobile={props.mobile}
-                            popover={false}
-                          />
-                        )}
-                      </For>
-                    </div>
-                  )}
-                </For>
-              </Show>
-            </div>
-            <div class="px-2 py-2 border-t border-border-weak-base">
-              <Button
-                variant="ghost"
-                class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
-                onClick={() => {
-                  layout.sidebar.open()
-                  setOpen(false)
-                  if (selected()) {
-                    return
-                  }
-                  navigateToProject(props.project.worktree)
-                }}
-              >
-                {language.t("sidebar.project.viewAllSessions")}
-              </Button>
+                >
+                  {language.t("sidebar.project.viewAllSessions")}
+                </Button>
+              </div>
             </div>
-          </div>
-        </HoverCard>
+          </HoverCard>
+        </Show>
       </div>
     )
   }
@@ -2219,9 +2264,6 @@ export default function Layout(props: ParentProps) {
         style={{ "overflow-anchor": "none" }}
       >
         <nav class="flex flex-col gap-1 px-2">
-          <Show when={workspaceSetting()}>
-            <NewSessionItem slug={slug()} mobile={props.mobile} />
-          </Show>
           <Show when={loading()}>
             <SessionSkeleton />
           </Show>
@@ -2248,60 +2290,241 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
-    const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
+  const createWorkspace = async (project: LocalProject) => {
+    const created = await globalSDK.client.worktree
+      .create({ directory: project.worktree })
+      .then((x) => x.data)
+      .catch((err) => {
+        showToast({
+          title: language.t("workspace.create.failed.title"),
+          description: errorMessage(err),
+        })
+        return undefined
+      })
 
-    const sync = useGlobalSync()
-    const project = createMemo(() => currentProject())
+    if (!created?.directory) return
+
+    const local = project.worktree
+    const key = workspaceKey(created.directory)
+    const root = workspaceKey(local)
+
+    setBusy(created.directory, true)
+    WorktreeState.pending(created.directory)
+    setStore("workspaceExpanded", key, true)
+    if (key !== created.directory) {
+      setStore("workspaceExpanded", created.directory, true)
+    }
+    setStore("workspaceOrder", project.worktree, (prev) => {
+      const existing = prev ?? []
+      const next = existing.filter((item) => {
+        const id = workspaceKey(item)
+        if (id === root) return false
+        return id !== key
+      })
+      return [local, created.directory, ...next]
+    })
+
+    globalSync.child(created.directory)
+    navigate(`/${base64Encode(created.directory)}/session`)
+    layout.mobileSidebar.hide()
+  }
+
+  const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
     const projectName = createMemo(() => {
-      const current = project()
-      if (!current) return ""
-      return current.name || getFilename(current.worktree)
+      const project = panelProps.project
+      if (!project) return ""
+      return project.name || getFilename(project.worktree)
     })
-    const projectId = createMemo(() => project()?.id ?? "")
-    const workspaces = createMemo(() => workspaceIds(project()))
-
-    const createWorkspace = async () => {
-      const current = project()
-      if (!current) return
-
-      const created = await globalSDK.client.worktree
-        .create({ directory: current.worktree })
-        .then((x) => x.data)
-        .catch((err) => {
-          showToast({
-            title: language.t("workspace.create.failed.title"),
-            description: errorMessage(err),
-          })
-          return undefined
-        })
+    const projectId = createMemo(() => panelProps.project?.id ?? "")
+    const workspaces = createMemo(() => workspaceIds(panelProps.project))
+    const workspacesEnabled = createMemo(() => {
+      const project = panelProps.project
+      if (!project) return false
+      if (project.vcs !== "git") return false
+      return layout.sidebar.workspaces(project.worktree)()
+    })
+    const homedir = createMemo(() => globalSync.data.path.home)
 
-      if (!created?.directory) return
+    return (
+      <div
+        classList={{
+          "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
+          "flex-1 min-w-0": panelProps.mobile,
+        }}
+        style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
+      >
+        <Show when={panelProps.project} keyed>
+          {(p) => (
+            <>
+              <div class="shrink-0 px-2 py-1">
+                <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
+                  <div class="flex flex-col min-w-0">
+                    <InlineEditor
+                      id={`project:${projectId()}`}
+                      value={projectName}
+                      onSave={(next) => renameProject(p, next)}
+                      class="text-16-medium text-text-strong truncate"
+                      displayClass="text-16-medium text-text-strong truncate"
+                      stopPropagation
+                    />
+
+                    <Tooltip
+                      placement="bottom"
+                      gutter={2}
+                      value={p.worktree}
+                      class="shrink-0"
+                      contentStyle={{
+                        "max-width": "640px",
+                        transform: "translate3d(52px, 0, 0)",
+                      }}
+                    >
+                      <span class="text-12-regular text-text-base truncate select-text">
+                        {p.worktree.replace(homedir(), "~")}
+                      </span>
+                    </Tooltip>
+                  </div>
 
-      const local = current.worktree
-      const key = workspaceKey(created.directory)
-      const root = workspaceKey(local)
+                  <DropdownMenu>
+                    <DropdownMenu.Trigger
+                      as={IconButton}
+                      icon="dot-grid"
+                      variant="ghost"
+                      class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
+                      aria-label={language.t("common.moreOptions")}
+                    />
+                    <DropdownMenu.Portal>
+                      <DropdownMenu.Content class="mt-1">
+                        <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
+                          <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Item
+                          disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
+                          onSelect={() => {
+                            const enabled = layout.sidebar.workspaces(p.worktree)()
+                            if (enabled) {
+                              layout.sidebar.toggleWorkspaces(p.worktree)
+                              return
+                            }
+                            if (p.vcs !== "git") return
+                            layout.sidebar.toggleWorkspaces(p.worktree)
+                          }}
+                        >
+                          <DropdownMenu.ItemLabel>
+                            {layout.sidebar.workspaces(p.worktree)()
+                              ? language.t("sidebar.workspaces.disable")
+                              : language.t("sidebar.workspaces.enable")}
+                          </DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Separator />
+                        <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
+                          <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                      </DropdownMenu.Content>
+                    </DropdownMenu.Portal>
+                  </DropdownMenu>
+                </div>
+              </div>
 
-      setBusy(created.directory, true)
-      WorktreeState.pending(created.directory)
-      setStore("workspaceExpanded", key, true)
-      if (key !== created.directory) {
-        setStore("workspaceExpanded", created.directory, true)
-      }
-      setStore("workspaceOrder", current.worktree, (prev) => {
-        const existing = prev ?? []
-        const next = existing.filter((item) => {
-          const id = workspaceKey(item)
-          if (id === root) return false
-          return id !== key
-        })
-        return [local, created.directory, ...next]
-      })
+              <Show
+                when={workspacesEnabled()}
+                fallback={
+                  <>
+                    <div class="py-4 px-3">
+                      <TooltipKeybind
+                        title={language.t("command.session.new")}
+                        keybind={command.keybind("session.new")}
+                        placement="top"
+                      >
+                        <Button
+                          size="large"
+                          icon="plus-small"
+                          class="w-full"
+                          onClick={() => {
+                            navigate(`/${base64Encode(p.worktree)}/session`)
+                            layout.mobileSidebar.hide()
+                          }}
+                        >
+                          {language.t("command.session.new")}
+                        </Button>
+                      </TooltipKeybind>
+                    </div>
+                    <div class="flex-1 min-h-0">
+                      <LocalWorkspace project={p} mobile={panelProps.mobile} />
+                    </div>
+                  </>
+                }
+              >
+                <>
+                  <div class="py-4 px-3">
+                    <TooltipKeybind
+                      title={language.t("workspace.new")}
+                      keybind={command.keybind("workspace.new")}
+                      placement="top"
+                    >
+                      <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
+                        {language.t("workspace.new")}
+                      </Button>
+                    </TooltipKeybind>
+                  </div>
+                  <div class="relative flex-1 min-h-0">
+                    <DragDropProvider
+                      onDragStart={handleWorkspaceDragStart}
+                      onDragEnd={handleWorkspaceDragEnd}
+                      onDragOver={handleWorkspaceDragOver}
+                      collisionDetector={closestCenter}
+                    >
+                      <DragDropSensors />
+                      <ConstrainDragXAxis />
+                      <div
+                        ref={(el) => {
+                          if (!panelProps.mobile) scrollContainerRef = el
+                        }}
+                        class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar"
+                        style={{ "overflow-anchor": "none" }}
+                      >
+                        <SortableProvider ids={workspaces()}>
+                          <For each={workspaces()}>
+                            {(directory) => (
+                              <SortableWorkspace directory={directory} project={p} mobile={panelProps.mobile} />
+                            )}
+                          </For>
+                        </SortableProvider>
+                      </div>
+                      <DragOverlay>
+                        <WorkspaceDragOverlay />
+                      </DragOverlay>
+                    </DragDropProvider>
+                  </div>
+                </>
+              </Show>
+            </>
+          )}
+        </Show>
+        <Show when={providers.all().length > 0 && providers.paid().length === 0}>
+          <div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
+            <div class="rounded-md bg-background-base shadow-xs-border-base">
+              <div class="p-3 flex flex-col gap-2">
+                <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
+                <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
+                <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
+              </div>
+              <Button
+                class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
+                size="large"
+                icon="plus"
+                onClick={connectProvider}
+              >
+                {language.t("command.provider.connect")}
+              </Button>
+            </div>
+          </div>
+        </Show>
+      </div>
+    )
+  }
 
-      globalSync.child(created.directory)
-      navigate(`/${base64Encode(created.directory)}/session`)
-      layout.mobileSidebar.hide()
-    }
+  const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
+    const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
 
     command.register(() => [
       {
@@ -2310,12 +2533,14 @@ export default function Layout(props: ParentProps) {
         category: language.t("command.category.workspace"),
         keybind: "mod+shift+w",
         disabled: !workspaceSetting(),
-        onSelect: createWorkspace,
+        onSelect: () => {
+          const project = currentProject()
+          if (!project) return
+          return createWorkspace(project)
+        },
       },
     ])
 
-    const homedir = createMemo(() => sync.data.path.home)
-
     return (
       <div class="flex h-full w-full overflow-hidden">
         <div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
@@ -2386,180 +2611,7 @@ export default function Layout(props: ParentProps) {
         </div>
 
         <Show when={expanded()}>
-          <div
-            classList={{
-              "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
-              "flex-1 min-w-0": sidebarProps.mobile,
-            }}
-            style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
-          >
-            <Show when={project()} keyed>
-              {(p) => (
-                <>
-                  <div class="shrink-0 px-2 py-1">
-                    <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
-                      <div class="flex flex-col min-w-0">
-                        <InlineEditor
-                          id={`project:${projectId()}`}
-                          value={projectName}
-                          onSave={(next) => project() && renameProject(project()!, next)}
-                          class="text-16-medium text-text-strong truncate"
-                          displayClass="text-16-medium text-text-strong truncate"
-                          stopPropagation
-                        />
-
-                        <Tooltip
-                          placement="bottom"
-                          gutter={2}
-                          value={project()?.worktree}
-                          class="shrink-0"
-                          contentStyle={{
-                            "max-width": "640px",
-                            transform: "translate3d(52px, 0, 0)",
-                          }}
-                        >
-                          <span class="text-12-regular text-text-base truncate select-text">
-                            {project()?.worktree.replace(homedir(), "~")}
-                          </span>
-                        </Tooltip>
-                      </div>
-
-                      <DropdownMenu>
-                        <DropdownMenu.Trigger
-                          as={IconButton}
-                          icon="dot-grid"
-                          variant="ghost"
-                          class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
-                          aria-label={language.t("common.moreOptions")}
-                        />
-                        <DropdownMenu.Portal>
-                          <DropdownMenu.Content class="mt-1">
-                            <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
-                              <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                            <DropdownMenu.Item
-                              disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
-                              onSelect={() => {
-                                const enabled = layout.sidebar.workspaces(p.worktree)()
-                                if (enabled) {
-                                  layout.sidebar.toggleWorkspaces(p.worktree)
-                                  return
-                                }
-                                if (p.vcs !== "git") return
-                                layout.sidebar.toggleWorkspaces(p.worktree)
-                              }}
-                            >
-                              <DropdownMenu.ItemLabel>
-                                {layout.sidebar.workspaces(p.worktree)()
-                                  ? language.t("sidebar.workspaces.disable")
-                                  : language.t("sidebar.workspaces.enable")}
-                              </DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                            <DropdownMenu.Separator />
-                            <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
-                              <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                          </DropdownMenu.Content>
-                        </DropdownMenu.Portal>
-                      </DropdownMenu>
-                    </div>
-                  </div>
-
-                  <Show
-                    when={workspaceSetting()}
-                    fallback={
-                      <>
-                        <div class="py-4 px-3">
-                          <TooltipKeybind
-                            title={language.t("command.session.new")}
-                            keybind={command.keybind("session.new")}
-                            placement="top"
-                          >
-                            <Button
-                              size="large"
-                              icon="plus-small"
-                              class="w-full"
-                              onClick={() => {
-                                navigate(`/${base64Encode(p.worktree)}/session`)
-                                layout.mobileSidebar.hide()
-                              }}
-                            >
-                              {language.t("command.session.new")}
-                            </Button>
-                          </TooltipKeybind>
-                        </div>
-                        <div class="flex-1 min-h-0">
-                          <LocalWorkspace project={p} mobile={sidebarProps.mobile} />
-                        </div>
-                      </>
-                    }
-                  >
-                    <>
-                      <div class="py-4 px-3">
-                        <TooltipKeybind
-                          title={language.t("workspace.new")}
-                          keybind={command.keybind("workspace.new")}
-                          placement="top"
-                        >
-                          <Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
-                            {language.t("workspace.new")}
-                          </Button>
-                        </TooltipKeybind>
-                      </div>
-                      <div class="relative flex-1 min-h-0">
-                        <DragDropProvider
-                          onDragStart={handleWorkspaceDragStart}
-                          onDragEnd={handleWorkspaceDragEnd}
-                          onDragOver={handleWorkspaceDragOver}
-                          collisionDetector={closestCenter}
-                        >
-                          <DragDropSensors />
-                          <ConstrainDragXAxis />
-                          <div
-                            ref={(el) => {
-                              if (!sidebarProps.mobile) scrollContainerRef = el
-                            }}
-                            class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar"
-                            style={{ "overflow-anchor": "none" }}
-                          >
-                            <SortableProvider ids={workspaces()}>
-                              <For each={workspaces()}>
-                                {(directory) => (
-                                  <SortableWorkspace directory={directory} project={p} mobile={sidebarProps.mobile} />
-                                )}
-                              </For>
-                            </SortableProvider>
-                          </div>
-                          <DragOverlay>
-                            <WorkspaceDragOverlay />
-                          </DragOverlay>
-                        </DragDropProvider>
-                      </div>
-                    </>
-                  </Show>
-                </>
-              )}
-            </Show>
-            <Show when={providers.all().length > 0 && providers.paid().length === 0}>
-              <div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
-                <div class="rounded-md bg-background-base shadow-xs-border-base">
-                  <div class="p-3 flex flex-col gap-2">
-                    <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
-                    <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
-                    <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
-                  </div>
-                  <Button
-                    class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
-                    size="large"
-                    icon="plus"
-                    onClick={connectProvider}
-                  >
-                    {language.t("command.provider.connect")}
-                  </Button>
-                </div>
-              </div>
-            </Show>
-          </div>
+          <SidebarPanel project={currentProject()} mobile={sidebarProps.mobile} />
         </Show>
       </div>
     )
@@ -2576,10 +2628,18 @@ export default function Layout(props: ParentProps) {
             "relative shrink-0": true,
           }}
           style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
+          onMouseLeave={() => setHoverProject(undefined)}
         >
           <div class="@container w-full h-full contain-strict">
             <SidebarContent />
           </div>
+          <Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
+            {(project) => (
+              <div class="absolute inset-y-0 left-16 z-50 flex">
+                <SidebarPanel project={project} />
+              </div>
+            )}
+          </Show>
           <Show when={layout.sidebar.opened()}>
             <ResizeHandle
               direction="horizontal"