summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages/layout
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/pages/layout
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/pages/layout')
-rw-r--r--packages/app/src/pages/layout/deep-links.ts26
-rw-r--r--packages/app/src/pages/layout/helpers.test.ts63
-rw-r--r--packages/app/src/pages/layout/helpers.ts65
-rw-r--r--packages/app/src/pages/layout/inline-editor.tsx113
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx330
-rw-r--r--packages/app/src/pages/layout/sidebar-project-helpers.test.ts63
-rw-r--r--packages/app/src/pages/layout/sidebar-project-helpers.ts11
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx283
-rw-r--r--packages/app/src/pages/layout/sidebar-shell-helpers.ts1
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.test.ts13
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.tsx109
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace-helpers.ts2
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.test.ts13
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx387
14 files changed, 1479 insertions, 0 deletions
diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts
new file mode 100644
index 000000000..772e6ece6
--- /dev/null
+++ b/packages/app/src/pages/layout/deep-links.ts
@@ -0,0 +1,26 @@
+export const deepLinkEvent = "opencode:deep-link"
+
+export const parseDeepLink = (input: string) => {
+ if (!input.startsWith("opencode://")) return
+ const url = new URL(input)
+ if (url.hostname !== "open-project") return
+ const directory = url.searchParams.get("directory")
+ if (!directory) return
+ return directory
+}
+
+export const collectOpenProjectDeepLinks = (urls: string[]) =>
+ urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
+
+type OpenCodeWindow = Window & {
+ __OPENCODE__?: {
+ deepLinks?: string[]
+ }
+}
+
+export const drainPendingDeepLinks = (target: OpenCodeWindow) => {
+ const pending = target.__OPENCODE__?.deepLinks ?? []
+ if (pending.length === 0) return []
+ if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = []
+ return pending
+}
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
new file mode 100644
index 000000000..8a8ea78c7
--- /dev/null
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
+import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
+
+describe("layout deep links", () => {
+ test("parses open-project deep links", () => {
+ expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
+ })
+
+ test("ignores non-project deep links", () => {
+ expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined()
+ expect(parseDeepLink("https://example.com")).toBeUndefined()
+ })
+
+ test("collects only valid open-project directories", () => {
+ const result = collectOpenProjectDeepLinks([
+ "opencode://open-project?directory=/a",
+ "opencode://other?directory=/b",
+ "opencode://open-project?directory=/c",
+ ])
+ expect(result).toEqual(["/a", "/c"])
+ })
+
+ test("drains global deep links once", () => {
+ const target = {
+ __OPENCODE__: {
+ deepLinks: ["opencode://open-project?directory=/a"],
+ },
+ } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } }
+
+ expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"])
+ expect(drainPendingDeepLinks(target)).toEqual([])
+ })
+})
+
+describe("layout workspace helpers", () => {
+ test("normalizes trailing slash in workspace key", () => {
+ expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
+ expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
+ })
+
+ test("keeps local first while preserving known order", () => {
+ const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
+ expect(result).toEqual(["/root", "/c", "/b"])
+ })
+
+ test("extracts draggable id safely", () => {
+ expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
+ expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
+ expect(getDraggableId(null)).toBeUndefined()
+ })
+
+ test("formats fallback project display name", () => {
+ expect(displayName({ worktree: "/tmp/app" })).toBe("app")
+ expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+ })
+
+ test("extracts api error message and fallback", () => {
+ expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom")
+ expect(errorMessage(new Error("broken"), "fallback")).toBe("broken")
+ expect(errorMessage("unknown", "fallback")).toBe("fallback")
+ })
+})
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
new file mode 100644
index 000000000..4d144f34e
--- /dev/null
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -0,0 +1,65 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type Session } from "@opencode-ai/sdk/v2/client"
+
+export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
+
+export function sortSessions(now: number) {
+ const oneMinuteAgo = now - 60 * 1000
+ return (a: Session, b: Session) => {
+ const aUpdated = a.time.updated ?? a.time.created
+ const bUpdated = b.time.updated ?? b.time.created
+ const aRecent = aUpdated > oneMinuteAgo
+ const bRecent = bUpdated > oneMinuteAgo
+ if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
+ if (aRecent && !bRecent) return -1
+ if (!aRecent && bRecent) return 1
+ return bUpdated - aUpdated
+ }
+}
+
+export const isRootVisibleSession = (session: Session, directory: string) =>
+ workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
+
+export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
+ store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
+
+export const childMapByParent = (sessions: Session[]) => {
+ const map = new Map<string, string[]>()
+ for (const session of sessions) {
+ if (!session.parentID) continue
+ const existing = map.get(session.parentID)
+ if (existing) {
+ existing.push(session.id)
+ continue
+ }
+ map.set(session.parentID, [session.id])
+ }
+ return map
+}
+
+export function getDraggableId(event: unknown): string | undefined {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+}
+
+export const displayName = (project: { name?: string; worktree: string }) =>
+ project.name || getFilename(project.worktree)
+
+export const errorMessage = (err: unknown, fallback: string) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return fallback
+}
+
+export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
+ if (!existing) return dirs
+ const keep = existing.filter((d) => d !== local && dirs.includes(d))
+ const missing = dirs.filter((d) => d !== local && !existing.includes(d))
+ return [local, ...missing, ...keep]
+}
diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx
new file mode 100644
index 000000000..0bbfe244c
--- /dev/null
+++ b/packages/app/src/pages/layout/inline-editor.tsx
@@ -0,0 +1,113 @@
+import { createStore } from "solid-js/store"
+import { Show, type Accessor } from "solid-js"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+
+export function createInlineEditorController() {
+ const [editor, setEditor] = createStore({
+ active: "" as string,
+ value: "",
+ })
+
+ const editorOpen = (id: string) => editor.active === id
+ const editorValue = () => editor.value
+ const openEditor = (id: string, value: string) => {
+ if (!id) return
+ setEditor({ active: id, value })
+ }
+ const closeEditor = () => setEditor({ active: "", value: "" })
+
+ const saveEditor = (callback: (next: string) => void) => {
+ const next = editor.value.trim()
+ if (!next) {
+ closeEditor()
+ return
+ }
+ closeEditor()
+ callback(next)
+ }
+
+ const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
+ if (event.key === "Enter") {
+ event.preventDefault()
+ saveEditor(callback)
+ return
+ }
+ if (event.key !== "Escape") return
+ event.preventDefault()
+ closeEditor()
+ }
+
+ const InlineEditor = (props: {
+ id: string
+ value: Accessor<string>
+ onSave: (next: string) => void
+ class?: string
+ displayClass?: string
+ editing?: boolean
+ stopPropagation?: boolean
+ openOnDblClick?: boolean
+ }) => {
+ const isEditing = () => props.editing ?? editorOpen(props.id)
+ const stopEvents = () => props.stopPropagation ?? false
+ const allowDblClick = () => props.openOnDblClick ?? true
+ const stopPropagation = (event: Event) => {
+ if (!stopEvents()) return
+ event.stopPropagation()
+ }
+ const handleDblClick = (event: MouseEvent) => {
+ if (!allowDblClick()) return
+ stopPropagation(event)
+ openEditor(props.id, props.value())
+ }
+
+ return (
+ <Show
+ when={isEditing()}
+ fallback={
+ <span
+ class={props.displayClass ?? props.class}
+ onDblClick={handleDblClick}
+ onPointerDown={stopPropagation}
+ onMouseDown={stopPropagation}
+ onClick={stopPropagation}
+ onTouchStart={stopPropagation}
+ >
+ {props.value()}
+ </span>
+ }
+ >
+ <InlineInput
+ ref={(el) => {
+ requestAnimationFrame(() => el.focus())
+ }}
+ value={editorValue()}
+ class={props.class}
+ onInput={(event) => setEditor("value", event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ editorKeyDown(event, props.onSave)
+ }}
+ onBlur={closeEditor}
+ onPointerDown={stopPropagation}
+ onClick={stopPropagation}
+ onDblClick={stopPropagation}
+ onMouseDown={stopPropagation}
+ onMouseUp={stopPropagation}
+ onTouchStart={stopPropagation}
+ />
+ </Show>
+ )
+ }
+
+ return {
+ editor,
+ editorOpen,
+ editorValue,
+ openEditor,
+ closeEditor,
+ saveEditor,
+ editorKeyDown,
+ setEditor,
+ InlineEditor,
+ }
+}
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
new file mode 100644
index 000000000..facfbddc7
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -0,0 +1,330 @@
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout"
+import { useNotification } from "@/context/notification"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Avatar } from "@opencode-ai/ui/avatar"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getFilename } from "@opencode-ai/util/path"
+import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
+import { agentColor } from "@/utils/agent"
+
+const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
+ const notification = useNotification()
+ const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
+ const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
+ const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
+ return (
+ <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
+ <div class="size-full rounded overflow-clip">
+ <Avatar
+ fallback={name()}
+ src={
+ props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
+ }
+ {...getAvatarColors(props.project.icon?.color)}
+ class="size-full rounded"
+ classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
+ />
+ </div>
+ <Show when={unseenCount() > 0 && props.notify}>
+ <div
+ classList={{
+ "absolute top-px right-px size-1.5 rounded-full z-10": true,
+ "bg-icon-critical-base": hasError(),
+ "bg-text-interactive-base": !hasError(),
+ }}
+ />
+ </Show>
+ </div>
+ )
+}
+
+export type SessionItemProps = {
+ session: Session
+ slug: string
+ mobile?: boolean
+ dense?: boolean
+ popover?: boolean
+ children: Map<string, string[]>
+ sidebarExpanded: Accessor<boolean>
+ sidebarHovering: Accessor<boolean>
+ nav: Accessor<HTMLElement | undefined>
+ hoverSession: Accessor<string | undefined>
+ setHoverSession: (id: string | undefined) => void
+ clearHoverProjectSoon: () => void
+ prefetchSession: (session: Session, priority?: "high" | "low") => void
+ archiveSession: (session: Session) => Promise<void>
+}
+
+export const SessionItem = (props: SessionItemProps): JSX.Element => {
+ const params = useParams()
+ const navigate = useNavigate()
+ const layout = useLayout()
+ const language = useLanguage()
+ const notification = useNotification()
+ const globalSync = useGlobalSync()
+ const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
+ const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
+ const [sessionStore] = globalSync.child(props.session.directory)
+ const hasPermissions = createMemo(() => {
+ const permissions = sessionStore.permission?.[props.session.id] ?? []
+ if (permissions.length > 0) return true
+
+ for (const id of props.children.get(props.session.id) ?? []) {
+ const childPermissions = sessionStore.permission?.[id] ?? []
+ if (childPermissions.length > 0) return true
+ }
+ return false
+ })
+ const isWorking = createMemo(() => {
+ if (hasPermissions()) return false
+ const status = sessionStore.session_status[props.session.id]
+ return status?.type === "busy" || status?.type === "retry"
+ })
+
+ const tint = createMemo(() => {
+ const messages = sessionStore.message[props.session.id]
+ if (!messages) return undefined
+ let user: Message | undefined
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i]
+ if (message.role !== "user") continue
+ user = message
+ break
+ }
+ if (!user?.agent) return undefined
+
+ const agent = sessionStore.agent.find((a) => a.name === user.agent)
+ return agentColor(user.agent, agent?.color)
+ })
+
+ const hoverMessages = createMemo(() =>
+ sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+ )
+ const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
+ const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
+ const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
+ const isActive = createMemo(() => props.session.id === params.id)
+
+ const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
+ const cancelHoverPrefetch = () => {
+ if (hoverPrefetch.current === undefined) return
+ clearTimeout(hoverPrefetch.current)
+ hoverPrefetch.current = undefined
+ }
+ const scheduleHoverPrefetch = () => {
+ if (hoverPrefetch.current !== undefined) return
+ hoverPrefetch.current = setTimeout(() => {
+ hoverPrefetch.current = undefined
+ props.prefetchSession(props.session)
+ }, 200)
+ }
+
+ onCleanup(cancelHoverPrefetch)
+
+ const messageLabel = (message: Message) => {
+ const parts = sessionStore.part[message.id] ?? []
+ const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
+ return text?.text
+ }
+
+ 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] ${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}
+ onMouseLeave={cancelHoverPrefetch}
+ onFocus={() => props.prefetchSession(props.session, "high")}
+ onClick={() => {
+ props.setHoverSession(undefined)
+ if (layout.sidebar.opened()) return
+ props.clearHoverProjectSoon()
+ }}
+ >
+ <div class="flex items-center gap-1 w-full">
+ <div
+ class="shrink-0 size-6 flex items-center justify-center"
+ style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+ >
+ <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+ <Match when={isWorking()}>
+ <Spinner class="size-[15px]" />
+ </Match>
+ <Match when={hasPermissions()}>
+ <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
+ <Match when={hasError()}>
+ <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={unseenCount() > 0}>
+ <div class="size-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ </Switch>
+ </div>
+ <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">
+ <DiffChanges changes={summary()} />
+ </div>
+ )}
+ </Show>
+ </div>
+ </A>
+ )
+
+ return (
+ <div
+ data-session-id={props.session.id}
+ class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
+ hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
+ >
+ <Show
+ when={hoverEnabled()}
+ fallback={
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
+ {item}
+ </Tooltip>
+ }
+ >
+ <HoverCard
+ openDelay={1000}
+ closeDelay={props.sidebarHovering() ? 600 : 0}
+ placement="right-start"
+ gutter={16}
+ shift={-2}
+ trigger={item}
+ mount={!props.mobile ? props.nav() : undefined}
+ open={props.hoverSession() === props.session.id}
+ onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
+ >
+ <Show
+ when={hoverReady()}
+ fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
+ >
+ <div class="overflow-y-auto max-h-72 h-full">
+ <MessageNav
+ messages={hoverMessages() ?? []}
+ current={undefined}
+ getLabel={messageLabel}
+ onMessageSelect={(message) => {
+ if (!isActive()) {
+ layout.pendingMessage.set(
+ `${base64Encode(props.session.directory)}/${props.session.id}`,
+ message.id,
+ )
+ navigate(`${props.slug}/session/${props.session.id}`)
+ return
+ }
+ window.history.replaceState(null, "", `#message-${message.id}`)
+ window.dispatchEvent(new HashChangeEvent("hashchange"))
+ }}
+ size="normal"
+ class="w-60"
+ />
+ </div>
+ </Show>
+ </HoverCard>
+ </Show>
+ <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": !!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,
+ }}
+ >
+ <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 props.archiveSession(props.session)
+ }}
+ />
+ </Tooltip>
+ </div>
+ </div>
+ )
+}
+
+export const NewSessionItem = (props: {
+ slug: string
+ mobile?: boolean
+ dense?: boolean
+ sidebarExpanded: Accessor<boolean>
+ clearHoverProjectSoon: () => void
+ setHoverSession: (id: string | undefined) => void
+}): JSX.Element => {
+ const layout = useLayout()
+ const language = useLanguage()
+ const label = language.t("command.session.new")
+ const tooltip = () => props.mobile || !props.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={() => {
+ props.setHoverSession(undefined)
+ if (layout.sidebar.opened()) return
+ props.clearHoverProjectSoon()
+ }}
+ >
+ <div class="flex items-center gap-1 w-full">
+ <div class="shrink-0 size-6 flex items-center justify-center">
+ <Icon name="plus-small" size="small" class="text-icon-weak" />
+ </div>
+ <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+ {label}
+ </span>
+ </div>
+ </A>
+ )
+
+ return (
+ <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
+ <Show
+ when={!tooltip()}
+ fallback={
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
+ {item}
+ </Tooltip>
+ }
+ >
+ {item}
+ </Show>
+ </div>
+ )
+}
+
+export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
+ const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
+ return (
+ <div class="flex flex-col gap-1">
+ <For each={items}>
+ {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
+ </For>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts
new file mode 100644
index 000000000..75958d49e
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
+
+describe("projectSelected", () => {
+ test("matches direct worktree", () => {
+ expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true)
+ })
+
+ test("matches sandbox worktree", () => {
+ expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true)
+ expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false)
+ })
+})
+
+describe("projectTileActive", () => {
+ test("menu state always wins", () => {
+ expect(
+ projectTileActive({
+ menu: true,
+ preview: false,
+ open: false,
+ overlay: false,
+ worktree: "/tmp/root",
+ }),
+ ).toBe(true)
+ })
+
+ test("preview mode uses open state", () => {
+ expect(
+ projectTileActive({
+ menu: false,
+ preview: true,
+ open: true,
+ overlay: true,
+ hoverProject: "/tmp/other",
+ worktree: "/tmp/root",
+ }),
+ ).toBe(true)
+ })
+
+ test("overlay mode uses hovered project", () => {
+ expect(
+ projectTileActive({
+ menu: false,
+ preview: false,
+ open: false,
+ overlay: true,
+ hoverProject: "/tmp/root",
+ worktree: "/tmp/root",
+ }),
+ ).toBe(true)
+ expect(
+ projectTileActive({
+ menu: false,
+ preview: false,
+ open: false,
+ overlay: true,
+ hoverProject: "/tmp/other",
+ worktree: "/tmp/root",
+ }),
+ ).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.ts b/packages/app/src/pages/layout/sidebar-project-helpers.ts
new file mode 100644
index 000000000..06d38a3cd
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-project-helpers.ts
@@ -0,0 +1,11 @@
+export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) =>
+ worktree === currentDir || sandboxes?.includes(currentDir) === true
+
+export const projectTileActive = (args: {
+ menu: boolean
+ preview: boolean
+ open: boolean
+ overlay: boolean
+ hoverProject?: string
+ worktree: string
+}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree)
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
new file mode 100644
index 000000000..c91dc987d
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -0,0 +1,283 @@
+import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Button } from "@opencode-ai/ui/button"
+import { ContextMenu } from "@opencode-ai/ui/context-menu"
+import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { type LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
+import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
+import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
+
+export type ProjectSidebarContext = {
+ currentDir: Accessor<string>
+ sidebarOpened: Accessor<boolean>
+ sidebarHovering: Accessor<boolean>
+ hoverProject: Accessor<string | undefined>
+ nav: Accessor<HTMLElement | undefined>
+ onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
+ onProjectMouseLeave: (worktree: string) => void
+ onProjectFocus: (worktree: string) => void
+ navigateToProject: (directory: string) => void
+ openSidebar: () => void
+ closeProject: (directory: string) => void
+ showEditProjectDialog: (project: LocalProject) => void
+ toggleProjectWorkspaces: (project: LocalProject) => void
+ workspacesEnabled: (project: LocalProject) => boolean
+ workspaceIds: (project: LocalProject) => string[]
+ workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
+ sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
+ setHoverSession: (id: string | undefined) => void
+}
+
+export const ProjectDragOverlay = (props: {
+ projects: Accessor<LocalProject[]>
+ activeProject: Accessor<string | undefined>
+}): JSX.Element => {
+ const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
+ return (
+ <Show when={project()}>
+ {(p) => (
+ <div class="bg-background-base rounded-xl p-1">
+ <ProjectIcon project={p()} />
+ </div>
+ )}
+ </Show>
+ )
+}
+
+export const SortableProject = (props: {
+ project: LocalProject
+ mobile?: boolean
+ ctx: ProjectSidebarContext
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const sortable = createSortable(props.project.worktree)
+ const selected = createMemo(() =>
+ projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
+ )
+ const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
+ const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
+ const [open, setOpen] = createSignal(false)
+ const [menu, setMenu] = createSignal(false)
+
+ const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
+ const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
+ const active = createMemo(() =>
+ projectTileActive({
+ menu: menu(),
+ preview: preview(),
+ open: open(),
+ overlay: overlay(),
+ hoverProject: props.ctx.hoverProject(),
+ worktree: 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 =
+ directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+ const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
+ return `${kind} : ${name}`
+ }
+
+ const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
+ const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2))
+ const projectChildren = createMemo(() => childMapByParent(projectStore().session))
+ const workspaceSessions = (directory: string) => {
+ const [data] = globalSync.child(directory, { bootstrap: false })
+ return sortedRootSessions(data, Date.now()).slice(0, 2)
+ }
+ const workspaceChildren = (directory: string) => {
+ const [data] = globalSync.child(directory, { bootstrap: false })
+ return childMapByParent(data.session)
+ }
+
+ const Trigger = () => (
+ <ContextMenu
+ modal={!props.ctx.sidebarHovering()}
+ onOpenChange={(value) => {
+ setMenu(value)
+ if (value) setOpen(false)
+ }}
+ >
+ <ContextMenu.Trigger
+ as="button"
+ type="button"
+ aria-label={displayName(props.project)}
+ data-action="project-switch"
+ data-project={base64Encode(props.project.worktree)}
+ classList={{
+ "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() && !active(),
+ "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+ }}
+ onMouseEnter={(event: MouseEvent) => {
+ if (!overlay()) return
+ props.ctx.onProjectMouseEnter(props.project.worktree, event)
+ }}
+ onMouseLeave={() => {
+ if (!overlay()) return
+ props.ctx.onProjectMouseLeave(props.project.worktree)
+ }}
+ onFocus={() => {
+ if (!overlay()) return
+ props.ctx.onProjectFocus(props.project.worktree)
+ }}
+ onClick={() => props.ctx.navigateToProject(props.project.worktree)}
+ onBlur={() => setOpen(false)}
+ >
+ <ProjectIcon project={props.project} notify />
+ </ContextMenu.Trigger>
+ <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
+ <ContextMenu.Content>
+ <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
+ <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={base64Encode(props.project.worktree)}
+ disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
+ onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
+ >
+ <ContextMenu.ItemLabel>
+ {props.ctx.workspacesEnabled(props.project)
+ ? language.t("sidebar.workspaces.disable")
+ : language.t("sidebar.workspaces.enable")}
+ </ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ <ContextMenu.Separator />
+ <ContextMenu.Item
+ data-action="project-close-menu"
+ data-project={base64Encode(props.project.worktree)}
+ onSelect={() => props.ctx.closeProject(props.project.worktree)}
+ >
+ <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
+ </ContextMenu.Item>
+ </ContextMenu.Content>
+ </ContextMenu.Portal>
+ </ContextMenu>
+ )
+
+ return (
+ // @ts-ignore
+ <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
+ <Show when={preview()} fallback={<Trigger />}>
+ <HoverCard
+ open={open() && !menu()}
+ openDelay={0}
+ closeDelay={0}
+ placement="right-start"
+ gutter={6}
+ trigger={<Trigger />}
+ onOpenChange={(value) => {
+ if (menu()) return
+ setOpen(value)
+ if (value) props.ctx.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"
+ data-action="project-close-hover"
+ data-project={base64Encode(props.project.worktree)}
+ aria-label={language.t("common.close")}
+ onClick={(event) => {
+ event.stopPropagation()
+ setOpen(false)
+ props.ctx.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
+ {...props.ctx.sessionProps}
+ session={session}
+ slug={base64Encode(props.project.worktree)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={projectChildren()}
+ />
+ )}
+ </For>
+ }
+ >
+ <For each={workspaces()}>
+ {(directory) => {
+ const sessions = createMemo(() => workspaceSessions(directory))
+ const children = createMemo(() => workspaceChildren(directory))
+ return (
+ <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()}>
+ {(session) => (
+ <SessionItem
+ {...props.ctx.sessionProps}
+ session={session}
+ slug={base64Encode(directory)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={children()}
+ />
+ )}
+ </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={() => {
+ props.ctx.openSidebar()
+ setOpen(false)
+ if (selected()) return
+ props.ctx.navigateToProject(props.project.worktree)
+ }}
+ >
+ {language.t("sidebar.project.viewAllSessions")}
+ </Button>
+ </div>
+ </div>
+ </HoverCard>
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/packages/app/src/pages/layout/sidebar-shell-helpers.ts
new file mode 100644
index 000000000..93c286c15
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-shell-helpers.ts
@@ -0,0 +1 @@
+export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened
diff --git a/packages/app/src/pages/layout/sidebar-shell.test.ts b/packages/app/src/pages/layout/sidebar-shell.test.ts
new file mode 100644
index 000000000..694025a65
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-shell.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { sidebarExpanded } from "./sidebar-shell-helpers"
+
+describe("sidebarExpanded", () => {
+ test("expands on mobile regardless of desktop open state", () => {
+ expect(sidebarExpanded(true, false)).toBe(true)
+ })
+
+ test("follows desktop open state when not mobile", () => {
+ expect(sidebarExpanded(false, true)).toBe(true)
+ expect(sidebarExpanded(false, false)).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
new file mode 100644
index 000000000..ce96a09d1
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -0,0 +1,109 @@
+import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ type DragEvent,
+} from "@thisbeyond/solid-dnd"
+import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { type LocalProject } from "@/context/layout"
+import { sidebarExpanded } from "./sidebar-shell-helpers"
+
+export const SidebarContent = (props: {
+ mobile?: boolean
+ opened: Accessor<boolean>
+ aimMove: (event: MouseEvent) => void
+ projects: Accessor<LocalProject[]>
+ renderProject: (project: LocalProject) => JSX.Element
+ handleDragStart: (event: unknown) => void
+ handleDragEnd: () => void
+ handleDragOver: (event: DragEvent) => void
+ openProjectLabel: JSX.Element
+ openProjectKeybind: Accessor<string | undefined>
+ onOpenProject: () => void
+ renderProjectOverlay: () => JSX.Element
+ settingsLabel: Accessor<string>
+ settingsKeybind: Accessor<string | undefined>
+ onOpenSettings: () => void
+ helpLabel: Accessor<string>
+ onOpenHelp: () => void
+ renderPanel: () => JSX.Element
+}): JSX.Element => {
+ const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
+
+ 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"
+ onMouseMove={props.aimMove}
+ >
+ <div class="flex-1 min-h-0 w-full">
+ <DragDropProvider
+ onDragStart={props.handleDragStart}
+ onDragEnd={props.handleDragEnd}
+ onDragOver={props.handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
+ <SortableProvider ids={props.projects().map((p) => p.worktree)}>
+ <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
+ </SortableProvider>
+ <Tooltip
+ placement={props.mobile ? "bottom" : "right"}
+ value={
+ <div class="flex items-center gap-2">
+ <span>{props.openProjectLabel}</span>
+ <Show when={!props.mobile && !!props.openProjectKeybind()}>
+ <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
+ </Show>
+ </div>
+ }
+ >
+ <IconButton
+ icon="plus"
+ variant="ghost"
+ size="large"
+ onClick={props.onOpenProject}
+ aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
+ />
+ </Tooltip>
+ </div>
+ <DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
+ </DragDropProvider>
+ </div>
+ <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
+ <TooltipKeybind
+ placement={props.mobile ? "bottom" : "right"}
+ title={props.settingsLabel()}
+ keybind={props.settingsKeybind() ?? ""}
+ >
+ <IconButton
+ icon="settings-gear"
+ variant="ghost"
+ size="large"
+ onClick={props.onOpenSettings}
+ aria-label={props.settingsLabel()}
+ />
+ </TooltipKeybind>
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}>
+ <IconButton
+ icon="help"
+ variant="ghost"
+ size="large"
+ onClick={props.onOpenHelp}
+ aria-label={props.helpLabel()}
+ />
+ </Tooltip>
+ </div>
+ </div>
+
+ <Show when={expanded()}>{props.renderPanel()}</Show>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts
new file mode 100644
index 000000000..aa7cb480e
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts
@@ -0,0 +1,2 @@
+export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
+ expanded[directory] ?? local
diff --git a/packages/app/src/pages/layout/sidebar-workspace.test.ts b/packages/app/src/pages/layout/sidebar-workspace.test.ts
new file mode 100644
index 000000000..d71c39fc8
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-workspace.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { workspaceOpenState } from "./sidebar-workspace-helpers"
+
+describe("workspaceOpenState", () => {
+ test("defaults to local workspace open", () => {
+ expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true)
+ })
+
+ test("uses persisted expansion state when present", () => {
+ expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false)
+ expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true)
+ })
+})
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
new file mode 100644
index 000000000..11bad84b0
--- /dev/null
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -0,0 +1,387 @@
+import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { getFilename } from "@opencode-ai/util/path"
+import { Button } from "@opencode-ai/ui/button"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { type Session } from "@opencode-ai/sdk/v2/client"
+import { type LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
+import { childMapByParent, sortedRootSessions } from "./helpers"
+
+type InlineEditorComponent = (props: {
+ id: string
+ value: Accessor<string>
+ onSave: (next: string) => void
+ class?: string
+ displayClass?: string
+ editing?: boolean
+ stopPropagation?: boolean
+ openOnDblClick?: boolean
+}) => JSX.Element
+
+export type WorkspaceSidebarContext = {
+ currentDir: Accessor<string>
+ sidebarExpanded: Accessor<boolean>
+ sidebarHovering: Accessor<boolean>
+ nav: Accessor<HTMLElement | undefined>
+ hoverSession: Accessor<string | undefined>
+ setHoverSession: (id: string | undefined) => void
+ clearHoverProjectSoon: () => void
+ prefetchSession: (session: Session, priority?: "high" | "low") => void
+ archiveSession: (session: Session) => Promise<void>
+ workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
+ renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
+ editorOpen: (id: string) => boolean
+ openEditor: (id: string, value: string) => void
+ closeEditor: () => void
+ setEditor: (key: "value", value: string) => void
+ InlineEditor: InlineEditorComponent
+ isBusy: (directory: string) => boolean
+ workspaceExpanded: (directory: string, local: boolean) => boolean
+ setWorkspaceExpanded: (directory: string, value: boolean) => void
+ showResetWorkspaceDialog: (root: string, directory: string) => void
+ showDeleteWorkspaceDialog: (root: string, directory: string) => void
+ setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
+}
+
+export const WorkspaceDragOverlay = (props: {
+ sidebarProject: Accessor<LocalProject | undefined>
+ activeWorkspace: Accessor<string | undefined>
+ workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const label = createMemo(() => {
+ const project = props.sidebarProject()
+ if (!project) return
+ const directory = props.activeWorkspace()
+ if (!directory) return
+
+ const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
+ const kind =
+ directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+ const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
+ return `${kind} : ${name}`
+ })
+
+ return (
+ <Show when={label()}>
+ {(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
+ </Show>
+ )
+}
+
+export const SortableWorkspace = (props: {
+ ctx: WorkspaceSidebarContext
+ directory: string
+ project: LocalProject
+ mobile?: boolean
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const sortable = createSortable(props.directory)
+ const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
+ const [menu, setMenu] = createStore({
+ open: false,
+ pendingRename: false,
+ })
+ const slug = createMemo(() => base64Encode(props.directory))
+ const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now()))
+ const children = createMemo(() => childMapByParent(workspaceStore.session))
+ const local = createMemo(() => props.directory === props.project.worktree)
+ const active = createMemo(() => props.ctx.currentDir() === props.directory)
+ const workspaceValue = createMemo(() => {
+ const branch = workspaceStore.vcs?.branch
+ const name = branch ?? getFilename(props.directory)
+ return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
+ })
+ const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
+ const boot = createMemo(() => open() || active())
+ const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
+ const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
+ const busy = createMemo(() => props.ctx.isBusy(props.directory))
+ const wasBusy = createMemo((prev) => prev || busy(), false)
+ const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
+ const loadMore = async () => {
+ setWorkspaceStore("limit", (limit) => limit + 5)
+ await globalSync.project.loadSessions(props.directory)
+ }
+
+ const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
+
+ const openWrapper = (value: boolean) => {
+ props.ctx.setWorkspaceExpanded(props.directory, value)
+ if (value) return
+ if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
+ }
+
+ createEffect(() => {
+ if (!boot()) return
+ globalSync.child(props.directory, { bootstrap: true })
+ })
+
+ const header = () => (
+ <div class="flex items-center gap-1 min-w-0 flex-1">
+ <div class="flex items-center justify-center shrink-0 size-6">
+ <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
+ <Spinner class="size-[15px]" />
+ </Show>
+ </div>
+ <span class="text-14-medium text-text-base shrink-0">
+ {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
+ </span>
+ <Show
+ when={!local()}
+ fallback={
+ <span class="text-14-medium text-text-base min-w-0 truncate">
+ {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
+ </span>
+ }
+ >
+ <props.ctx.InlineEditor
+ id={`workspace:${props.directory}`}
+ value={workspaceValue}
+ onSave={(next) => {
+ const trimmed = next.trim()
+ if (!trimmed) return
+ props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
+ props.ctx.setEditor("value", workspaceValue())
+ }}
+ class="text-14-medium text-text-base min-w-0 truncate"
+ displayClass="text-14-medium text-text-base min-w-0 truncate"
+ editing={workspaceEditActive()}
+ stopPropagation={false}
+ openOnDblClick={false}
+ />
+ </Show>
+ <Icon
+ name={open() ? "chevron-down" : "chevron-right"}
+ size="small"
+ class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
+ />
+ </div>
+ )
+
+ return (
+ <div
+ // @ts-ignore
+ use:sortable
+ classList={{
+ "opacity-30": sortable.isActiveDraggable,
+ "opacity-50 pointer-events-none": busy(),
+ }}
+ >
+ <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
+ <div class="px-2 py-1">
+ <div
+ class="group/workspace relative"
+ data-component="workspace-item"
+ data-workspace={base64Encode(props.directory)}
+ >
+ <div class="flex items-center gap-1">
+ <Show
+ when={workspaceEditActive()}
+ fallback={
+ <Collapsible.Trigger
+ class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
+ data-action="workspace-toggle"
+ data-workspace={base64Encode(props.directory)}
+ >
+ {header()}
+ </Collapsible.Trigger>
+ }
+ >
+ <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
+ </Show>
+ <div
+ class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
+ classList={{
+ "opacity-100 pointer-events-auto": menu.open,
+ "opacity-0 pointer-events-none": !menu.open,
+ "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
+ "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
+ }}
+ >
+ <DropdownMenu
+ modal={!props.ctx.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-action="workspace-menu"
+ data-workspace={base64Encode(props.directory)}
+ aria-label={language.t("common.moreOptions")}
+ />
+ </Tooltip>
+ <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!menu.pendingRename) return
+ event.preventDefault()
+ setMenu("pendingRename", false)
+ props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue())
+ }}
+ >
+ <DropdownMenu.Item
+ disabled={local()}
+ onSelect={() => {
+ setMenu("pendingRename", true)
+ setMenu("open", false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={local() || busy()}
+ onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ disabled={local() || busy()}
+ onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <Collapsible.Content>
+ <nav class="flex flex-col gap-1 px-2">
+ <NewSessionItem
+ slug={slug()}
+ mobile={props.mobile}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ setHoverSession={props.ctx.setHoverSession}
+ />
+ <Show when={loading()}>
+ <SessionSkeleton />
+ </Show>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={slug()}
+ mobile={props.mobile}
+ children={children()}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ sidebarHovering={props.ctx.sidebarHovering}
+ nav={props.ctx.nav}
+ hoverSession={props.ctx.hoverSession}
+ setHoverSession={props.ctx.setHoverSession}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ prefetchSession={props.ctx.prefetchSession}
+ archiveSession={props.ctx.archiveSession}
+ />
+ )}
+ </For>
+ <Show when={hasMore()}>
+ <div class="relative w-full py-1">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+ size="large"
+ onClick={(e: MouseEvent) => {
+ loadMore()
+ ;(e.currentTarget as HTMLButtonElement).blur()
+ }}
+ >
+ {language.t("common.loadMore")}
+ </Button>
+ </div>
+ </Show>
+ </nav>
+ </Collapsible.Content>
+ </Collapsible>
+ </div>
+ )
+}
+
+export const LocalWorkspace = (props: {
+ ctx: WorkspaceSidebarContext
+ project: LocalProject
+ mobile?: boolean
+}): JSX.Element => {
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const workspace = createMemo(() => {
+ const [store, setStore] = globalSync.child(props.project.worktree)
+ return { store, setStore }
+ })
+ const slug = createMemo(() => base64Encode(props.project.worktree))
+ const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now()))
+ const children = createMemo(() => childMapByParent(workspace().store.session))
+ const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
+ const loading = createMemo(() => !booted() && sessions().length === 0)
+ const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
+ const loadMore = async () => {
+ workspace().setStore("limit", (limit) => limit + 5)
+ await globalSync.project.loadSessions(props.project.worktree)
+ }
+
+ return (
+ <div
+ ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
+ class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+ >
+ <nav class="flex flex-col gap-1 px-2">
+ <Show when={loading()}>
+ <SessionSkeleton />
+ </Show>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={slug()}
+ mobile={props.mobile}
+ children={children()}
+ sidebarExpanded={props.ctx.sidebarExpanded}
+ sidebarHovering={props.ctx.sidebarHovering}
+ nav={props.ctx.nav}
+ hoverSession={props.ctx.hoverSession}
+ setHoverSession={props.ctx.setHoverSession}
+ clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+ prefetchSession={props.ctx.prefetchSession}
+ archiveSession={props.ctx.archiveSession}
+ />
+ )}
+ </For>
+ <Show when={hasMore()}>
+ <div class="relative w-full py-1">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+ size="large"
+ onClick={(e: MouseEvent) => {
+ loadMore()
+ ;(e.currentTarget as HTMLButtonElement).blur()
+ }}
+ >
+ {language.t("common.loadMore")}
+ </Button>
+ </div>
+ </Show>
+ </nav>
+ </div>
+ )
+}