Browse Source

feat(app): new layout

adamelmore 1 month ago
parent
commit
9f66a45970

+ 205 - 197
packages/app/src/components/session/session-header.tsx

@@ -1,4 +1,5 @@
 import { createMemo, createResource, Show } from "solid-js"
+import { Portal } from "solid-js/web"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
 import { useCommand } from "@/context/command"
@@ -57,211 +58,218 @@ export function SessionHeader() {
     navigate(`/${params.dir}/session/${session.id}`)
   }
 
+  const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left"))
+  const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
+
   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-4 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={
-                <>
+    <>
+      <Show when={leftMount()}>
+        {(mount) => (
+          <Portal mount={mount()}>
+            <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={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"
+                    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"
-                  />
-                </>
-              }
-            >
-              <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>
+                  >
+                    {/* @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) => {
+                        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>
+                    <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>
+                </Show>
               </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="edit-small-2" variant="ghost" />
-            </TooltipKeybind>
-          </Show>
-        </div>
-        <div class="flex items-center gap-3">
-          <div class="hidden md:flex items-center gap-1">
-            <Button
-              size="small"
-              variant="ghost"
-              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,
-                }}
-              />
-              <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")}
-              >
+              <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="edit-small-2" variant="ghost" />
+                </TooltipKeybind>
+              </Show>
+            </div>
+          </Portal>
+        )}
+      </Show>
+      <Show when={rightMount()}>
+        {(mount) => (
+          <Portal mount={mount()}>
+            <div class="flex items-center gap-3">
+              <div class="hidden md:flex items-center gap-1">
                 <Button
+                  size="small"
                   variant="ghost"
-                  class="group/review-toggle size-6 p-0"
-                  onClick={() => view().reviewPanel.toggle()}
+                  onClick={() => {
+                    dialog.show(() => <DialogSelectServer />)
+                  }}
                 >
-                  <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                    <Icon
-                      name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
-                      size="small"
-                      class="group-hover/review-toggle:hidden"
-                    />
-                    <Icon
-                      name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
-                      size="small"
-                      class="hidden group-hover/review-toggle:inline-block"
-                    />
-                    <Icon
-                      name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
-                      size="small"
-                      class="hidden group-active/review-toggle:inline-block"
-                    />
-                  </div>
-                </Button>
-              </TooltipKeybind>
-            </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={() => view().terminal.toggle()}>
-                <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                  <Icon
-                    size="small"
-                    name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
-                    class="group-hover/terminal-toggle:hidden"
+                  <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
-                    size="small"
-                    name="layout-bottom-partial"
-                    class="hidden group-hover/terminal-toggle:inline-block"
-                  />
-                  <Icon
-                    size="small"
-                    name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
-                    class="hidden group-active/terminal-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>
-        </div>
-      </div>
-    </header>
+                  <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={() => view().reviewPanel.toggle()}
+                    >
+                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                        <Icon
+                          name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
+                          size="small"
+                          class="group-hover/review-toggle:hidden"
+                        />
+                        <Icon
+                          name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
+                          size="small"
+                          class="hidden group-hover/review-toggle:inline-block"
+                        />
+                        <Icon
+                          name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
+                          size="small"
+                          class="hidden group-active/review-toggle:inline-block"
+                        />
+                      </div>
+                    </Button>
+                  </TooltipKeybind>
+                </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={() => view().terminal.toggle()}
+                  >
+                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                      <Icon
+                        size="small"
+                        name={view().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={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+                        class="hidden group-active/terminal-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>
+            </div>
+          </Portal>
+        )}
+      </Show>
+    </>
   )
 }

+ 8 - 1
packages/app/src/context/global-sync.tsx

@@ -124,12 +124,19 @@ function createGlobalSync() {
     return globalSDK.client.session
       .list({ directory, roots: true })
       .then((x) => {
-        const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
         const nonArchived = (x.data ?? [])
           .filter((s) => !!s?.id)
           .filter((s) => !s.time?.archived)
           .slice()
           .sort((a, b) => a.id.localeCompare(b.id))
+
+        const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
+        if (sandboxWorkspace) {
+          setStore("session", reconcile(nonArchived, { key: "id" }))
+          return
+        }
+
+        const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
         // Include up to the limit, plus any updated in the last 4 hours
         const sessions = nonArchived.filter((s, i) => {
           if (i < limit) return true

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -5,6 +5,9 @@ export type Platform = {
   /** Platform discriminator */
   platform: "web" | "desktop"
 
+  /** Desktop OS (Tauri only) */
+  os?: "macos" | "windows" | "linux"
+
   /** App version */
   version?: string
 

File diff suppressed because it is too large
+ 419 - 412
packages/app/src/pages/layout.tsx


+ 21 - 10
packages/desktop/src/index.tsx

@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
 import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
 import { Store } from "@tauri-apps/plugin-store"
 import { Logo } from "@opencode-ai/ui/logo"
-import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
+import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
 
 import { UPDATER_ENABLED } from "./updater"
 import { createMenu } from "./menu"
@@ -30,6 +30,11 @@ let update: Update | null = null
 
 const createPlatform = (password: Accessor<string | null>): Platform => ({
   platform: "desktop",
+  os: (() => {
+    const type = ostype()
+    if (type === "macos" || type === "windows" || type === "linux") return type
+    return undefined
+  })(),
   version: pkg.version,
 
   async openDirectoryPickerDialog(opts) {
@@ -292,19 +297,25 @@ root?.addEventListener("mousewheel", (e) => {
   e.stopPropagation()
 })
 
-// Handle external links - open in system browser instead of webview
-document.addEventListener("click", (e) => {
-  const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
-  if (link?.href) {
-    e.preventDefault()
-    platform.openLink(link.href)
-  }
-})
-
 render(() => {
   const [serverPassword, setServerPassword] = createSignal<string | null>(null)
   const platform = createPlatform(() => serverPassword())
 
+  function handleClick(e: MouseEvent) {
+    const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
+    if (link?.href) {
+      e.preventDefault()
+      platform.openLink(link.href)
+    }
+  }
+
+  onMount(() => {
+    document.addEventListener("click", handleClick)
+    onCleanup(() => {
+      document.removeEventListener("click", handleClick)
+    })
+  })
+
   return (
     <PlatformProvider value={platform}>
       <AppBaseProviders>

Some files were not shown because too many files changed in this diff