summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
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
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/directory-layout.tsx2
-rw-r--r--packages/app/src/pages/layout.tsx1296
-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
-rw-r--r--packages/app/src/pages/session.tsx2384
-rw-r--r--packages/app/src/pages/session/file-tab-scroll.test.ts40
-rw-r--r--packages/app/src/pages/session/file-tab-scroll.ts67
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx516
-rw-r--r--packages/app/src/pages/session/message-gesture.test.ts62
-rw-r--r--packages/app/src/pages/session/message-gesture.ts21
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx348
-rw-r--r--packages/app/src/pages/session/review-tab.tsx158
-rw-r--r--packages/app/src/pages/session/session-command-helpers.ts10
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx36
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.test.ts22
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx137
-rw-r--r--packages/app/src/pages/session/session-prompt-helpers.ts4
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx306
-rw-r--r--packages/app/src/pages/session/terminal-label.ts16
-rw-r--r--packages/app/src/pages/session/terminal-panel.test.ts25
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx169
-rw-r--r--packages/app/src/pages/session/use-session-commands.test.ts44
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx439
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.test.ts16
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts174
37 files changed, 4490 insertions, 3281 deletions
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index da4667a82..2f4db8564 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -25,7 +25,7 @@ export default function Layout(props: ParentProps) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
- description: "Invalid directory in URL.",
+ description: language.t("directory.error.invalidUrl"),
})
navigate("/")
})
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 3b66258c9..59adef469 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -2,53 +2,34 @@ import {
batch,
createEffect,
createMemo,
- createSignal,
For,
- Match,
on,
onCleanup,
onMount,
ParentProps,
Show,
- Switch,
untrack,
- type Accessor,
type JSX,
} from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
-import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
+import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
-import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { HoverCard } from "@opencode-ai/ui/hover-card"
-import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { ContextMenu } from "@opencode-ai/ui/context-menu"
-import { Collapsible } from "@opencode-ai/ui/collapsible"
-import { DiffChanges } from "@opencode-ai/ui/diff-changes"
-import { Spinner } from "@opencode-ai/ui/spinner"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
-import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { Session, type Message } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
-} from "@thisbeyond/solid-dnd"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
@@ -60,7 +41,6 @@ import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { Worktree as WorktreeState } from "@/utils/worktree"
-import { agentColor } from "@/utils/agent"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -75,44 +55,26 @@ import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
-
-const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
-
-const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
-
-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
- }
-}
-
-const isRootVisibleSession = (session: Session, directory: string) =>
- workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
-
-const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
- store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
-
-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
-}
+import {
+ childMapByParent,
+ displayName,
+ errorMessage,
+ getDraggableId,
+ sortedRootSessions,
+ syncWorkspaceOrder,
+ workspaceKey,
+} from "./layout/helpers"
+import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
+import { createInlineEditorController } from "./layout/inline-editor"
+import {
+ LocalWorkspace,
+ SortableWorkspace,
+ WorkspaceDragOverlay,
+ type WorkspaceSidebarContext,
+} from "./layout/sidebar-workspace"
+import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
+import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
+import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
@@ -168,10 +130,7 @@ export default function Layout(props: ParentProps) {
nav: undefined as HTMLElement | undefined,
})
- const [editor, setEditor] = createStore({
- active: "" as string,
- value: "",
- })
+ const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory)
setState("busyWorkspaces", (prev) => {
@@ -202,6 +161,8 @@ export default function Layout(props: ParentProps) {
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
+ const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined))
+ const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const hoverProjectData = createMemo(() => {
const id = state.hoverProject
@@ -253,99 +214,11 @@ export default function Layout(props: ParentProps) {
setState("autoselect", false)
})
- 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") {
- 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>
- )
- }
+ const editorOpen = editor.editorOpen
+ const openEditor = editor.openEditor
+ const closeEditor = editor.closeEditor
+ const setEditor = editor.setEditor
+ const InlineEditor = editor.InlineEditor
function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id)
@@ -670,15 +543,12 @@ export default function Layout(props: ParentProps) {
const local = project.worktree
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
+ const merged = syncWorkspaceOrder(local, dirs, existing)
if (!existing) {
- setStore("workspaceOrder", project.worktree, dirs)
+ setStore("workspaceOrder", project.worktree, merged)
return
}
- const keep = existing.filter((d) => d !== local && dirs.includes(d))
- const missing = dirs.filter((d) => d !== local && !existing.includes(d))
- const merged = [local, ...missing, ...keep]
-
if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged)
return
@@ -1241,33 +1111,13 @@ export default function Layout(props: ParentProps) {
if (navigate) navigateToProject(directory)
}
- const deepLinkEvent = "opencode:deep-link"
-
- 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
- }
-
const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return
- for (const input of urls) {
- const directory = parseDeepLink(input)
- if (!directory) continue
+ for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory)
}
}
- const drainDeepLinks = () => {
- const pending = window.__OPENCODE__?.deepLinks ?? []
- if (pending.length === 0) return
- if (window.__OPENCODE__) window.__OPENCODE__.deepLinks = []
- handleDeepLinks(pending)
- }
-
onMount(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ urls: string[] }>).detail
@@ -1276,13 +1126,11 @@ export default function Layout(props: ParentProps) {
handleDeepLinks(urls)
}
- drainDeepLinks()
+ handleDeepLinks(drainPendingDeepLinks(window))
window.addEventListener(deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
})
- const displayName = (project: LocalProject) => project.name || getFilename(project.worktree)
-
async function renameProject(project: LocalProject, next: string) {
const current = displayName(project)
if (next === current) return
@@ -1310,6 +1158,18 @@ export default function Layout(props: ParentProps) {
else navigate("/")
}
+ function toggleProjectWorkspaces(project: LocalProject) {
+ const enabled = layout.sidebar.workspaces(project.worktree)()
+ if (enabled) {
+ layout.sidebar.toggleWorkspaces(project.worktree)
+ return
+ }
+ if (project.vcs !== "git") return
+ layout.sidebar.toggleWorkspaces(project.worktree)
+ }
+
+ const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
+
async function chooseProject() {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
@@ -1336,15 +1196,6 @@ export default function Layout(props: ParentProps) {
}
}
- const errorMessage = (err: unknown) => {
- 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 language.t("common.requestFailed")
- }
-
const deleteWorkspace = async (root: string, directory: string) => {
if (directory === root) return
@@ -1356,7 +1207,7 @@ export default function Layout(props: ParentProps) {
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
- description: errorMessage(err),
+ description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
@@ -1395,7 +1246,7 @@ export default function Layout(props: ParentProps) {
.catch((err) => {
showToast({
title: language.t("workspace.reset.failed.title"),
- description: errorMessage(err),
+ description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
@@ -1622,14 +1473,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.loadSessions(project.worktree)
})
- 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
- }
-
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
@@ -1665,9 +1508,8 @@ export default function Layout(props: ParentProps) {
const existing = store.workspaceOrder[project.worktree]
if (!existing) return extra ? [...dirs, extra] : dirs
- const keep = existing.filter((d) => d !== local && dirs.includes(d))
- const missing = dirs.filter((d) => d !== local && !existing.includes(d))
- const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep]
+ const merged = syncWorkspaceOrder(local, dirs, existing)
+ if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
@@ -1710,830 +1552,6 @@ export default function Layout(props: ParentProps) {
setStore("activeWorkspace", undefined)
}
- 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>
- )
- }
-
- const SessionItem = (props: {
- session: Session
- slug: string
- mobile?: boolean
- dense?: boolean
- popover?: boolean
- children: Map<string, string[]>
- }): JSX.Element => {
- const notification = useNotification()
- 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 && 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
- 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={() => prefetchSession(props.session, "high")}
- onClick={() => {
- setState("hoverSession", undefined)
- if (layout.sidebar.opened()) return
- queueMicrotask(() => setState("hoverProject", undefined))
- }}
- >
- <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={sidebarHovering() ? 600 : 0}
- placement="right-start"
- gutter={16}
- shift={-2}
- trigger={item}
- mount={!props.mobile ? state.nav : undefined}
- open={state.hoverSession === props.session.id}
- onOpenChange={(open) => setState("hoverSession", 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 archiveSession(props.session)
- }}
- />
- </Tooltip>
- </div>
- </div>
- )
- }
-
- const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
- const label = language.t("command.session.new")
- 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={() => {
- setState("hoverSession", undefined)
- if (layout.sidebar.opened()) return
- queueMicrotask(() => setState("hoverProject", undefined))
- }}
- >
- <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>
- )
- }
-
- 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>
- )
- }
-
- const ProjectDragOverlay = (): JSX.Element => {
- const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
- return (
- <Show when={project()}>
- {(p) => (
- <div class="bg-background-base rounded-xl p-1">
- <ProjectIcon project={p()} />
- </div>
- )}
- </Show>
- )
- }
-
- const WorkspaceDragOverlay = (): JSX.Element => {
- const label = createMemo(() => {
- const project = sidebarProject()
- if (!project) return
- const directory = store.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 = 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>
- )
- }
-
- const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
- 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(() => currentDir() === props.directory)
- const workspaceValue = createMemo(() => {
- const branch = workspaceStore.vcs?.branch
- const name = branch ?? getFilename(props.directory)
- return workspaceName(props.directory, props.project.id, branch) ?? name
- })
- const open = createMemo(() => store.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(() => 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(() => editorOpen(`workspace:${props.directory}`))
-
- const openWrapper = (value: boolean) => {
- setStore("workspaceExpanded", props.directory, value)
- if (value) return
- if (editorOpen(`workspace:${props.directory}`)) 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>
- }
- >
- <InlineEditor
- id={`workspace:${props.directory}`}
- value={workspaceValue}
- onSave={(next) => {
- const trimmed = next.trim()
- if (!trimmed) return
- renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
- 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={!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 ? state.nav : undefined}>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!menu.pendingRename) return
- event.preventDefault()
- setMenu("pendingRename", false)
- 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={() =>
- 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 root={props.project.worktree} directory={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} />
- <Show when={loading()}>
- <SessionSkeleton />
- </Show>
- <For each={sessions()}>
- {(session) => (
- <SessionItem session={session} slug={slug()} mobile={props.mobile} children={children()} />
- )}
- </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>
- )
- }
-
- const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- const sortable = createSortable(props.project.worktree)
- const selected = createMemo(() => {
- const current = currentDir()
- return props.project.worktree === current || props.project.sandboxes?.includes(current)
- })
-
- const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
- const workspaceEnabled = createMemo(
- () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
- )
- const [open, setOpen] = createSignal(false)
- const [menu, setMenu] = createSignal(false)
-
- const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
- const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
- const active = createMemo(
- () => menu() || (preview() ? open() : overlay() && state.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 =
- directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
- const name = 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 projectName = () => props.project.name || getFilename(props.project.worktree)
- const Trigger = () => (
- <ContextMenu
- modal={!sidebarHovering()}
- onOpenChange={(value) => {
- setMenu(value)
- if (value) setOpen(false)
- }}
- >
- <ContextMenu.Trigger
- as="button"
- type="button"
- aria-label={projectName()}
- 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
- aim.enter(props.project.worktree, event)
- }}
- onMouseLeave={() => {
- if (!overlay()) return
- aim.leave(props.project.worktree)
- }}
- onFocus={() => {
- if (!overlay()) return
- aim.activate(props.project.worktree)
- }}
- onClick={() => navigateToProject(props.project.worktree)}
- onBlur={() => setOpen(false)}
- >
- <ProjectIcon project={props.project} notify />
- </ContextMenu.Trigger>
- <ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
- <ContextMenu.Content>
- <ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={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" && !layout.sidebar.workspaces(props.project.worktree)()}
- onSelect={() => {
- const enabled = layout.sidebar.workspaces(props.project.worktree)()
- if (enabled) {
- layout.sidebar.toggleWorkspaces(props.project.worktree)
- return
- }
- if (props.project.vcs !== "git") return
- layout.sidebar.toggleWorkspaces(props.project.worktree)
- }}
- >
- <ContextMenu.ItemLabel>
- {layout.sidebar.workspaces(props.project.worktree)()
- ? 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={() => 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) setState("hoverSession", 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)
- 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}
- 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
- 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={() => {
- layout.sidebar.open()
- setOpen(false)
- if (selected()) {
- return
- }
- navigateToProject(props.project.worktree)
- }}
- >
- {language.t("sidebar.project.viewAllSessions")}
- </Button>
- </div>
- </div>
- </HoverCard>
- </Show>
- </div>
- )
- }
-
- const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- 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) => {
- if (!props.mobile) scrollContainerRef = el
- }}
- 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()} />}
- </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>
- )
- }
-
const createWorkspace = async (project: LocalProject) => {
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
@@ -2545,7 +1563,7 @@ export default function Layout(props: ParentProps) {
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
- description: errorMessage(err),
+ description: errorMessage(err, language.t("common.requestFailed")),
})
return undefined
})
@@ -2579,6 +1597,65 @@ export default function Layout(props: ParentProps) {
layout.mobileSidebar.hide()
}
+ const workspaceSidebarCtx: WorkspaceSidebarContext = {
+ currentDir,
+ sidebarExpanded,
+ sidebarHovering,
+ nav: () => state.nav,
+ hoverSession: () => state.hoverSession,
+ setHoverSession,
+ clearHoverProjectSoon,
+ prefetchSession,
+ archiveSession,
+ workspaceName,
+ renameWorkspace,
+ editorOpen,
+ openEditor,
+ closeEditor,
+ setEditor,
+ InlineEditor,
+ isBusy,
+ workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
+ setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
+ showResetWorkspaceDialog: (root, directory) =>
+ dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
+ showDeleteWorkspaceDialog: (root, directory) =>
+ dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />),
+ setScrollContainerRef: (el, mobile) => {
+ if (!mobile) scrollContainerRef = el
+ },
+ }
+
+ const projectSidebarCtx: ProjectSidebarContext = {
+ currentDir,
+ sidebarOpened: () => layout.sidebar.opened(),
+ sidebarHovering,
+ hoverProject: () => state.hoverProject,
+ nav: () => state.nav,
+ onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
+ onProjectMouseLeave: (worktree) => aim.leave(worktree),
+ onProjectFocus: (worktree) => aim.activate(worktree),
+ navigateToProject,
+ openSidebar: () => layout.sidebar.open(),
+ closeProject,
+ showEditProjectDialog,
+ toggleProjectWorkspaces,
+ workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
+ workspaceIds,
+ workspaceLabel,
+ sessionProps: {
+ sidebarExpanded,
+ sidebarHovering,
+ nav: () => state.nav,
+ hoverSession: () => state.hoverSession,
+ setHoverSession,
+ clearHoverProjectSoon,
+ prefetchSession,
+ archiveSession,
+ },
+ setHoverSession,
+ }
+
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
const projectName = createMemo(() => {
const project = panelProps.project
@@ -2649,22 +1726,14 @@ export default function Layout(props: ParentProps) {
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
+ <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
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)
- }}
+ onSelect={() => toggleProjectWorkspaces(p())}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
@@ -2715,7 +1784,7 @@ export default function Layout(props: ParentProps) {
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
- <LocalWorkspace project={p()} mobile={panelProps.mobile} />
+ <LocalWorkspace ctx={workspaceSidebarCtx} project={p()} mobile={panelProps.mobile} />
</div>
</>
}
@@ -2750,13 +1819,22 @@ export default function Layout(props: ParentProps) {
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
- <SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
+ <SortableWorkspace
+ ctx={workspaceSidebarCtx}
+ directory={directory}
+ project={p()}
+ mobile={panelProps.mobile}
+ />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
- <WorkspaceDragOverlay />
+ <WorkspaceDragOverlay
+ sidebarProject={sidebarProject}
+ activeWorkspace={() => store.activeWorkspace}
+ workspaceLabel={workspaceLabel}
+ />
</DragOverlay>
</DragDropProvider>
</div>
@@ -2793,85 +1871,6 @@ export default function Layout(props: ParentProps) {
)
}
- const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
- const expanded = () => sidebarProps.mobile || layout.sidebar.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={aim.move}>
- <div class="flex-1 min-h-0 w-full">
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={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={layout.projects.list().map((p) => p.worktree)}>
- <For each={layout.projects.list()}>
- {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
- </For>
- </SortableProvider>
- <Tooltip
- placement={sidebarProps.mobile ? "bottom" : "right"}
- value={
- <div class="flex items-center gap-2">
- <span>{language.t("command.project.open")}</span>
- <Show when={!sidebarProps.mobile}>
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
- </Show>
- </div>
- }
- >
- <IconButton
- icon="plus"
- variant="ghost"
- size="large"
- onClick={chooseProject}
- aria-label={language.t("command.project.open")}
- />
- </Tooltip>
- </div>
- <DragOverlay>
- <ProjectDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
- <TooltipKeybind
- placement={sidebarProps.mobile ? "bottom" : "right"}
- title={language.t("sidebar.settings")}
- keybind={command.keybind("settings.open")}
- >
- <IconButton
- icon="settings-gear"
- variant="ghost"
- size="large"
- onClick={openSettings}
- aria-label={language.t("sidebar.settings")}
- />
- </TooltipKeybind>
- <Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value={language.t("sidebar.help")}>
- <IconButton
- icon="help"
- variant="ghost"
- size="large"
- onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
- aria-label={language.t("sidebar.help")}
- />
- </Tooltip>
- </div>
- </div>
-
- <Show when={expanded()}>
- <SidebarPanel project={currentProject()} mobile={sidebarProps.mobile} />
- </Show>
- </div>
- )
- }
-
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
@@ -2905,7 +1904,27 @@ export default function Layout(props: ParentProps) {
}}
>
<div class="@container w-full h-full contain-strict">
- <SidebarContent />
+ <SidebarContent
+ opened={() => layout.sidebar.opened()}
+ aimMove={aim.move}
+ projects={() => layout.projects.list()}
+ renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} />}
+ handleDragStart={handleDragStart}
+ handleDragEnd={handleDragEnd}
+ handleDragOver={handleDragOver}
+ openProjectLabel={language.t("command.project.open")}
+ openProjectKeybind={() => command.keybind("project.open")}
+ onOpenProject={chooseProject}
+ renderProjectOverlay={() => (
+ <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
+ )}
+ settingsLabel={() => language.t("sidebar.settings")}
+ settingsKeybind={() => command.keybind("settings.open")}
+ onOpenSettings={openSettings}
+ helpLabel={() => language.t("sidebar.help")}
+ onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+ renderPanel={() => <SidebarPanel project={currentProject()} />}
+ />
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
{(project) => (
@@ -2947,7 +1966,28 @@ export default function Layout(props: ParentProps) {
}}
onClick={(e) => e.stopPropagation()}
>
- <SidebarContent mobile />
+ <SidebarContent
+ mobile
+ opened={() => layout.sidebar.opened()}
+ aimMove={aim.move}
+ projects={() => layout.projects.list()}
+ renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} mobile />}
+ handleDragStart={handleDragStart}
+ handleDragEnd={handleDragEnd}
+ handleDragOver={handleDragOver}
+ openProjectLabel={language.t("command.project.open")}
+ openProjectKeybind={() => command.keybind("project.open")}
+ onOpenProject={chooseProject}
+ renderProjectOverlay={() => (
+ <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
+ )}
+ settingsLabel={() => language.t("sidebar.settings")}
+ settingsKeybind={() => command.keybind("settings.open")}
+ onOpenSettings={openSettings}
+ helpLabel={() => language.t("sidebar.help")}
+ onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+ renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
+ />
</nav>
</div>
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>
+ )
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 67606e860..a70d4e8a2 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,84 +1,63 @@
-import {
- For,
- onCleanup,
- onMount,
- Show,
- Match,
- Switch,
- createMemo,
- createEffect,
- createSignal,
- on,
- type JSX,
-} from "solid-js"
+import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store"
-import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
-import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
-import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
-import { SessionTurn } from "@opencode-ai/ui/session-turn"
-import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
-import { SessionReview } from "@opencode-ai/ui/session-review"
import { Mark } from "@opencode-ai/ui/logo"
-import { QuestionDock } from "@/components/question-dock"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
-import { Terminal } from "@/components/terminal"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
-import { DialogSelectModel } from "@/components/dialog-select-model"
-import { DialogSelectMcp } from "@/components/dialog-select-mcp"
-import { DialogFork } from "@/components/dialog-fork"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
-import type { FileDiff } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
-import { useComments, type LineComment } from "@/context/comments"
-import { extractPromptFromParts } from "@/utils/prompt"
+import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
-import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
-import {
- SessionHeader,
- SessionContextTab,
- SortableTab,
- FileVisual,
- SortableTerminalTab,
- NewSessionView,
-} from "@/components/session"
+import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
-import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
+import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
-
-type DiffStyle = "unified" | "split"
+import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
+import { FileTabContent } from "@/pages/session/file-tabs"
+import {
+ SessionReviewTab,
+ StickyAddButton,
+ type DiffStyle,
+ type SessionReviewTabProps,
+} from "@/pages/session/review-tab"
+import { TerminalPanel } from "@/pages/session/terminal-panel"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+import { MessageTimeline } from "@/pages/session/message-timeline"
+import { useSessionCommands } from "@/pages/session/use-session-commands"
+import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
+import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
+import { SessionSidePanel } from "@/pages/session/session-side-panel"
+import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
type HandoffSession = {
prompt: string
@@ -107,155 +86,6 @@ const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
touch(handoff.session, key, { ...prev, ...patch })
}
-interface SessionReviewTabProps {
- title?: JSX.Element
- empty?: JSX.Element
- diffs: () => FileDiff[]
- view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
- diffStyle: DiffStyle
- onDiffStyleChange?: (style: DiffStyle) => void
- onViewFile?: (file: string) => void
- onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
- comments?: LineComment[]
- focusedComment?: { file: string; id: string } | null
- onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
- focusedFile?: string
- onScrollRef?: (el: HTMLDivElement) => void
- classes?: {
- root?: string
- header?: string
- container?: string
- }
-}
-
-function StickyAddButton(props: { children: JSX.Element }) {
- const [stuck, setStuck] = createSignal(false)
- let button: HTMLDivElement | undefined
-
- createEffect(() => {
- const node = button
- if (!node) return
-
- const scroll = node.parentElement
- if (!scroll) return
-
- const handler = () => {
- const rect = node.getBoundingClientRect()
- const scrollRect = scroll.getBoundingClientRect()
- setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
- }
-
- scroll.addEventListener("scroll", handler, { passive: true })
- const observer = new ResizeObserver(handler)
- observer.observe(scroll)
- handler()
- onCleanup(() => {
- scroll.removeEventListener("scroll", handler)
- observer.disconnect()
- })
- })
-
- return (
- <div
- ref={button}
- class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
- classList={{ "border-l": stuck() }}
- >
- {props.children}
- </div>
- )
-}
-
-function SessionReviewTab(props: SessionReviewTabProps) {
- let scroll: HTMLDivElement | undefined
- let frame: number | undefined
- let pending: { x: number; y: number } | undefined
-
- const sdk = useSDK()
-
- const readFile = async (path: string) => {
- return sdk.client.file
- .read({ path })
- .then((x) => x.data)
- .catch(() => undefined)
- }
-
- const restoreScroll = () => {
- const el = scroll
- if (!el) return
-
- const s = props.view().scroll("review")
- if (!s) return
-
- if (el.scrollTop !== s.y) el.scrollTop = s.y
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
-
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- }
- if (frame !== undefined) return
-
- frame = requestAnimationFrame(() => {
- frame = undefined
-
- const next = pending
- pending = undefined
- if (!next) return
-
- props.view().setScroll("review", next)
- })
- }
-
- createEffect(
- on(
- () => props.diffs().length,
- () => {
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- onCleanup(() => {
- if (frame === undefined) return
- cancelAnimationFrame(frame)
- })
-
- return (
- <SessionReview
- title={props.title}
- empty={props.empty}
- scrollRef={(el) => {
- scroll = el
- props.onScrollRef?.(el)
- restoreScroll()
- }}
- onScroll={handleScroll}
- onDiffRendered={() => requestAnimationFrame(restoreScroll)}
- open={props.view().review.open()}
- onOpenChange={props.view().review.setOpen}
- classes={{
- root: props.classes?.root ?? "pb-40",
- header: props.classes?.header ?? "px-6",
- container: props.classes?.container ?? "px-6",
- }}
- diffs={props.diffs()}
- diffStyle={props.diffStyle}
- onDiffStyleChange={props.onDiffStyleChange}
- onViewFile={props.onViewFile}
- focusedFile={props.focusedFile}
- readFile={readFile}
- onLineComment={props.onLineComment}
- comments={props.comments}
- focusedComment={props.focusedComment}
- onFocusedCommentChange={props.onFocusedCommentChange}
- />
- )
-}
-
export default function Page() {
const layout = useLayout()
const local = useLocal()
@@ -820,8 +650,6 @@ export default function Page() {
const scrollGestureWindowMs = 250
- let touchGesture: number | undefined
-
const markScrollGesture = (target?: EventTarget | null) => {
const root = scroller
if (!root) return
@@ -963,396 +791,6 @@ export default function Page() {
})
}
- const sessionCommands = createMemo(() => [
- {
- id: "session.new",
- title: language.t("command.session.new"),
- category: language.t("command.category.session"),
- keybind: "mod+shift+s",
- slash: "new",
- onSelect: () => navigate(`/${params.dir}/session`),
- },
- ])
-
- const fileCommands = createMemo(() => [
- {
- id: "file.open",
- title: language.t("command.file.open"),
- description: language.t("palette.search.placeholder"),
- category: language.t("command.category.file"),
- keybind: "mod+p",
- slash: "open",
- onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
- },
- {
- id: "tab.close",
- title: language.t("command.tab.close"),
- category: language.t("command.category.file"),
- keybind: "mod+w",
- disabled: !tabs().active(),
- onSelect: () => {
- const active = tabs().active()
- if (!active) return
- tabs().close(active)
- },
- },
- ])
-
- const contextCommands = createMemo(() => [
- {
- id: "context.addSelection",
- title: language.t("command.context.addSelection"),
- description: language.t("command.context.addSelection.description"),
- category: language.t("command.category.context"),
- keybind: "mod+shift+l",
- disabled: (() => {
- const active = tabs().active()
- if (!active) return true
- const path = file.pathFromTab(active)
- if (!path) return true
- return file.selectedLines(path) == null
- })(),
- onSelect: () => {
- const active = tabs().active()
- if (!active) return
- const path = file.pathFromTab(active)
- if (!path) return
-
- const range = file.selectedLines(path)
- if (!range) {
- showToast({
- title: language.t("toast.context.noLineSelection.title"),
- description: language.t("toast.context.noLineSelection.description"),
- })
- return
- }
-
- addSelectionToContext(path, selectionFromLines(range))
- },
- },
- ])
-
- const viewCommands = createMemo(() => [
- {
- id: "terminal.toggle",
- title: language.t("command.terminal.toggle"),
- description: "",
- category: language.t("command.category.view"),
- keybind: "ctrl+`",
- slash: "terminal",
- onSelect: () => view().terminal.toggle(),
- },
- {
- id: "review.toggle",
- title: language.t("command.review.toggle"),
- description: "",
- category: language.t("command.category.view"),
- keybind: "mod+shift+r",
- onSelect: () => view().reviewPanel.toggle(),
- },
- {
- id: "fileTree.toggle",
- title: language.t("command.fileTree.toggle"),
- description: "",
- category: language.t("command.category.view"),
- onSelect: () => {
- const opening = !layout.fileTree.opened()
- if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
- layout.fileTree.toggle()
- },
- },
- {
- id: "terminal.new",
- title: language.t("command.terminal.new"),
- description: language.t("command.terminal.new.description"),
- category: language.t("command.category.terminal"),
- keybind: "ctrl+alt+t",
- onSelect: () => {
- if (terminal.all().length > 0) terminal.new()
- view().terminal.open()
- },
- },
- {
- id: "steps.toggle",
- title: language.t("command.steps.toggle"),
- description: language.t("command.steps.toggle.description"),
- category: language.t("command.category.view"),
- keybind: "mod+e",
- slash: "steps",
- disabled: !params.id,
- onSelect: () => {
- const msg = activeMessage()
- if (!msg) return
- setStore("expanded", msg.id, (open: boolean | undefined) => !open)
- },
- },
- ])
-
- const messageCommands = createMemo(() => [
- {
- id: "message.previous",
- title: language.t("command.message.previous"),
- description: language.t("command.message.previous.description"),
- category: language.t("command.category.session"),
- keybind: "mod+arrowup",
- disabled: !params.id,
- onSelect: () => navigateMessageByOffset(-1),
- },
- {
- id: "message.next",
- title: language.t("command.message.next"),
- description: language.t("command.message.next.description"),
- category: language.t("command.category.session"),
- keybind: "mod+arrowdown",
- disabled: !params.id,
- onSelect: () => navigateMessageByOffset(1),
- },
- ])
-
- const agentCommands = createMemo(() => [
- {
- id: "model.choose",
- title: language.t("command.model.choose"),
- description: language.t("command.model.choose.description"),
- category: language.t("command.category.model"),
- keybind: "mod+'",
- slash: "model",
- onSelect: () => dialog.show(() => <DialogSelectModel />),
- },
- {
- id: "mcp.toggle",
- title: language.t("command.mcp.toggle"),
- description: language.t("command.mcp.toggle.description"),
- category: language.t("command.category.mcp"),
- keybind: "mod+;",
- slash: "mcp",
- onSelect: () => dialog.show(() => <DialogSelectMcp />),
- },
- {
- id: "agent.cycle",
- title: language.t("command.agent.cycle"),
- description: language.t("command.agent.cycle.description"),
- category: language.t("command.category.agent"),
- keybind: "mod+.",
- slash: "agent",
- onSelect: () => local.agent.move(1),
- },
- {
- id: "agent.cycle.reverse",
- title: language.t("command.agent.cycle.reverse"),
- description: language.t("command.agent.cycle.reverse.description"),
- category: language.t("command.category.agent"),
- keybind: "shift+mod+.",
- onSelect: () => local.agent.move(-1),
- },
- {
- id: "model.variant.cycle",
- title: language.t("command.model.variant.cycle"),
- description: language.t("command.model.variant.cycle.description"),
- category: language.t("command.category.model"),
- keybind: "shift+mod+d",
- onSelect: () => {
- local.model.variant.cycle()
- },
- },
- ])
-
- const permissionCommands = createMemo(() => [
- {
- id: "permissions.autoaccept",
- title:
- params.id && permission.isAutoAccepting(params.id, sdk.directory)
- ? language.t("command.permissions.autoaccept.disable")
- : language.t("command.permissions.autoaccept.enable"),
- category: language.t("command.category.permissions"),
- keybind: "mod+shift+a",
- disabled: !params.id || !permission.permissionsEnabled(),
- onSelect: () => {
- const sessionID = params.id
- if (!sessionID) return
- permission.toggleAutoAccept(sessionID, sdk.directory)
- showToast({
- title: permission.isAutoAccepting(sessionID, sdk.directory)
- ? language.t("toast.permissions.autoaccept.on.title")
- : language.t("toast.permissions.autoaccept.off.title"),
- description: permission.isAutoAccepting(sessionID, sdk.directory)
- ? language.t("toast.permissions.autoaccept.on.description")
- : language.t("toast.permissions.autoaccept.off.description"),
- })
- },
- },
- ])
-
- const sessionActionCommands = createMemo(() => [
- {
- id: "session.undo",
- title: language.t("command.session.undo"),
- description: language.t("command.session.undo.description"),
- category: language.t("command.category.session"),
- slash: "undo",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- if (status()?.type !== "idle") {
- await sdk.client.session.abort({ sessionID }).catch(() => {})
- }
- const revert = info()?.revert?.messageID
- const message = findLast(userMessages(), (x) => !revert || x.id < revert)
- if (!message) return
- await sdk.client.session.revert({ sessionID, messageID: message.id })
- const parts = sync.data.part[message.id]
- if (parts) {
- const restored = extractPromptFromParts(parts, { directory: sdk.directory })
- prompt.set(restored)
- }
- const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
- setActiveMessage(priorMessage)
- },
- },
- {
- id: "session.redo",
- title: language.t("command.session.redo"),
- description: language.t("command.session.redo.description"),
- category: language.t("command.category.session"),
- slash: "redo",
- disabled: !params.id || !info()?.revert?.messageID,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- const revertMessageID = info()?.revert?.messageID
- if (!revertMessageID) return
- const nextMessage = userMessages().find((x) => x.id > revertMessageID)
- if (!nextMessage) {
- await sdk.client.session.unrevert({ sessionID })
- prompt.reset()
- const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
- setActiveMessage(lastMsg)
- return
- }
- await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
- const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
- setActiveMessage(priorMsg)
- },
- },
- {
- id: "session.compact",
- title: language.t("command.session.compact"),
- description: language.t("command.session.compact.description"),
- category: language.t("command.category.session"),
- slash: "compact",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- const model = local.model.current()
- if (!model) {
- showToast({
- title: language.t("toast.model.none.title"),
- description: language.t("toast.model.none.description"),
- })
- return
- }
- await sdk.client.session.summarize({
- sessionID,
- modelID: model.id,
- providerID: model.provider.id,
- })
- },
- },
- {
- id: "session.fork",
- title: language.t("command.session.fork"),
- description: language.t("command.session.fork.description"),
- category: language.t("command.category.session"),
- slash: "fork",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: () => dialog.show(() => <DialogFork />),
- },
- ])
-
- const shareCommands = createMemo(() => {
- if (sync.data.config.share === "disabled") return []
- return [
- {
- id: "session.share",
- title: language.t("command.session.share"),
- description: language.t("command.session.share.description"),
- category: language.t("command.category.session"),
- slash: "share",
- disabled: !params.id || !!info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .share({ sessionID: params.id })
- .then((res) => {
- navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
- showToast({
- title: language.t("toast.session.share.copyFailed.title"),
- variant: "error",
- }),
- )
- })
- .then(() =>
- showToast({
- title: language.t("toast.session.share.success.title"),
- description: language.t("toast.session.share.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.share.failed.title"),
- description: language.t("toast.session.share.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- {
- id: "session.unshare",
- title: language.t("command.session.unshare"),
- description: language.t("command.session.unshare.description"),
- category: language.t("command.category.session"),
- slash: "unshare",
- disabled: !params.id || !info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .unshare({ sessionID: params.id })
- .then(() =>
- showToast({
- title: language.t("toast.session.unshare.success.title"),
- description: language.t("toast.session.unshare.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.unshare.failed.title"),
- description: language.t("toast.session.unshare.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- ]
- })
-
- command.register("session", () =>
- combineCommandSections([
- sessionCommands(),
- fileCommands(),
- contextCommands(),
- viewCommands(),
- messageCommands(),
- agentCommands(),
- permissionCommands(),
- sessionActionCommands(),
- shareCommands(),
- ]),
- )
-
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
@@ -1465,6 +903,34 @@ export default function Page() {
setFileTreeTab("all")
}
+ useSessionCommands({
+ command,
+ dialog,
+ file,
+ language,
+ local,
+ permission,
+ prompt,
+ sdk,
+ sync,
+ terminal,
+ layout,
+ params,
+ navigate,
+ tabs,
+ view,
+ info,
+ status,
+ userMessages,
+ visibleUserMessages,
+ activeMessage,
+ showAllFiles,
+ navigateMessageByOffset,
+ setExpanded: (id, fn) => setStore("expanded", id, fn),
+ setActiveMessage,
+ addSelectionToContext,
+ })
+
const openReviewFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
@@ -1781,11 +1247,6 @@ export default function Page() {
overflowAnchor: "dynamic",
})
- const clearMessageHash = () => {
- if (!window.location.hash) return
- window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
- }
-
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
const scrollSpy = createScrollSpy({
@@ -1975,162 +1436,23 @@ export default function Page() {
},
)
- const updateHash = (id: string) => {
- window.history.replaceState(null, "", `#${anchor(id)}`)
- }
-
- createEffect(
- on(sessionKey, (key) => {
- if (!params.id) return
- const messageID = layout.pendingMessage.consume(key)
- if (!messageID) return
- setUi("pendingMessage", messageID)
- }),
- )
-
- const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
- const root = scroller
- if (!root) return false
-
- const a = el.getBoundingClientRect()
- const b = root.getBoundingClientRect()
- const top = a.top - b.top + root.scrollTop
- root.scrollTo({ top, behavior })
- return true
- }
-
- const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
- setActiveMessage(message)
-
- const msgs = visibleUserMessages()
- const index = msgs.findIndex((m) => m.id === message.id)
- if (index !== -1 && index < store.turnStart) {
- setStore("turnStart", index)
- scheduleTurnBackfill()
-
- requestAnimationFrame(() => {
- const el = document.getElementById(anchor(message.id))
- if (!el) {
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- scrollToElement(next, behavior)
- })
- return
- }
- scrollToElement(el, behavior)
- })
-
- updateHash(message.id)
- return
- }
-
- const el = document.getElementById(anchor(message.id))
- if (!el) {
- updateHash(message.id)
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- return
- }
- if (scrollToElement(el, behavior)) {
- updateHash(message.id)
- return
- }
-
- requestAnimationFrame(() => {
- const next = document.getElementById(anchor(message.id))
- if (!next) return
- if (!scrollToElement(next, behavior)) return
- })
- updateHash(message.id)
- }
-
- const applyHash = (behavior: ScrollBehavior) => {
- const hash = window.location.hash.slice(1)
- if (!hash) {
- autoScroll.forceScrollToBottom()
-
- const el = scroller
- if (el) scheduleScrollState(el)
- return
- }
-
- const match = hash.match(/^message-(.+)$/)
- if (match) {
- autoScroll.pause()
- const msg = visibleUserMessages().find((m) => m.id === match[1])
- if (msg) {
- scrollToMessage(msg, behavior)
- return
- }
-
- // If we have a message hash but the message isn't loaded/rendered yet,
- // don't fall back to "bottom". We'll retry once messages arrive.
- return
- }
-
- const target = document.getElementById(hash)
- if (target) {
- autoScroll.pause()
- scrollToElement(target, behavior)
- return
- }
-
- autoScroll.forceScrollToBottom()
-
- const el = scroller
- if (el) scheduleScrollState(el)
- }
-
- createEffect(() => {
- const sessionID = params.id
- const ready = messagesReady()
- if (!sessionID || !ready) return
-
- requestAnimationFrame(() => {
- applyHash("auto")
- })
- })
-
- // Retry message navigation once the target message is actually loaded.
- createEffect(() => {
- const sessionID = params.id
- const ready = messagesReady()
- if (!sessionID || !ready) return
-
- // dependencies
- visibleUserMessages().length
- store.turnStart
-
- const targetId =
- ui.pendingMessage ??
- (() => {
- const hash = window.location.hash.slice(1)
- const match = hash.match(/^message-(.+)$/)
- if (!match) return undefined
- return match[1]
- })()
- if (!targetId) return
- if (store.messageId === targetId) return
-
- const msg = visibleUserMessages().find((m) => m.id === targetId)
- if (!msg) return
- if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined)
- autoScroll.pause()
- requestAnimationFrame(() => scrollToMessage(msg, "auto"))
- })
-
- createEffect(() => {
- const sessionID = params.id
- const ready = messagesReady()
- if (!sessionID || !ready) return
-
- const handler = () => requestAnimationFrame(() => applyHash("auto"))
- window.addEventListener("hashchange", handler)
- onCleanup(() => window.removeEventListener("hashchange", handler))
+ const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
+ sessionKey,
+ sessionID: () => params.id,
+ messagesReady,
+ visibleUserMessages,
+ turnStart: () => store.turnStart,
+ currentMessageId: () => store.messageId,
+ pendingMessage: () => ui.pendingMessage,
+ setPendingMessage: (value) => setUi("pendingMessage", value),
+ setActiveMessage,
+ setTurnStart: (value) => setStore("turnStart", value),
+ scheduleTurnBackfill,
+ autoScroll,
+ scroller: () => scroller,
+ anchor,
+ scheduleScrollState,
+ consumePendingMessage: layout.pendingMessage.consume,
})
createEffect(() => {
@@ -2158,20 +1480,17 @@ export default function Page() {
if (!terminal.ready()) return
language.locale()
- const label = (pty: LocalPTY) => {
- const title = pty.title
- const number = pty.titleNumber
- const match = title.match(/^Terminal (\d+)$/)
- const parsed = match ? Number(match[1]) : undefined
- const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
-
- if (title && !isDefaultTitle) return title
- if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
- if (title) return title
- return language.t("terminal.title")
- }
-
- touch(handoff.terminal, params.dir!, terminal.all().map(label))
+ touch(
+ handoff.terminal,
+ params.dir!,
+ terminal.all().map((pty) =>
+ terminalTabLabel({
+ title: pty.title,
+ titleNumber: pty.titleNumber,
+ t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+ }),
+ ),
+ )
})
createEffect(() => {
@@ -2200,34 +1519,14 @@ export default function Page() {
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
- {/* Mobile tab bar */}
- <Show when={!isDesktop() && params.id}>
- <Tabs class="h-auto">
- <Tabs.List>
- <Tabs.Trigger
- value="session"
- class="w-1/2"
- classes={{ button: "w-full" }}
- onClick={() => setStore("mobileTab", "session")}
- >
- {language.t("session.tab.session")}
- </Tabs.Trigger>
- <Tabs.Trigger
- value="changes"
- class="w-1/2 !border-r-0"
- classes={{ button: "w-full" }}
- onClick={() => setStore("mobileTab", "changes")}
- >
- <Switch>
- <Match when={hasReview()}>
- {language.t("session.review.filesChanged", { count: reviewCount() })}
- </Match>
- <Match when={true}>{language.t("session.review.change.other")}</Match>
- </Switch>
- </Tabs.Trigger>
- </Tabs.List>
- </Tabs>
- </Show>
+ <SessionMobileTabs
+ open={!isDesktop() && !!params.id}
+ hasReview={hasReview()}
+ reviewCount={reviewCount()}
+ onSession={() => setStore("mobileTab", "session")}
+ onChanges={() => setStore("mobileTab", "changes")}
+ t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
+ />
{/* Session panel */}
<div
@@ -2245,347 +1544,79 @@ export default function Page() {
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
- <Show
- when={!mobileChanges()}
- fallback={
- <div class="relative h-full overflow-hidden">
- {reviewContent({
- diffStyle: "unified",
- classes: {
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- },
- loadingClass: "px-4 py-4 text-text-weak",
- emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
- })}
- </div>
- }
- >
- <div class="relative w-full h-full min-w-0">
- <div
- class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
- classList={{
- "opacity-100 translate-y-0 scale-100": ui.scroll.overflow && !ui.scroll.bottom,
- "opacity-0 translate-y-2 scale-95 pointer-events-none":
- !ui.scroll.overflow || ui.scroll.bottom,
- }}
- >
- <button
- class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
- onClick={resumeScroll}
- >
- <Icon name="arrow-down-to-line" />
- </button>
- </div>
- <div
- ref={setScrollRef}
- onWheel={(e) => {
- const root = e.currentTarget
- const target = e.target instanceof Element ? e.target : undefined
- const nested = target?.closest("[data-scrollable]")
- if (!nested || nested === root) {
- markScrollGesture(root)
- return
- }
-
- if (!(nested instanceof HTMLElement)) {
- markScrollGesture(root)
- return
- }
-
- const max = nested.scrollHeight - nested.clientHeight
- if (max <= 1) {
- markScrollGesture(root)
- return
- }
-
- const delta =
- e.deltaMode === 1
- ? e.deltaY * 40
- : e.deltaMode === 2
- ? e.deltaY * root.clientHeight
- : e.deltaY
- if (!delta) return
-
- if (delta < 0) {
- if (nested.scrollTop + delta <= 0) markScrollGesture(root)
- return
- }
-
- const remaining = max - nested.scrollTop
- if (delta > remaining) markScrollGesture(root)
- }}
- onTouchStart={(e) => {
- touchGesture = e.touches[0]?.clientY
- }}
- onTouchMove={(e) => {
- const next = e.touches[0]?.clientY
- const prev = touchGesture
- touchGesture = next
- if (next === undefined || prev === undefined) return
-
- const delta = prev - next
- if (!delta) return
-
- const root = e.currentTarget
- const target = e.target instanceof Element ? e.target : undefined
- const nested = target?.closest("[data-scrollable]")
- if (!nested || nested === root) {
- markScrollGesture(root)
- return
- }
-
- if (!(nested instanceof HTMLElement)) {
- markScrollGesture(root)
- return
- }
-
- const max = nested.scrollHeight - nested.clientHeight
- if (max <= 1) {
- markScrollGesture(root)
- return
- }
-
- if (delta < 0) {
- if (nested.scrollTop + delta <= 0) markScrollGesture(root)
- return
- }
-
- const remaining = max - nested.scrollTop
- if (delta > remaining) markScrollGesture(root)
- }}
- onTouchEnd={() => {
- touchGesture = undefined
- }}
- onTouchCancel={() => {
- touchGesture = undefined
- }}
- onPointerDown={(e) => {
- if (e.target !== e.currentTarget) return
- markScrollGesture(e.currentTarget)
- }}
- onScroll={(e) => {
- scheduleScrollState(e.currentTarget)
- if (!hasScrollGesture()) return
- autoScroll.handleScroll()
- markScrollGesture(e.currentTarget)
- if (isDesktop()) scrollSpy.onScroll()
- }}
- onClick={autoScroll.handleInteraction}
- class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
- style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
- >
- <Show when={info()?.title || info()?.parentID}>
- <div
- classList={{
- "sticky top-0 z-30 bg-background-stronger": true,
- "w-full": true,
- "px-4 md:px-6": true,
- "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(),
- }}
- >
- <div class="h-10 w-full flex items-center justify-between gap-2">
- <div class="flex items-center gap-1 min-w-0 flex-1">
- <Show when={info()?.parentID}>
- <IconButton
- tabIndex={-1}
- icon="arrow-left"
- variant="ghost"
- onClick={() => {
- navigate(`/${params.dir}/session/${info()?.parentID}`)
- }}
- aria-label={language.t("common.goBack")}
- />
- </Show>
- <Show when={info()?.title || title.editing}>
- <Show
- when={title.editing}
- fallback={
- <h1
- class="text-16-medium text-text-strong truncate min-w-0"
- onDblClick={openTitleEditor}
- >
- {info()?.title}
- </h1>
- }
- >
- <InlineInput
- ref={(el) => {
- titleRef = el
- }}
- value={title.draft}
- disabled={title.saving}
- class="text-16-medium text-text-strong grow-1 min-w-0"
- onInput={(event) => setTitle("draft", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- if (event.key === "Enter") {
- event.preventDefault()
- void saveTitleEditor()
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeTitleEditor()
- }
- }}
- onBlur={() => closeTitleEditor()}
- />
- </Show>
- </Show>
- </div>
- <Show when={params.id}>
- {(id) => (
- <div class="shrink-0 flex items-center">
- <DropdownMenu
- open={title.menuOpen}
- onOpenChange={(open) => setTitle("menuOpen", open)}
- >
- <Tooltip value={language.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- aria-label={language.t("common.moreOptions")}
- />
- </Tooltip>
- <DropdownMenu.Portal>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!title.pendingRename) return
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- }}
- >
- <DropdownMenu.Item
- onSelect={() => {
- setTitle({ pendingRename: true, menuOpen: false })
- }}
- >
- <DropdownMenu.ItemLabel>
- {language.t("common.rename")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
- <DropdownMenu.ItemLabel>
- {language.t("common.archive")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
- >
- <DropdownMenu.ItemLabel>
- {language.t("common.delete")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </div>
- )}
- </Show>
- </div>
- </div>
- </Show>
-
- <div
- ref={(el) => {
- content = el
- autoScroll.contentRef(el)
-
- const root = scroller
- if (root) scheduleScrollState(root)
- }}
- role="log"
- class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
- classList={{
- "w-full": true,
- "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(),
- "mt-0.5": centered(),
- "mt-0": !centered(),
- }}
- >
- <Show when={store.turnStart > 0}>
- <div class="w-full flex justify-center">
- <Button
- variant="ghost"
- size="large"
- class="text-12-medium opacity-50"
- onClick={() => setStore("turnStart", 0)}
- >
- {language.t("session.messages.renderEarlier")}
- </Button>
- </div>
- </Show>
- <Show when={historyMore()}>
- <div class="w-full flex justify-center">
- <Button
- variant="ghost"
- size="large"
- class="text-12-medium opacity-50"
- disabled={historyLoading()}
- onClick={() => {
- const id = params.id
- if (!id) return
- setStore("turnStart", 0)
- sync.session.history.loadMore(id)
- }}
- >
- {historyLoading()
- ? language.t("session.messages.loadingEarlier")
- : language.t("session.messages.loadEarlier")}
- </Button>
- </div>
- </Show>
- <For each={renderedUserMessages()}>
- {(message) => {
- if (import.meta.env.DEV) {
- onMount(() => {
- const id = params.id
- if (!id) return
- navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
- })
- }
-
- return (
- <div
- id={anchor(message.id)}
- data-message-id={message.id}
- ref={(el) => {
- scrollSpy.register(el, message.id)
- onCleanup(() => scrollSpy.unregister(message.id))
- }}
- classList={{
- "min-w-0 w-full max-w-full": true,
- "md:max-w-200 3xl:max-w-[1200px]": centered(),
- }}
- >
- <SessionTurn
- sessionID={params.id!}
- messageID={message.id}
- lastUserMessageID={lastUserMessage()?.id}
- stepsExpanded={store.expanded[message.id] ?? false}
- onStepsExpandedToggle={() =>
- setStore("expanded", message.id, (open: boolean | undefined) => !open)
- }
- classes={{
- root: "min-w-0 w-full relative",
- content: "flex flex-col justify-between !overflow-visible",
- container: "w-full px-4 md:px-6",
- }}
- />
- </div>
- )
- }}
- </For>
- </div>
- </div>
- </div>
- </Show>
+ <MessageTimeline
+ mobileChanges={mobileChanges()}
+ mobileFallback={reviewContent({
+ diffStyle: "unified",
+ classes: {
+ root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+ header: "px-4",
+ container: "px-4",
+ },
+ loadingClass: "px-4 py-4 text-text-weak",
+ emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
+ })}
+ scroll={ui.scroll}
+ onResumeScroll={resumeScroll}
+ setScrollRef={setScrollRef}
+ onScheduleScrollState={scheduleScrollState}
+ onAutoScrollHandleScroll={autoScroll.handleScroll}
+ onMarkScrollGesture={markScrollGesture}
+ hasScrollGesture={hasScrollGesture}
+ isDesktop={isDesktop()}
+ onScrollSpyScroll={scrollSpy.onScroll}
+ onAutoScrollInteraction={autoScroll.handleInteraction}
+ showHeader={!!(info()?.title || info()?.parentID)}
+ centered={centered()}
+ title={info()?.title}
+ parentID={info()?.parentID}
+ openTitleEditor={openTitleEditor}
+ closeTitleEditor={closeTitleEditor}
+ saveTitleEditor={saveTitleEditor}
+ titleRef={(el) => {
+ titleRef = el
+ }}
+ titleState={title}
+ onTitleDraft={(value) => setTitle("draft", value)}
+ onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
+ onTitlePendingRename={(value) => setTitle("pendingRename", value)}
+ onNavigateParent={() => {
+ navigate(`/${params.dir}/session/${info()?.parentID}`)
+ }}
+ sessionID={params.id!}
+ onArchiveSession={(sessionID) => void archiveSession(sessionID)}
+ onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
+ t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
+ setContentRef={(el) => {
+ content = el
+ autoScroll.contentRef(el)
+
+ const root = scroller
+ if (root) scheduleScrollState(root)
+ }}
+ turnStart={store.turnStart}
+ onRenderEarlier={() => setStore("turnStart", 0)}
+ historyMore={historyMore()}
+ historyLoading={historyLoading()}
+ onLoadEarlier={() => {
+ const id = params.id
+ if (!id) return
+ setStore("turnStart", 0)
+ sync.session.history.loadMore(id)
+ }}
+ renderedUserMessages={renderedUserMessages()}
+ anchor={anchor}
+ onRegisterMessage={scrollSpy.register}
+ onUnregisterMessage={scrollSpy.unregister}
+ onFirstTurnMount={() => {
+ const id = params.id
+ if (!id) return
+ navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
+ }}
+ lastUserMessageID={lastUserMessage()?.id}
+ expanded={store.expanded}
+ onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
+ />
</Show>
</Match>
<Match when={true}>
@@ -2610,115 +1641,27 @@ export default function Page() {
</Switch>
</div>
- {/* Prompt input */}
- <div
- ref={(el) => (promptDock = el)}
- class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
- >
- <div
- classList={{
- "w-full px-4 pointer-events-auto": true,
- "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(),
- }}
- >
- <Show when={questionRequest()} keyed>
- {(req) => {
- const count = req.questions.length
- const subtitle =
- count === 0
- ? ""
- : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
- return (
- <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
- <BasicTool
- icon="bubble-5"
- locked
- defaultOpen
- trigger={{
- title: language.t("ui.tool.questions"),
- subtitle,
- }}
- />
- <QuestionDock request={req} />
- </div>
- )
- }}
- </Show>
-
- <Show when={permRequest()} keyed>
- {(perm) => (
- <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
- <BasicTool
- icon="checklist"
- locked
- defaultOpen
- trigger={{
- title: language.t("notification.permission.title"),
- subtitle:
- perm.permission === "doom_loop"
- ? language.t("settings.permissions.tool.doom_loop.title")
- : perm.permission,
- }}
- >
- <Show when={perm.patterns.length > 0}>
- <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
- <For each={perm.patterns}>
- {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
- </For>
- </div>
- </Show>
- <Show when={perm.permission === "doom_loop"}>
- <div class="text-12-regular text-text-weak pb-2 px-3">
- {language.t("settings.permissions.tool.doom_loop.description")}
- </div>
- </Show>
- </BasicTool>
- <div data-component="permission-prompt">
- <div data-slot="permission-actions">
- <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={ui.responding}>
- {language.t("ui.permission.deny")}
- </Button>
- <Button
- variant="secondary"
- size="small"
- onClick={() => decide("always")}
- disabled={ui.responding}
- >
- {language.t("ui.permission.allowAlways")}
- </Button>
- <Button variant="primary" size="small" onClick={() => decide("once")} disabled={ui.responding}>
- {language.t("ui.permission.allowOnce")}
- </Button>
- </div>
- </div>
- </div>
- )}
- </Show>
-
- <Show when={!blocked()}>
- <Show
- when={prompt.ready()}
- fallback={
- <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
- {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
- </div>
- }
- >
- <PromptInput
- ref={(el) => {
- inputRef = el
- }}
- newSessionWorktree={newSessionWorktree()}
- onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
- onSubmit={() => {
- comments.clear()
- resumeScroll()
- }}
- />
- </Show>
- </Show>
- </div>
- </div>
+ <SessionPromptDock
+ centered={centered()}
+ questionRequest={questionRequest}
+ permissionRequest={permRequest}
+ blocked={blocked()}
+ promptReady={prompt.ready()}
+ handoffPrompt={handoff.session.get(sessionKey())?.prompt}
+ t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
+ responding={ui.responding}
+ onDecide={decide}
+ inputRef={(el) => {
+ inputRef = el
+ }}
+ newSessionWorktree={newSessionWorktree()}
+ onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+ onSubmit={() => {
+ comments.clear()
+ resumeScroll()
+ }}
+ setPromptDockRef={(el) => (promptDock = el)}
+ />
<Show when={isDesktop() && view().reviewPanel.opened()}>
<ResizeHandle
@@ -2731,925 +1674,62 @@ export default function Page() {
</Show>
</div>
- {/* Desktop side panel - hidden on mobile */}
- <Show when={isDesktop() && view().reviewPanel.opened()}>
- <aside
- id="review-panel"
- aria-label={language.t("session.panel.reviewAndFiles")}
- class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
- >
- <div class="flex-1 min-w-0 h-full">
- <Show
- when={layout.fileTree.opened() && fileTreeTab() === "changes"}
- fallback={
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <Tabs value={activeTab()} onChange={openTab}>
- <div class="sticky top-0 shrink-0 flex">
- <Tabs.List
- ref={(el: HTMLDivElement) => {
- let scrollTimeout: number | undefined
- let prevScrollWidth = el.scrollWidth
- let prevContextOpen = contextOpen()
-
- const handler = () => {
- if (scrollTimeout !== undefined) clearTimeout(scrollTimeout)
- scrollTimeout = window.setTimeout(() => {
- const scrollWidth = el.scrollWidth
- const clientWidth = el.clientWidth
- const currentContextOpen = contextOpen()
-
- // Only scroll when a tab is added (width increased), not on removal
- if (scrollWidth > prevScrollWidth) {
- if (!prevContextOpen && currentContextOpen) {
- // Context tab was opened, scroll to first
- el.scrollTo({
- left: 0,
- behavior: "smooth",
- })
- } else if (scrollWidth > clientWidth) {
- // File tab was added, scroll to rightmost
- el.scrollTo({
- left: scrollWidth - clientWidth,
- behavior: "smooth",
- })
- }
- }
- // When width decreases (tab removed), don't scroll - let browser handle it naturally
-
- prevScrollWidth = scrollWidth
- prevContextOpen = currentContextOpen
- }, 0)
- }
-
- const wheelHandler = (e: WheelEvent) => {
- // Enable horizontal scrolling with mouse wheel
- if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
- el.scrollLeft += e.deltaY > 0 ? 50 : -50
- e.preventDefault()
- }
- }
-
- el.addEventListener("wheel", wheelHandler, { passive: false })
-
- const observer = new MutationObserver(handler)
- observer.observe(el, { childList: true })
-
- onCleanup(() => {
- el.removeEventListener("wheel", wheelHandler)
- observer.disconnect()
- if (scrollTimeout !== undefined) clearTimeout(scrollTimeout)
- })
- }}
- >
- <Show when={reviewTab()}>
- <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
- <div class="flex items-center gap-1.5">
- <div>{language.t("session.tab.review")}</div>
- <Show when={hasReview()}>
- <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
- {reviewCount()}
- </div>
- </Show>
- </div>
- </Tabs.Trigger>
- </Show>
- <Show when={contextOpen()}>
- <Tabs.Trigger
- value="context"
- closeButton={
- <Tooltip value={language.t("common.closeTab")} placement="bottom">
- <IconButton
- icon="close-small"
- variant="ghost"
- class="h-5 w-5"
- onClick={() => tabs().close("context")}
- aria-label={language.t("common.closeTab")}
- />
- </Tooltip>
- }
- hideCloseButton
- onMiddleClick={() => tabs().close("context")}
- >
- <div class="flex items-center gap-2">
- <SessionContextUsage variant="indicator" />
- <div>{language.t("session.tab.context")}</div>
- </div>
- </Tabs.Trigger>
- </Show>
- <SortableProvider ids={openedTabs()}>
- <For each={openedTabs()}>
- {(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}
- </For>
- </SortableProvider>
- <StickyAddButton>
- <TooltipKeybind
- title={language.t("command.file.open")}
- keybind={command.keybind("file.open")}
- class="flex items-center"
- >
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={() =>
- dialog.show(() => <DialogSelectFile mode="files" onOpenFile={() => showAllFiles()} />)
- }
- aria-label={language.t("command.file.open")}
- />
- </TooltipKeybind>
- </StickyAddButton>
- </Tabs.List>
- </div>
-
- <Show when={reviewTab()}>
- <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={activeTab() === "review"}>{reviewPanel()}</Show>
- </Tabs.Content>
- </Show>
-
- <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={activeTab() === "empty"}>
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="text-14-regular text-text-weak max-w-56">
- {language.t("session.files.selectToOpen")}
- </div>
- </div>
- </div>
- </Show>
- </Tabs.Content>
-
- <Show when={contextOpen()}>
- <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={activeTab() === "context"}>
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <SessionContextTab
- messages={messages}
- visibleUserMessages={visibleUserMessages}
- view={view}
- info={info}
- />
- </div>
- </Show>
- </Tabs.Content>
- </Show>
-
- <Show when={activeFileTab()} keyed>
- {(tab) => {
- let scroll: HTMLDivElement | undefined
- let scrollFrame: number | undefined
- let pending: { x: number; y: number } | undefined
- let codeScroll: HTMLElement[] = []
-
- const path = createMemo(() => file.pathFromTab(tab))
- const state = createMemo(() => {
- const p = path()
- if (!p) return
- return file.get(p)
- })
- const contents = createMemo(() => state()?.content?.content ?? "")
- const cacheKey = createMemo(() => checksum(contents()))
- const isImage = createMemo(() => {
- const c = state()?.content
- return (
- c?.encoding === "base64" &&
- c?.mimeType?.startsWith("image/") &&
- c?.mimeType !== "image/svg+xml"
- )
- })
- const isSvg = createMemo(() => {
- const c = state()?.content
- return c?.mimeType === "image/svg+xml"
- })
- const isBinary = createMemo(() => state()?.content?.type === "binary")
- const svgContent = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding !== "base64") return c.content
- return decode64(c.content)
- })
-
- const svgDecodeFailed = createMemo(() => {
- if (!isSvg()) return false
- const c = state()?.content
- if (!c) return false
- if (c.encoding !== "base64") return false
- return svgContent() === undefined
- })
-
- const svgToast = { shown: false }
- createEffect(() => {
- if (!svgDecodeFailed()) return
- if (svgToast.shown) return
- svgToast.shown = true
- showToast({
- variant: "error",
- title: language.t("toast.file.loadFailed.title"),
- description: "Invalid base64 content.",
- })
- })
- const svgPreviewUrl = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
- })
- const imageDataUrl = createMemo(() => {
- if (!isImage()) return
- const c = state()?.content
- return `data:${c?.mimeType};base64,${c?.content}`
- })
- const selectedLines = createMemo(() => {
- const p = path()
- if (!p) return null
- if (file.ready()) return file.selectedLines(p) ?? null
- return handoff.session.get(sessionKey())?.files[p] ?? null
- })
-
- let wrap: HTMLDivElement | undefined
-
- const fileComments = createMemo(() => {
- const p = path()
- if (!p) return []
- return comments.list(p)
- })
-
- const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
-
- const [note, setNote] = createStore({
- openedComment: null as string | null,
- commenting: null as SelectedLineRange | null,
- draft: "",
- positions: {} as Record<string, number>,
- draftTop: undefined as number | undefined,
- })
-
- const openedComment = () => note.openedComment
- const setOpenedComment = (
- value:
- | typeof note.openedComment
- | ((value: typeof note.openedComment) => typeof note.openedComment),
- ) => setNote("openedComment", value)
-
- const commenting = () => note.commenting
- const setCommenting = (
- value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting),
- ) => setNote("commenting", value)
-
- const draft = () => note.draft
- const setDraft = (
- value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft),
- ) => setNote("draft", value)
-
- const positions = () => note.positions
- const setPositions = (
- value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions),
- ) => setNote("positions", value)
-
- const draftTop = () => note.draftTop
- const setDraftTop = (
- value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop),
- ) => setNote("draftTop", value)
-
- const commentLabel = (range: SelectedLineRange) => {
- const start = Math.min(range.start, range.end)
- const end = Math.max(range.start, range.end)
- if (start === end) return `line ${start}`
- return `lines ${start}-${end}`
- }
-
- const getRoot = () => {
- const el = wrap
- if (!el) return
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const root = host.shadowRoot
- if (!root) return
-
- return root
- }
-
- const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
- const line = Math.max(range.start, range.end)
- const node = root.querySelector(`[data-line="${line}"]`)
- if (!(node instanceof HTMLElement)) return
- return node
- }
-
- const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
- const wrapperRect = wrapper.getBoundingClientRect()
- const rect = marker.getBoundingClientRect()
- return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
- }
-
- const updateComments = () => {
- const el = wrap
- const root = getRoot()
- if (!el || !root) {
- setPositions({})
- setDraftTop(undefined)
- return
- }
-
- const next: Record<string, number> = {}
- for (const comment of fileComments()) {
- const marker = findMarker(root, comment.selection)
- if (!marker) continue
- next[comment.id] = markerTop(el, marker)
- }
-
- setPositions(next)
-
- const range = commenting()
- if (!range) {
- setDraftTop(undefined)
- return
- }
-
- const marker = findMarker(root, range)
- if (!marker) {
- setDraftTop(undefined)
- return
- }
-
- setDraftTop(markerTop(el, marker))
- }
-
- const scheduleComments = () => {
- requestAnimationFrame(updateComments)
- }
-
- createEffect(() => {
- fileComments()
- scheduleComments()
- })
-
- createEffect(() => {
- const range = commenting()
- scheduleComments()
- if (!range) return
- setDraft("")
- })
-
- createEffect(() => {
- const focus = comments.focus()
- const p = path()
- if (!focus || !p) return
- if (focus.file !== p) return
- if (activeTab() !== tab) return
-
- const target = fileComments().find((comment) => comment.id === focus.id)
- if (!target) return
-
- setOpenedComment(target.id)
- setCommenting(null)
- file.setSelectedLines(p, target.selection)
- requestAnimationFrame(() => comments.clearFocus())
- })
-
- const renderCode = (source: string, wrapperClass: string) => (
- <div
- ref={(el) => {
- wrap = el
- scheduleComments()
- }}
- class={`relative overflow-hidden ${wrapperClass}`}
- >
- <Dynamic
- component={codeComponent}
- file={{
- name: path() ?? "",
- contents: source,
- cacheKey: cacheKey(),
- }}
- enableLineSelection
- selectedLines={selectedLines()}
- commentedLines={commentedLines()}
- onRendered={() => {
- requestAnimationFrame(restoreScroll)
- requestAnimationFrame(scheduleComments)
- }}
- onLineSelected={(range: SelectedLineRange | null) => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- if (!range) setCommenting(null)
- }}
- onLineSelectionEnd={(range: SelectedLineRange | null) => {
- if (!range) {
- setCommenting(null)
- return
- }
-
- setOpenedComment(null)
- setCommenting(range)
- }}
- overflow="scroll"
- class="select-text"
- />
- <For each={fileComments()}>
- {(comment) => (
- <LineCommentView
- id={comment.id}
- top={positions()[comment.id]}
- open={openedComment() === comment.id}
- comment={comment.comment}
- selection={commentLabel(comment.selection)}
- onMouseEnter={() => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, comment.selection)
- }}
- onClick={() => {
- const p = path()
- if (!p) return
- setCommenting(null)
- setOpenedComment((current) => (current === comment.id ? null : comment.id))
- file.setSelectedLines(p, comment.selection)
- }}
- />
- )}
- </For>
- <Show when={commenting()}>
- {(range) => (
- <Show when={draftTop() !== undefined}>
- <LineCommentEditor
- top={draftTop()}
- value={draft()}
- selection={commentLabel(range())}
- onInput={(value) => setDraft(value)}
- onCancel={() => setCommenting(null)}
- onSubmit={(value) => {
- const p = path()
- if (!p) return
- addCommentToContext({
- file: p,
- selection: range(),
- comment: value,
- origin: "file",
- })
- setCommenting(null)
- }}
- onPopoverFocusOut={(e: FocusEvent) => {
- const current = e.currentTarget as HTMLDivElement
- const target = e.relatedTarget
- if (target instanceof Node && current.contains(target)) return
-
- setTimeout(() => {
- if (!document.activeElement || !current.contains(document.activeElement)) {
- setCommenting(null)
- }
- }, 0)
- }}
- />
- </Show>
- )}
- </Show>
- </div>
- )
-
- const getCodeScroll = () => {
- const el = scroll
- if (!el) return []
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return []
-
- const root = host.shadowRoot
- if (!root) return []
-
- return Array.from(root.querySelectorAll("[data-code]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
- )
- }
-
- const queueScrollUpdate = (next: { x: number; y: number }) => {
- pending = next
- if (scrollFrame !== undefined) return
-
- scrollFrame = requestAnimationFrame(() => {
- scrollFrame = undefined
-
- const next = pending
- pending = undefined
- if (!next) return
-
- view().setScroll(tab, next)
- })
- }
-
- const handleCodeScroll = (event: Event) => {
- const el = scroll
- if (!el) return
-
- const target = event.currentTarget
- if (!(target instanceof HTMLElement)) return
-
- queueScrollUpdate({
- x: target.scrollLeft,
- y: el.scrollTop,
- })
- }
-
- const syncCodeScroll = () => {
- const next = getCodeScroll()
- if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
-
- for (const item of codeScroll) {
- item.removeEventListener("scroll", handleCodeScroll)
- }
-
- codeScroll = next
-
- for (const item of codeScroll) {
- item.addEventListener("scroll", handleCodeScroll)
- }
- }
-
- const restoreScroll = () => {
- const el = scroll
- if (!el) return
-
- const s = view()?.scroll(tab)
- if (!s) return
-
- syncCodeScroll()
-
- if (codeScroll.length > 0) {
- for (const item of codeScroll) {
- if (item.scrollLeft !== s.x) item.scrollLeft = s.x
- }
- }
-
- if (el.scrollTop !== s.y) el.scrollTop = s.y
-
- if (codeScroll.length > 0) return
-
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
-
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- if (codeScroll.length === 0) syncCodeScroll()
-
- queueScrollUpdate({
- x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- })
- }
-
- createEffect(
- on(
- () => state()?.loaded,
- (loaded) => {
- if (!loaded) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => file.ready(),
- (ready) => {
- if (!ready) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => tabs().active() === tab,
- (active) => {
- if (!active) return
- if (!state()?.loaded) return
- requestAnimationFrame(restoreScroll)
- },
- ),
- )
-
- onCleanup(() => {
- for (const item of codeScroll) {
- item.removeEventListener("scroll", handleCodeScroll)
- }
-
- if (scrollFrame === undefined) return
- cancelAnimationFrame(scrollFrame)
- })
-
- return (
- <Tabs.Content
- value={tab}
- class="mt-3 relative"
- ref={(el: HTMLDivElement) => {
- scroll = el
- restoreScroll()
- }}
- onScroll={handleScroll}
- >
- <Switch>
- <Match when={state()?.loaded && isImage()}>
- <div class="px-6 py-4 pb-40">
- <img
- src={imageDataUrl()}
- alt={path()}
- class="max-w-full"
- onLoad={() => requestAnimationFrame(restoreScroll)}
- />
- </div>
- </Match>
- <Match when={state()?.loaded && isSvg()}>
- <div class="flex flex-col gap-4 px-6 py-4">
- {renderCode(svgContent() ?? "", "")}
- <Show when={svgPreviewUrl()}>
- <div class="flex justify-center pb-40">
- <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
- </div>
- </Show>
- </div>
- </Match>
- <Match when={state()?.loaded && isBinary()}>
- <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="flex flex-col gap-2 max-w-md">
- <div class="text-14-semibold text-text-strong truncate">
- {path()?.split("/").pop()}
- </div>
- <div class="text-14-regular text-text-weak">
- {language.t("session.files.binaryContent")}
- </div>
- </div>
- </div>
- </Match>
- <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
- <Match when={state()?.loading}>
- <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
- </Match>
- <Match when={state()?.error}>
- {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
- </Match>
- </Switch>
- </Tabs.Content>
- )
- }}
- </Show>
- </Tabs>
- <DragOverlay>
- <Show when={store.activeDraggable}>
- {(tab) => {
- const path = createMemo(() => file.pathFromTab(tab()))
- return (
- <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
- <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
- </div>
- )
- }}
- </Show>
- </DragOverlay>
- </DragDropProvider>
- }
- >
- {reviewPanel()}
- </Show>
- </div>
-
- <Show when={layout.fileTree.opened()}>
- <div
- id="file-tree-panel"
- class="relative shrink-0 h-full"
- style={{ width: `${layout.fileTree.width()}px` }}
- >
- <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
- <Tabs
- variant="pill"
- value={fileTreeTab()}
- onChange={setFileTreeTabValue}
- class="h-full"
- data-scope="filetree"
- >
- <Tabs.List>
- <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
- {reviewCount()}{" "}
- {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
- </Tabs.Trigger>
- <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
- {language.t("session.files.all")}
- </Tabs.Trigger>
- </Tabs.List>
- <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
- <Switch>
- <Match when={hasReview()}>
- <Show
- when={diffsReady()}
- fallback={
- <div class="px-2 py-2 text-12-regular text-text-weak">
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
- </div>
- }
- >
- <FileTree
- path=""
- allowed={diffFiles()}
- kinds={kinds()}
- draggable={false}
- active={tree.activeDiff}
- onFileClick={(node) => focusReviewDiff(node.path)}
- />
- </Show>
- </Match>
- <Match when={true}>
- <div class="mt-8 text-center text-12-regular text-text-weak">
- {language.t("session.review.noChanges")}
- </div>
- </Match>
- </Switch>
- </Tabs.Content>
- <Tabs.Content value="all" class="bg-background-base px-3 py-0">
- <FileTree
- path=""
- modified={diffFiles()}
- kinds={kinds()}
- onFileClick={(node) => openTab(file.tab(node.path))}
- />
- </Tabs.Content>
- </Tabs>
- </div>
- <ResizeHandle
- direction="horizontal"
- edge="start"
- size={layout.fileTree.width()}
- min={200}
- max={480}
- collapseThreshold={160}
- onResize={layout.fileTree.resize}
- onCollapse={layout.fileTree.close}
- />
- </div>
- </Show>
- </aside>
- </Show>
+ <SessionSidePanel
+ open={isDesktop() && view().reviewPanel.opened()}
+ language={language}
+ layout={layout}
+ command={command}
+ dialog={dialog}
+ file={file}
+ comments={comments}
+ sync={sync}
+ hasReview={hasReview()}
+ reviewCount={reviewCount()}
+ reviewTab={reviewTab()}
+ contextOpen={contextOpen}
+ openedTabs={openedTabs}
+ activeTab={activeTab}
+ activeFileTab={activeFileTab}
+ tabs={tabs}
+ openTab={openTab}
+ showAllFiles={showAllFiles}
+ reviewPanel={reviewPanel}
+ messages={messages as () => unknown[]}
+ visibleUserMessages={visibleUserMessages as () => unknown[]}
+ view={view}
+ info={info as () => unknown}
+ handoffFiles={() => handoff.session.get(sessionKey())?.files}
+ codeComponent={codeComponent}
+ addCommentToContext={addCommentToContext}
+ activeDraggable={() => store.activeDraggable}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ fileTreeTab={fileTreeTab}
+ setFileTreeTabValue={setFileTreeTabValue}
+ diffsReady={diffsReady()}
+ diffFiles={diffFiles()}
+ kinds={kinds()}
+ activeDiff={tree.activeDiff}
+ focusReviewDiff={focusReviewDiff}
+ />
</div>
- <Show when={isDesktop() && view().terminal.opened()}>
- <div
- id="terminal-panel"
- role="region"
- aria-label={language.t("terminal.title")}
- class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
- style={{ height: `${layout.terminal.height()}px` }}
- >
- <ResizeHandle
- direction="vertical"
- size={layout.terminal.height()}
- min={100}
- max={window.innerHeight * 0.6}
- collapseThreshold={50}
- onResize={layout.terminal.resize}
- onCollapse={view().terminal.close}
- />
- <Show
- when={terminal.ready()}
- fallback={
- <div class="flex flex-col h-full pointer-events-none">
- <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
- <For each={handoff.terminal.get(params.dir!) ?? []}>
- {(title) => (
- <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
- {title}
- </div>
- )}
- </For>
- <div class="flex-1" />
- <div class="text-text-weak pr-2">
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
- </div>
- </div>
- <div class="flex-1 flex items-center justify-center text-text-weak">
- {language.t("terminal.loading")}
- </div>
- </div>
- }
- >
- <DragDropProvider
- onDragStart={handleTerminalDragStart}
- onDragEnd={handleTerminalDragEnd}
- onDragOver={handleTerminalDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <div class="flex flex-col h-full">
- <Tabs
- variant="alt"
- value={terminal.active()}
- onChange={(id) => {
- // Only switch tabs if not in the middle of starting edit mode
- terminal.open(id)
- }}
- class="!h-auto !flex-none"
- >
- <Tabs.List class="h-10">
- <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
- <For each={terminal.all()}>
- {(pty) => (
- <SortableTerminalTab
- terminal={pty}
- onClose={() => {
- view().terminal.close()
- setUi("autoCreated", false)
- }}
- />
- )}
- </For>
- </SortableProvider>
- <div class="h-full flex items-center justify-center">
- <TooltipKeybind
- title={language.t("command.terminal.new")}
- keybind={command.keybind("terminal.new")}
- class="flex items-center"
- >
- <IconButton
- icon="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={terminal.new}
- aria-label={language.t("command.terminal.new")}
- />
- </TooltipKeybind>
- </div>
- </Tabs.List>
- </Tabs>
- <div class="flex-1 min-h-0 relative">
- <For each={terminal.all()}>
- {(pty) => (
- <div
- id={`terminal-wrapper-${pty.id}`}
- class="absolute inset-0"
- style={{
- display: terminal.active() === pty.id ? "block" : "none",
- }}
- >
- <Show when={pty.id} keyed>
- <Terminal
- pty={pty}
- onCleanup={terminal.update}
- onConnectError={() => terminal.clone(pty.id)}
- />
- </Show>
- </div>
- )}
- </For>
- </div>
- </div>
- <DragOverlay>
- <Show when={store.activeTerminalDraggable}>
- {(draggedId) => {
- const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
- return (
- <Show when={pty()}>
- {(t) => (
- <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
- {(() => {
- const title = t().title
- const number = t().titleNumber
- const match = title.match(/^Terminal (\d+)$/)
- const parsed = match ? Number(match[1]) : undefined
- const isDefaultTitle =
- Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
-
- if (title && !isDefaultTitle) return title
- if (Number.isFinite(number) && number > 0)
- return language.t("terminal.title.numbered", { number })
- if (title) return title
- return language.t("terminal.title")
- })()}
- </div>
- )}
- </Show>
- )
- }}
- </Show>
- </DragOverlay>
- </DragDropProvider>
- </Show>
- </div>
- </Show>
+ <TerminalPanel
+ open={isDesktop() && view().terminal.opened()}
+ height={layout.terminal.height()}
+ resize={layout.terminal.resize}
+ close={view().terminal.close}
+ terminal={terminal}
+ language={language}
+ command={command}
+ handoff={() => handoff.terminal.get(params.dir!) ?? []}
+ activeTerminalDraggable={() => store.activeTerminalDraggable}
+ handleTerminalDragStart={handleTerminalDragStart}
+ handleTerminalDragOver={handleTerminalDragOver}
+ handleTerminalDragEnd={handleTerminalDragEnd}
+ onCloseTab={() => setUi("autoCreated", false)}
+ />
</div>
)
}
diff --git a/packages/app/src/pages/session/file-tab-scroll.test.ts b/packages/app/src/pages/session/file-tab-scroll.test.ts
new file mode 100644
index 000000000..89e0dcc8f
--- /dev/null
+++ b/packages/app/src/pages/session/file-tab-scroll.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test"
+import { nextTabListScrollLeft } from "./file-tab-scroll"
+
+describe("nextTabListScrollLeft", () => {
+ test("does not scroll when width shrinks", () => {
+ const left = nextTabListScrollLeft({
+ prevScrollWidth: 500,
+ scrollWidth: 420,
+ clientWidth: 300,
+ prevContextOpen: false,
+ contextOpen: false,
+ })
+
+ expect(left).toBeUndefined()
+ })
+
+ test("scrolls to start when context tab opens", () => {
+ const left = nextTabListScrollLeft({
+ prevScrollWidth: 400,
+ scrollWidth: 500,
+ clientWidth: 320,
+ prevContextOpen: false,
+ contextOpen: true,
+ })
+
+ expect(left).toBe(0)
+ })
+
+ test("scrolls to right edge for new file tabs", () => {
+ const left = nextTabListScrollLeft({
+ prevScrollWidth: 500,
+ scrollWidth: 780,
+ clientWidth: 300,
+ prevContextOpen: true,
+ contextOpen: true,
+ })
+
+ expect(left).toBe(480)
+ })
+})
diff --git a/packages/app/src/pages/session/file-tab-scroll.ts b/packages/app/src/pages/session/file-tab-scroll.ts
new file mode 100644
index 000000000..b69188d40
--- /dev/null
+++ b/packages/app/src/pages/session/file-tab-scroll.ts
@@ -0,0 +1,67 @@
+type Input = {
+ prevScrollWidth: number
+ scrollWidth: number
+ clientWidth: number
+ prevContextOpen: boolean
+ contextOpen: boolean
+}
+
+export const nextTabListScrollLeft = (input: Input) => {
+ if (input.scrollWidth <= input.prevScrollWidth) return
+ if (!input.prevContextOpen && input.contextOpen) return 0
+ if (input.scrollWidth <= input.clientWidth) return
+ return input.scrollWidth - input.clientWidth
+}
+
+export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => {
+ let frame: number | undefined
+ let prevScrollWidth = input.el.scrollWidth
+ let prevContextOpen = input.contextOpen()
+
+ const update = () => {
+ const scrollWidth = input.el.scrollWidth
+ const clientWidth = input.el.clientWidth
+ const contextOpen = input.contextOpen()
+ const left = nextTabListScrollLeft({
+ prevScrollWidth,
+ scrollWidth,
+ clientWidth,
+ prevContextOpen,
+ contextOpen,
+ })
+
+ if (left !== undefined) {
+ input.el.scrollTo({
+ left,
+ behavior: "smooth",
+ })
+ }
+
+ prevScrollWidth = scrollWidth
+ prevContextOpen = contextOpen
+ }
+
+ const schedule = () => {
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+ update()
+ })
+ }
+
+ const onWheel = (e: WheelEvent) => {
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
+ input.el.scrollLeft += e.deltaY > 0 ? 50 : -50
+ e.preventDefault()
+ }
+
+ input.el.addEventListener("wheel", onWheel, { passive: false })
+ const observer = new MutationObserver(schedule)
+ observer.observe(input.el, { childList: true })
+
+ return () => {
+ input.el.removeEventListener("wheel", onWheel)
+ observer.disconnect()
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ }
+}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
new file mode 100644
index 000000000..0c8281a66
--- /dev/null
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -0,0 +1,516 @@
+import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Dynamic } from "solid-js/web"
+import { checksum } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
+import { showToast } from "@opencode-ai/ui/toast"
+import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useLayout } from "@/context/layout"
+import { useFile, type SelectedLineRange } from "@/context/file"
+import { useComments } from "@/context/comments"
+import { useLanguage } from "@/context/language"
+
+export function FileTabContent(props: {
+ tab: string
+ activeTab: () => string
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
+ file: ReturnType<typeof useFile>
+ comments: ReturnType<typeof useComments>
+ language: ReturnType<typeof useLanguage>
+ codeComponent: NonNullable<ValidComponent>
+ addCommentToContext: (input: {
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+ }) => void
+}) {
+ let scroll: HTMLDivElement | undefined
+ let scrollFrame: number | undefined
+ let pending: { x: number; y: number } | undefined
+ let codeScroll: HTMLElement[] = []
+
+ const path = createMemo(() => props.file.pathFromTab(props.tab))
+ const state = createMemo(() => {
+ const p = path()
+ if (!p) return
+ return props.file.get(p)
+ })
+ const contents = createMemo(() => state()?.content?.content ?? "")
+ const cacheKey = createMemo(() => checksum(contents()))
+ const isImage = createMemo(() => {
+ const c = state()?.content
+ return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
+ })
+ const isSvg = createMemo(() => {
+ const c = state()?.content
+ return c?.mimeType === "image/svg+xml"
+ })
+ const isBinary = createMemo(() => state()?.content?.type === "binary")
+ const svgContent = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding !== "base64") return c.content
+ return decode64(c.content)
+ })
+
+ const svgDecodeFailed = createMemo(() => {
+ if (!isSvg()) return false
+ const c = state()?.content
+ if (!c) return false
+ if (c.encoding !== "base64") return false
+ return svgContent() === undefined
+ })
+
+ const svgToast = { shown: false }
+ createEffect(() => {
+ if (!svgDecodeFailed()) return
+ if (svgToast.shown) return
+ svgToast.shown = true
+ showToast({
+ variant: "error",
+ title: props.language.t("toast.file.loadFailed.title"),
+ description: "Invalid base64 content.",
+ })
+ })
+ const svgPreviewUrl = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
+ })
+ const imageDataUrl = createMemo(() => {
+ if (!isImage()) return
+ const c = state()?.content
+ return `data:${c?.mimeType};base64,${c?.content}`
+ })
+ const selectedLines = createMemo(() => {
+ const p = path()
+ if (!p) return null
+ if (props.file.ready()) return props.file.selectedLines(p) ?? null
+ return props.handoffFiles()?.[p] ?? null
+ })
+
+ let wrap: HTMLDivElement | undefined
+
+ const fileComments = createMemo(() => {
+ const p = path()
+ if (!p) return []
+ return props.comments.list(p)
+ })
+
+ const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
+
+ const [note, setNote] = createStore({
+ openedComment: null as string | null,
+ commenting: null as SelectedLineRange | null,
+ draft: "",
+ positions: {} as Record<string, number>,
+ draftTop: undefined as number | undefined,
+ })
+
+ const openedComment = () => note.openedComment
+ const setOpenedComment = (
+ value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment),
+ ) => setNote("openedComment", value)
+
+ const commenting = () => note.commenting
+ const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) =>
+ setNote("commenting", value)
+
+ const draft = () => note.draft
+ const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) =>
+ setNote("draft", value)
+
+ const positions = () => note.positions
+ const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) =>
+ setNote("positions", value)
+
+ const draftTop = () => note.draftTop
+ const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) =>
+ setNote("draftTop", value)
+
+ const commentLabel = (range: SelectedLineRange) => {
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (start === end) return `line ${start}`
+ return `lines ${start}-${end}`
+ }
+
+ const getRoot = () => {
+ const el = wrap
+ if (!el) return
+
+ const host = el.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return
+
+ const root = host.shadowRoot
+ if (!root) return
+
+ return root
+ }
+
+ const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
+ const line = Math.max(range.start, range.end)
+ const node = root.querySelector(`[data-line="${line}"]`)
+ if (!(node instanceof HTMLElement)) return
+ return node
+ }
+
+ const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
+ const wrapperRect = wrapper.getBoundingClientRect()
+ const rect = marker.getBoundingClientRect()
+ return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
+ }
+
+ const updateComments = () => {
+ const el = wrap
+ const root = getRoot()
+ if (!el || !root) {
+ setPositions({})
+ setDraftTop(undefined)
+ return
+ }
+
+ const next: Record<string, number> = {}
+ for (const comment of fileComments()) {
+ const marker = findMarker(root, comment.selection)
+ if (!marker) continue
+ next[comment.id] = markerTop(el, marker)
+ }
+
+ setPositions(next)
+
+ const range = commenting()
+ if (!range) {
+ setDraftTop(undefined)
+ return
+ }
+
+ const marker = findMarker(root, range)
+ if (!marker) {
+ setDraftTop(undefined)
+ return
+ }
+
+ setDraftTop(markerTop(el, marker))
+ }
+
+ const scheduleComments = () => {
+ requestAnimationFrame(updateComments)
+ }
+
+ createEffect(() => {
+ fileComments()
+ scheduleComments()
+ })
+
+ createEffect(() => {
+ const range = commenting()
+ scheduleComments()
+ if (!range) return
+ setDraft("")
+ })
+
+ createEffect(() => {
+ const focus = props.comments.focus()
+ const p = path()
+ if (!focus || !p) return
+ if (focus.file !== p) return
+ if (props.activeTab() !== props.tab) return
+
+ const target = fileComments().find((comment) => comment.id === focus.id)
+ if (!target) return
+
+ setOpenedComment(target.id)
+ setCommenting(null)
+ props.file.setSelectedLines(p, target.selection)
+ requestAnimationFrame(() => props.comments.clearFocus())
+ })
+
+ const getCodeScroll = () => {
+ const el = scroll
+ if (!el) return []
+
+ const host = el.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return []
+
+ const root = host.shadowRoot
+ if (!root) return []
+
+ return Array.from(root.querySelectorAll("[data-code]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
+ )
+ }
+
+ const queueScrollUpdate = (next: { x: number; y: number }) => {
+ pending = next
+ if (scrollFrame !== undefined) return
+
+ scrollFrame = requestAnimationFrame(() => {
+ scrollFrame = undefined
+
+ const out = pending
+ pending = undefined
+ if (!out) return
+
+ props.view().setScroll(props.tab, out)
+ })
+ }
+
+ const handleCodeScroll = (event: Event) => {
+ const el = scroll
+ if (!el) return
+
+ const target = event.currentTarget
+ if (!(target instanceof HTMLElement)) return
+
+ queueScrollUpdate({
+ x: target.scrollLeft,
+ y: el.scrollTop,
+ })
+ }
+
+ const syncCodeScroll = () => {
+ const next = getCodeScroll()
+ if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
+
+ for (const item of codeScroll) {
+ item.removeEventListener("scroll", handleCodeScroll)
+ }
+
+ codeScroll = next
+
+ for (const item of codeScroll) {
+ item.addEventListener("scroll", handleCodeScroll)
+ }
+ }
+
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
+
+ const s = props.view()?.scroll(props.tab)
+ if (!s) return
+
+ syncCodeScroll()
+
+ if (codeScroll.length > 0) {
+ for (const item of codeScroll) {
+ if (item.scrollLeft !== s.x) item.scrollLeft = s.x
+ }
+ }
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (codeScroll.length > 0) return
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ if (codeScroll.length === 0) syncCodeScroll()
+
+ queueScrollUpdate({
+ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ })
+ }
+
+ createEffect(
+ on(
+ () => state()?.loaded,
+ (loaded) => {
+ if (!loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => props.file.ready(),
+ (ready) => {
+ if (!ready) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => props.tabs().active() === props.tab,
+ (active) => {
+ if (!active) return
+ if (!state()?.loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ for (const item of codeScroll) {
+ item.removeEventListener("scroll", handleCodeScroll)
+ }
+
+ if (scrollFrame === undefined) return
+ cancelAnimationFrame(scrollFrame)
+ })
+
+ const renderCode = (source: string, wrapperClass: string) => (
+ <div
+ ref={(el) => {
+ wrap = el
+ scheduleComments()
+ }}
+ class={`relative overflow-hidden ${wrapperClass}`}
+ >
+ <Dynamic
+ component={props.codeComponent}
+ file={{
+ name: path() ?? "",
+ contents: source,
+ cacheKey: cacheKey(),
+ }}
+ enableLineSelection
+ selectedLines={selectedLines()}
+ commentedLines={commentedLines()}
+ onRendered={() => {
+ requestAnimationFrame(restoreScroll)
+ requestAnimationFrame(scheduleComments)
+ }}
+ onLineSelected={(range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ props.file.setSelectedLines(p, range)
+ if (!range) setCommenting(null)
+ }}
+ onLineSelectionEnd={(range: SelectedLineRange | null) => {
+ if (!range) {
+ setCommenting(null)
+ return
+ }
+
+ setOpenedComment(null)
+ setCommenting(range)
+ }}
+ overflow="scroll"
+ class="select-text"
+ />
+ <For each={fileComments()}>
+ {(comment) => (
+ <LineCommentView
+ id={comment.id}
+ top={positions()[comment.id]}
+ open={openedComment() === comment.id}
+ comment={comment.comment}
+ selection={commentLabel(comment.selection)}
+ onMouseEnter={() => {
+ const p = path()
+ if (!p) return
+ props.file.setSelectedLines(p, comment.selection)
+ }}
+ onClick={() => {
+ const p = path()
+ if (!p) return
+ setCommenting(null)
+ setOpenedComment((current) => (current === comment.id ? null : comment.id))
+ props.file.setSelectedLines(p, comment.selection)
+ }}
+ />
+ )}
+ </For>
+ <Show when={commenting()}>
+ {(range) => (
+ <Show when={draftTop() !== undefined}>
+ <LineCommentEditor
+ top={draftTop()}
+ value={draft()}
+ selection={commentLabel(range())}
+ onInput={(value) => setDraft(value)}
+ onCancel={() => setCommenting(null)}
+ onSubmit={(value) => {
+ const p = path()
+ if (!p) return
+ props.addCommentToContext({
+ file: p,
+ selection: range(),
+ comment: value,
+ origin: "file",
+ })
+ setCommenting(null)
+ }}
+ onPopoverFocusOut={(e: FocusEvent) => {
+ const current = e.currentTarget as HTMLDivElement
+ const target = e.relatedTarget
+ if (target instanceof Node && current.contains(target)) return
+
+ setTimeout(() => {
+ if (!document.activeElement || !current.contains(document.activeElement)) {
+ setCommenting(null)
+ }
+ }, 0)
+ }}
+ />
+ </Show>
+ )}
+ </Show>
+ </div>
+ )
+
+ return (
+ <Tabs.Content
+ value={props.tab}
+ class="mt-3 relative"
+ ref={(el: HTMLDivElement) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
+ <Switch>
+ <Match when={state()?.loaded && isImage()}>
+ <div class="px-6 py-4 pb-40">
+ <img
+ src={imageDataUrl()}
+ alt={path()}
+ class="max-w-full"
+ onLoad={() => requestAnimationFrame(restoreScroll)}
+ />
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isSvg()}>
+ <div class="flex flex-col gap-4 px-6 py-4">
+ {renderCode(svgContent() ?? "", "")}
+ <Show when={svgPreviewUrl()}>
+ <div class="flex justify-center pb-40">
+ <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+ </div>
+ </Show>
+ </div>
+ </Match>
+ <Match when={state()?.loaded && isBinary()}>
+ <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+ <Mark class="w-14 opacity-10" />
+ <div class="flex flex-col gap-2 max-w-md">
+ <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
+ <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
+ </div>
+ </div>
+ </Match>
+ <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+ <Match when={state()?.loading}>
+ <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
+ </Match>
+ <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
+ </Switch>
+ </Tabs.Content>
+ )
+}
diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts
new file mode 100644
index 000000000..b2af4bb83
--- /dev/null
+++ b/packages/app/src/pages/session/message-gesture.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test"
+import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture"
+
+describe("normalizeWheelDelta", () => {
+ test("converts line mode to px", () => {
+ expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120)
+ })
+
+ test("converts page mode to container height", () => {
+ expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600)
+ })
+
+ test("keeps pixel mode unchanged", () => {
+ expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16)
+ })
+})
+
+describe("shouldMarkBoundaryGesture", () => {
+ test("marks when nested scroller cannot scroll", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: 20,
+ scrollTop: 0,
+ scrollHeight: 300,
+ clientHeight: 300,
+ }),
+ ).toBe(true)
+ })
+
+ test("marks when scrolling beyond top boundary", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: -40,
+ scrollTop: 10,
+ scrollHeight: 1000,
+ clientHeight: 400,
+ }),
+ ).toBe(true)
+ })
+
+ test("marks when scrolling beyond bottom boundary", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: 50,
+ scrollTop: 580,
+ scrollHeight: 1000,
+ clientHeight: 400,
+ }),
+ ).toBe(true)
+ })
+
+ test("does not mark when nested scroller can consume movement", () => {
+ expect(
+ shouldMarkBoundaryGesture({
+ delta: 20,
+ scrollTop: 200,
+ scrollHeight: 1000,
+ clientHeight: 400,
+ }),
+ ).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts
new file mode 100644
index 000000000..731cb1bde
--- /dev/null
+++ b/packages/app/src/pages/session/message-gesture.ts
@@ -0,0 +1,21 @@
+export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => {
+ if (input.deltaMode === 1) return input.deltaY * 40
+ if (input.deltaMode === 2) return input.deltaY * input.rootHeight
+ return input.deltaY
+}
+
+export const shouldMarkBoundaryGesture = (input: {
+ delta: number
+ scrollTop: number
+ scrollHeight: number
+ clientHeight: number
+}) => {
+ const max = input.scrollHeight - input.clientHeight
+ if (max <= 1) return true
+ if (!input.delta) return false
+
+ if (input.delta < 0) return input.scrollTop + input.delta <= 0
+
+ const remaining = max - input.scrollTop
+ return input.delta > remaining
+}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
new file mode 100644
index 000000000..f536c7061
--- /dev/null
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -0,0 +1,348 @@
+import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+
+export function MessageTimeline(props: {
+ mobileChanges: boolean
+ mobileFallback: JSX.Element
+ scroll: { overflow: boolean; bottom: boolean }
+ onResumeScroll: () => void
+ setScrollRef: (el: HTMLDivElement | undefined) => void
+ onScheduleScrollState: (el: HTMLDivElement) => void
+ onAutoScrollHandleScroll: () => void
+ onMarkScrollGesture: (target?: EventTarget | null) => void
+ hasScrollGesture: () => boolean
+ isDesktop: boolean
+ onScrollSpyScroll: () => void
+ onAutoScrollInteraction: (event: MouseEvent) => void
+ showHeader: boolean
+ centered: boolean
+ title?: string
+ parentID?: string
+ openTitleEditor: () => void
+ closeTitleEditor: () => void
+ saveTitleEditor: () => void | Promise<void>
+ titleRef: (el: HTMLInputElement) => void
+ titleState: {
+ draft: string
+ editing: boolean
+ saving: boolean
+ menuOpen: boolean
+ pendingRename: boolean
+ }
+ onTitleDraft: (value: string) => void
+ onTitleMenuOpen: (open: boolean) => void
+ onTitlePendingRename: (value: boolean) => void
+ onNavigateParent: () => void
+ sessionID: string
+ onArchiveSession: (sessionID: string) => void
+ onDeleteSession: (sessionID: string) => void
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+ setContentRef: (el: HTMLDivElement) => void
+ turnStart: number
+ onRenderEarlier: () => void
+ historyMore: boolean
+ historyLoading: boolean
+ onLoadEarlier: () => void
+ renderedUserMessages: UserMessage[]
+ anchor: (id: string) => string
+ onRegisterMessage: (el: HTMLDivElement, id: string) => void
+ onUnregisterMessage: (id: string) => void
+ onFirstTurnMount?: () => void
+ lastUserMessageID?: string
+ expanded: Record<string, boolean>
+ onToggleExpanded: (id: string) => void
+}) {
+ let touchGesture: number | undefined
+
+ return (
+ <Show
+ when={!props.mobileChanges}
+ fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
+ >
+ <div class="relative w-full h-full min-w-0">
+ <div
+ class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
+ classList={{
+ "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
+ "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
+ }}
+ >
+ <button
+ class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
+ onClick={props.onResumeScroll}
+ >
+ <Icon name="arrow-down-to-line" />
+ </button>
+ </div>
+ <div
+ ref={props.setScrollRef}
+ onWheel={(e) => {
+ const root = e.currentTarget
+ const target = e.target instanceof Element ? e.target : undefined
+ const nested = target?.closest("[data-scrollable]")
+ if (!nested || nested === root) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ if (!(nested instanceof HTMLElement)) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ const delta = normalizeWheelDelta({
+ deltaY: e.deltaY,
+ deltaMode: e.deltaMode,
+ rootHeight: root.clientHeight,
+ })
+ if (!delta) return
+
+ if (
+ shouldMarkBoundaryGesture({
+ delta,
+ scrollTop: nested.scrollTop,
+ scrollHeight: nested.scrollHeight,
+ clientHeight: nested.clientHeight,
+ })
+ ) {
+ props.onMarkScrollGesture(root)
+ }
+ }}
+ onTouchStart={(e) => {
+ touchGesture = e.touches[0]?.clientY
+ }}
+ onTouchMove={(e) => {
+ const next = e.touches[0]?.clientY
+ const prev = touchGesture
+ touchGesture = next
+ if (next === undefined || prev === undefined) return
+
+ const delta = prev - next
+ if (!delta) return
+
+ const root = e.currentTarget
+ const target = e.target instanceof Element ? e.target : undefined
+ const nested = target?.closest("[data-scrollable]")
+ if (!nested || nested === root) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ if (!(nested instanceof HTMLElement)) {
+ props.onMarkScrollGesture(root)
+ return
+ }
+
+ if (
+ shouldMarkBoundaryGesture({
+ delta,
+ scrollTop: nested.scrollTop,
+ scrollHeight: nested.scrollHeight,
+ clientHeight: nested.clientHeight,
+ })
+ ) {
+ props.onMarkScrollGesture(root)
+ }
+ }}
+ onTouchEnd={() => {
+ touchGesture = undefined
+ }}
+ onTouchCancel={() => {
+ touchGesture = undefined
+ }}
+ onPointerDown={(e) => {
+ if (e.target !== e.currentTarget) return
+ props.onMarkScrollGesture(e.currentTarget)
+ }}
+ onScroll={(e) => {
+ props.onScheduleScrollState(e.currentTarget)
+ if (!props.hasScrollGesture()) return
+ props.onAutoScrollHandleScroll()
+ props.onMarkScrollGesture(e.currentTarget)
+ if (props.isDesktop) props.onScrollSpyScroll()
+ }}
+ onClick={props.onAutoScrollInteraction}
+ class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
+ style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
+ >
+ <Show when={props.showHeader}>
+ <div
+ classList={{
+ "sticky top-0 z-30 bg-background-stronger": true,
+ "w-full": true,
+ "px-4 md:px-6": true,
+ "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+ }}
+ >
+ <div class="h-10 w-full flex items-center justify-between gap-2">
+ <div class="flex items-center gap-1 min-w-0 flex-1">
+ <Show when={props.parentID}>
+ <IconButton
+ tabIndex={-1}
+ icon="arrow-left"
+ variant="ghost"
+ onClick={props.onNavigateParent}
+ aria-label={props.t("common.goBack")}
+ />
+ </Show>
+ <Show when={props.title || props.titleState.editing}>
+ <Show
+ when={props.titleState.editing}
+ fallback={
+ <h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
+ {props.title}
+ </h1>
+ }
+ >
+ <InlineInput
+ ref={props.titleRef}
+ value={props.titleState.draft}
+ disabled={props.titleState.saving}
+ class="text-16-medium text-text-strong grow-1 min-w-0"
+ onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === "Enter") {
+ event.preventDefault()
+ void props.saveTitleEditor()
+ return
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ props.closeTitleEditor()
+ }
+ }}
+ onBlur={props.closeTitleEditor}
+ />
+ </Show>
+ </Show>
+ </div>
+ <Show when={props.sessionID}>
+ {(id) => (
+ <div class="shrink-0 flex items-center">
+ <DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
+ <Tooltip value={props.t("common.moreOptions")} placement="top">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+ aria-label={props.t("common.moreOptions")}
+ />
+ </Tooltip>
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!props.titleState.pendingRename) return
+ event.preventDefault()
+ props.onTitlePendingRename(false)
+ props.openTitleEditor()
+ }}
+ >
+ <DropdownMenu.Item
+ onSelect={() => {
+ props.onTitlePendingRename(true)
+ props.onTitleMenuOpen(false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
+ <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
+ <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ )}
+ </Show>
+ </div>
+ </div>
+ </Show>
+
+ <div
+ ref={props.setContentRef}
+ role="log"
+ class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
+ classList={{
+ "w-full": true,
+ "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+ "mt-0.5": props.centered,
+ "mt-0": !props.centered,
+ }}
+ >
+ <Show when={props.turnStart > 0}>
+ <div class="w-full flex justify-center">
+ <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
+ {props.t("session.messages.renderEarlier")}
+ </Button>
+ </div>
+ </Show>
+ <Show when={props.historyMore}>
+ <div class="w-full flex justify-center">
+ <Button
+ variant="ghost"
+ size="large"
+ class="text-12-medium opacity-50"
+ disabled={props.historyLoading}
+ onClick={props.onLoadEarlier}
+ >
+ {props.historyLoading
+ ? props.t("session.messages.loadingEarlier")
+ : props.t("session.messages.loadEarlier")}
+ </Button>
+ </div>
+ </Show>
+ <For each={props.renderedUserMessages}>
+ {(message) => {
+ if (import.meta.env.DEV && props.onFirstTurnMount) {
+ onMount(() => props.onFirstTurnMount?.())
+ }
+
+ return (
+ <div
+ id={props.anchor(message.id)}
+ data-message-id={message.id}
+ ref={(el) => {
+ props.onRegisterMessage(el, message.id)
+ onCleanup(() => props.onUnregisterMessage(message.id))
+ }}
+ classList={{
+ "min-w-0 w-full max-w-full": true,
+ "md:max-w-200 3xl:max-w-[1200px]": props.centered,
+ }}
+ >
+ <SessionTurn
+ sessionID={props.sessionID}
+ messageID={message.id}
+ lastUserMessageID={props.lastUserMessageID}
+ stepsExpanded={props.expanded[message.id] ?? false}
+ onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
+ classes={{
+ root: "min-w-0 w-full relative",
+ content: "flex flex-col justify-between !overflow-visible",
+ container: "w-full px-4 md:px-6",
+ }}
+ />
+ </div>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
new file mode 100644
index 000000000..a4232dd74
--- /dev/null
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -0,0 +1,158 @@
+import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
+import type { FileDiff } from "@opencode-ai/sdk/v2"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import type { SelectedLineRange } from "@/context/file"
+import { useSDK } from "@/context/sdk"
+import { useLayout } from "@/context/layout"
+import type { LineComment } from "@/context/comments"
+
+export type DiffStyle = "unified" | "split"
+
+export interface SessionReviewTabProps {
+ title?: JSX.Element
+ empty?: JSX.Element
+ diffs: () => FileDiff[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ diffStyle: DiffStyle
+ onDiffStyleChange?: (style: DiffStyle) => void
+ onViewFile?: (file: string) => void
+ onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+ comments?: LineComment[]
+ focusedComment?: { file: string; id: string } | null
+ onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
+ focusedFile?: string
+ onScrollRef?: (el: HTMLDivElement) => void
+ classes?: {
+ root?: string
+ header?: string
+ container?: string
+ }
+}
+
+export function StickyAddButton(props: { children: JSX.Element }) {
+ const [stuck, setStuck] = createSignal(false)
+ let button: HTMLDivElement | undefined
+
+ createEffect(() => {
+ const node = button
+ if (!node) return
+
+ const scroll = node.parentElement
+ if (!scroll) return
+
+ const handler = () => {
+ const rect = node.getBoundingClientRect()
+ const scrollRect = scroll.getBoundingClientRect()
+ setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
+ }
+
+ scroll.addEventListener("scroll", handler, { passive: true })
+ const observer = new ResizeObserver(handler)
+ observer.observe(scroll)
+ handler()
+ onCleanup(() => {
+ scroll.removeEventListener("scroll", handler)
+ observer.disconnect()
+ })
+ })
+
+ return (
+ <div
+ ref={button}
+ class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
+ classList={{ "border-l": stuck() }}
+ >
+ {props.children}
+ </div>
+ )
+}
+
+export function SessionReviewTab(props: SessionReviewTabProps) {
+ let scroll: HTMLDivElement | undefined
+ let frame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const sdk = useSDK()
+
+ const readFile = async (path: string) => {
+ return sdk.client.file
+ .read({ path })
+ .then((x) => x.data)
+ .catch(() => undefined)
+ }
+
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
+
+ const s = props.view().scroll("review")
+ if (!s) return
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
+
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ pending = {
+ x: event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ }
+ if (frame !== undefined) return
+
+ frame = requestAnimationFrame(() => {
+ frame = undefined
+
+ const next = pending
+ pending = undefined
+ if (!next) return
+
+ props.view().setScroll("review", next)
+ })
+ }
+
+ createEffect(
+ on(
+ () => props.diffs().length,
+ () => {
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ })
+
+ return (
+ <SessionReview
+ title={props.title}
+ empty={props.empty}
+ scrollRef={(el) => {
+ scroll = el
+ props.onScrollRef?.(el)
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ onDiffRendered={() => requestAnimationFrame(restoreScroll)}
+ open={props.view().review.open()}
+ onOpenChange={props.view().review.setOpen}
+ classes={{
+ root: props.classes?.root ?? "pb-40",
+ header: props.classes?.header ?? "px-6",
+ container: props.classes?.container ?? "px-6",
+ }}
+ diffs={props.diffs()}
+ diffStyle={props.diffStyle}
+ onDiffStyleChange={props.onDiffStyleChange}
+ onViewFile={props.onViewFile}
+ focusedFile={props.focusedFile}
+ readFile={readFile}
+ onLineComment={props.onLineComment}
+ comments={props.comments}
+ focusedComment={props.focusedComment}
+ onFocusedCommentChange={props.onFocusedCommentChange}
+ />
+ )
+}
diff --git a/packages/app/src/pages/session/session-command-helpers.ts b/packages/app/src/pages/session/session-command-helpers.ts
new file mode 100644
index 000000000..b71a7b768
--- /dev/null
+++ b/packages/app/src/pages/session/session-command-helpers.ts
@@ -0,0 +1,10 @@
+export const canAddSelectionContext = (input: {
+ active?: string
+ pathFromTab: (tab: string) => string | undefined
+ selectedLines: (path: string) => unknown
+}) => {
+ if (!input.active) return false
+ const path = input.pathFromTab(input.active)
+ if (!path) return false
+ return input.selectedLines(path) != null
+}
diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx
new file mode 100644
index 000000000..41f058231
--- /dev/null
+++ b/packages/app/src/pages/session/session-mobile-tabs.tsx
@@ -0,0 +1,36 @@
+import { Match, Show, Switch } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+
+export function SessionMobileTabs(props: {
+ open: boolean
+ hasReview: boolean
+ reviewCount: number
+ onSession: () => void
+ onChanges: () => void
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+}) {
+ return (
+ <Show when={props.open}>
+ <Tabs class="h-auto">
+ <Tabs.List>
+ <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
+ {props.t("session.tab.session")}
+ </Tabs.Trigger>
+ <Tabs.Trigger
+ value="changes"
+ class="w-1/2 !border-r-0"
+ classes={{ button: "w-full" }}
+ onClick={props.onChanges}
+ >
+ <Switch>
+ <Match when={props.hasReview}>
+ {props.t("session.review.filesChanged", { count: props.reviewCount })}
+ </Match>
+ <Match when={true}>{props.t("session.review.change.other")}</Match>
+ </Switch>
+ </Tabs.Trigger>
+ </Tabs.List>
+ </Tabs>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/session-prompt-dock.test.ts b/packages/app/src/pages/session/session-prompt-dock.test.ts
new file mode 100644
index 000000000..b3a9945d6
--- /dev/null
+++ b/packages/app/src/pages/session/session-prompt-dock.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from "bun:test"
+import { questionSubtitle } from "./session-prompt-helpers"
+
+describe("questionSubtitle", () => {
+ const t = (key: string) => {
+ if (key === "ui.common.question.one") return "question"
+ if (key === "ui.common.question.other") return "questions"
+ return key
+ }
+
+ test("returns empty for zero", () => {
+ expect(questionSubtitle(0, t)).toBe("")
+ })
+
+ test("uses singular label", () => {
+ expect(questionSubtitle(1, t)).toBe("1 question")
+ })
+
+ test("uses plural label", () => {
+ expect(questionSubtitle(3, t)).toBe("3 questions")
+ })
+})
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
new file mode 100644
index 000000000..697957027
--- /dev/null
+++ b/packages/app/src/pages/session/session-prompt-dock.tsx
@@ -0,0 +1,137 @@
+import { For, Show, type ComponentProps } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { BasicTool } from "@opencode-ai/ui/basic-tool"
+import { PromptInput } from "@/components/prompt-input"
+import { QuestionDock } from "@/components/question-dock"
+import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
+
+const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
+
+export function SessionPromptDock(props: {
+ centered: boolean
+ questionRequest: () => { questions: unknown[] } | undefined
+ permissionRequest: () => { patterns: string[]; permission: string } | undefined
+ blocked: boolean
+ promptReady: boolean
+ handoffPrompt?: string
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+ responding: boolean
+ onDecide: (response: "once" | "always" | "reject") => void
+ inputRef: (el: HTMLDivElement) => void
+ newSessionWorktree: string
+ onNewSessionWorktreeReset: () => void
+ onSubmit: () => void
+ setPromptDockRef: (el: HTMLDivElement) => void
+}) {
+ return (
+ <div
+ ref={props.setPromptDockRef}
+ class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
+ >
+ <div
+ classList={{
+ "w-full px-4 pointer-events-auto": true,
+ "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+ }}
+ >
+ <Show when={props.questionRequest()} keyed>
+ {(req) => {
+ const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
+ return (
+ <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
+ <BasicTool
+ icon="bubble-5"
+ locked
+ defaultOpen
+ trigger={{
+ title: props.t("ui.tool.questions"),
+ subtitle,
+ }}
+ />
+ <QuestionDock request={questionDockRequest(req)} />
+ </div>
+ )
+ }}
+ </Show>
+
+ <Show when={props.permissionRequest()} keyed>
+ {(perm) => (
+ <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
+ <BasicTool
+ icon="checklist"
+ locked
+ defaultOpen
+ trigger={{
+ title: props.t("notification.permission.title"),
+ subtitle:
+ perm.permission === "doom_loop"
+ ? props.t("settings.permissions.tool.doom_loop.title")
+ : perm.permission,
+ }}
+ >
+ <Show when={perm.patterns.length > 0}>
+ <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
+ <For each={perm.patterns}>
+ {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+ </For>
+ </div>
+ </Show>
+ <Show when={perm.permission === "doom_loop"}>
+ <div class="text-12-regular text-text-weak pb-2 px-3">
+ {props.t("settings.permissions.tool.doom_loop.description")}
+ </div>
+ </Show>
+ </BasicTool>
+ <div data-component="permission-prompt">
+ <div data-slot="permission-actions">
+ <Button
+ variant="ghost"
+ size="small"
+ onClick={() => props.onDecide("reject")}
+ disabled={props.responding}
+ >
+ {props.t("ui.permission.deny")}
+ </Button>
+ <Button
+ variant="secondary"
+ size="small"
+ onClick={() => props.onDecide("always")}
+ disabled={props.responding}
+ >
+ {props.t("ui.permission.allowAlways")}
+ </Button>
+ <Button
+ variant="primary"
+ size="small"
+ onClick={() => props.onDecide("once")}
+ disabled={props.responding}
+ >
+ {props.t("ui.permission.allowOnce")}
+ </Button>
+ </div>
+ </div>
+ </div>
+ )}
+ </Show>
+
+ <Show when={!props.blocked}>
+ <Show
+ when={props.promptReady}
+ fallback={
+ <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
+ {props.handoffPrompt || props.t("prompt.loading")}
+ </div>
+ }
+ >
+ <PromptInput
+ ref={props.inputRef}
+ newSessionWorktree={props.newSessionWorktree}
+ onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+ onSubmit={props.onSubmit}
+ />
+ </Show>
+ </Show>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/app/src/pages/session/session-prompt-helpers.ts b/packages/app/src/pages/session/session-prompt-helpers.ts
new file mode 100644
index 000000000..ac3234c93
--- /dev/null
+++ b/packages/app/src/pages/session/session-prompt-helpers.ts
@@ -0,0 +1,4 @@
+export const questionSubtitle = (count: number, t: (key: string) => string) => {
+ if (count === 0) return ""
+ return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
+}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
new file mode 100644
index 000000000..573680dec
--- /dev/null
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -0,0 +1,306 @@
+import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Mark } from "@opencode-ai/ui/logo"
+import FileTree from "@/components/file-tree"
+import { SessionContextUsage } from "@/components/session-context-usage"
+import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
+import { FileTabContent } from "@/pages/session/file-tabs"
+import { StickyAddButton } from "@/pages/session/review-tab"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { useComments } from "@/context/comments"
+import { useCommand } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useFile, type SelectedLineRange } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useSync } from "@/context/sync"
+
+export function SessionSidePanel(props: {
+ open: boolean
+ language: ReturnType<typeof useLanguage>
+ layout: ReturnType<typeof useLayout>
+ command: ReturnType<typeof useCommand>
+ dialog: ReturnType<typeof useDialog>
+ file: ReturnType<typeof useFile>
+ comments: ReturnType<typeof useComments>
+ sync: ReturnType<typeof useSync>
+ hasReview: boolean
+ reviewCount: number
+ reviewTab: boolean
+ contextOpen: () => boolean
+ openedTabs: () => string[]
+ activeTab: () => string
+ activeFileTab: () => string | undefined
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ openTab: (value: string) => void
+ showAllFiles: () => void
+ reviewPanel: () => JSX.Element
+ messages: () => unknown[]
+ visibleUserMessages: () => unknown[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => unknown
+ handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
+ codeComponent: NonNullable<ValidComponent>
+ addCommentToContext: (input: {
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+ }) => void
+ activeDraggable: () => string | undefined
+ onDragStart: (event: unknown) => void
+ onDragEnd: () => void
+ onDragOver: (event: DragEvent) => void
+ fileTreeTab: () => "changes" | "all"
+ setFileTreeTabValue: (value: string) => void
+ diffsReady: boolean
+ diffFiles: string[]
+ kinds: Map<string, "add" | "del" | "mix">
+ activeDiff?: string
+ focusReviewDiff: (path: string) => void
+}) {
+ return (
+ <Show when={props.open}>
+ <aside
+ id="review-panel"
+ aria-label={props.language.t("session.panel.reviewAndFiles")}
+ class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
+ >
+ <div class="flex-1 min-w-0 h-full">
+ <Show
+ when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
+ fallback={
+ <DragDropProvider
+ onDragStart={props.onDragStart}
+ onDragEnd={props.onDragEnd}
+ onDragOver={props.onDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs value={props.activeTab()} onChange={props.openTab}>
+ <div class="sticky top-0 shrink-0 flex">
+ <Tabs.List
+ ref={(el: HTMLDivElement) => {
+ const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
+ onCleanup(stop)
+ }}
+ >
+ <Show when={props.reviewTab}>
+ <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
+ <div class="flex items-center gap-1.5">
+ <div>{props.language.t("session.tab.review")}</div>
+ <Show when={props.hasReview}>
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+ {props.reviewCount}
+ </div>
+ </Show>
+ </div>
+ </Tabs.Trigger>
+ </Show>
+ <Show when={props.contextOpen()}>
+ <Tabs.Trigger
+ value="context"
+ closeButton={
+ <Tooltip value={props.language.t("common.closeTab")} placement="bottom">
+ <IconButton
+ icon="close-small"
+ variant="ghost"
+ class="h-5 w-5"
+ onClick={() => props.tabs().close("context")}
+ aria-label={props.language.t("common.closeTab")}
+ />
+ </Tooltip>
+ }
+ hideCloseButton
+ onMiddleClick={() => props.tabs().close("context")}
+ >
+ <div class="flex items-center gap-2">
+ <SessionContextUsage variant="indicator" />
+ <div>{props.language.t("session.tab.context")}</div>
+ </div>
+ </Tabs.Trigger>
+ </Show>
+ <SortableProvider ids={props.openedTabs()}>
+ <For each={props.openedTabs()}>
+ {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
+ </For>
+ </SortableProvider>
+ <StickyAddButton>
+ <TooltipKeybind
+ title={props.language.t("command.file.open")}
+ keybind={props.command.keybind("file.open")}
+ class="flex items-center"
+ >
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() =>
+ props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
+ }
+ aria-label={props.language.t("command.file.open")}
+ />
+ </TooltipKeybind>
+ </StickyAddButton>
+ </Tabs.List>
+ </div>
+
+ <Show when={props.reviewTab}>
+ <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+ <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
+ </Tabs.Content>
+ </Show>
+
+ <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+ <Show when={props.activeTab() === "empty"}>
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+ <Mark class="w-14 opacity-10" />
+ <div class="text-14-regular text-text-weak max-w-56">
+ {props.language.t("session.files.selectToOpen")}
+ </div>
+ </div>
+ </div>
+ </Show>
+ </Tabs.Content>
+
+ <Show when={props.contextOpen()}>
+ <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+ <Show when={props.activeTab() === "context"}>
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <SessionContextTab
+ messages={props.messages as never}
+ visibleUserMessages={props.visibleUserMessages as never}
+ view={props.view as never}
+ info={props.info as never}
+ />
+ </div>
+ </Show>
+ </Tabs.Content>
+ </Show>
+
+ <Show when={props.activeFileTab()} keyed>
+ {(tab) => (
+ <FileTabContent
+ tab={tab}
+ activeTab={props.activeTab}
+ tabs={props.tabs}
+ view={props.view}
+ handoffFiles={props.handoffFiles}
+ file={props.file}
+ comments={props.comments}
+ language={props.language}
+ codeComponent={props.codeComponent}
+ addCommentToContext={props.addCommentToContext}
+ />
+ )}
+ </Show>
+ </Tabs>
+ <DragOverlay>
+ <Show when={props.activeDraggable()}>
+ {(tab) => {
+ const path = createMemo(() => props.file.pathFromTab(tab()))
+ return (
+ <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+ <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+ </div>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ }
+ >
+ {props.reviewPanel()}
+ </Show>
+ </div>
+
+ <Show when={props.layout.fileTree.opened()}>
+ <div
+ id="file-tree-panel"
+ class="relative shrink-0 h-full"
+ style={{ width: `${props.layout.fileTree.width()}px` }}
+ >
+ <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
+ <Tabs
+ variant="pill"
+ value={props.fileTreeTab()}
+ onChange={props.setFileTreeTabValue}
+ class="h-full"
+ data-scope="filetree"
+ >
+ <Tabs.List>
+ <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
+ {props.reviewCount}{" "}
+ {props.language.t(
+ props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
+ )}
+ </Tabs.Trigger>
+ <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
+ {props.language.t("session.files.all")}
+ </Tabs.Trigger>
+ </Tabs.List>
+ <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
+ <Switch>
+ <Match when={props.hasReview}>
+ <Show
+ when={props.diffsReady}
+ fallback={
+ <div class="px-2 py-2 text-12-regular text-text-weak">
+ {props.language.t("common.loading")}
+ {props.language.t("common.loading.ellipsis")}
+ </div>
+ }
+ >
+ <FileTree
+ path=""
+ allowed={props.diffFiles}
+ kinds={props.kinds}
+ draggable={false}
+ active={props.activeDiff}
+ onFileClick={(node) => props.focusReviewDiff(node.path)}
+ />
+ </Show>
+ </Match>
+ <Match when={true}>
+ <div class="mt-8 text-center text-12-regular text-text-weak">
+ {props.language.t("session.review.noChanges")}
+ </div>
+ </Match>
+ </Switch>
+ </Tabs.Content>
+ <Tabs.Content value="all" class="bg-background-base px-3 py-0">
+ <FileTree
+ path=""
+ modified={props.diffFiles}
+ kinds={props.kinds}
+ onFileClick={(node) => props.openTab(props.file.tab(node.path))}
+ />
+ </Tabs.Content>
+ </Tabs>
+ </div>
+ <ResizeHandle
+ direction="horizontal"
+ edge="start"
+ size={props.layout.fileTree.width()}
+ min={200}
+ max={480}
+ collapseThreshold={160}
+ onResize={props.layout.fileTree.resize}
+ onCollapse={props.layout.fileTree.close}
+ />
+ </div>
+ </Show>
+ </aside>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/terminal-label.ts b/packages/app/src/pages/session/terminal-label.ts
new file mode 100644
index 000000000..6d336769b
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-label.ts
@@ -0,0 +1,16 @@
+export const terminalTabLabel = (input: {
+ title?: string
+ titleNumber?: number
+ t: (key: string, vars?: Record<string, string | number | boolean>) => string
+}) => {
+ const title = input.title ?? ""
+ const number = input.titleNumber ?? 0
+ const match = title.match(/^Terminal (\d+)$/)
+ const parsed = match ? Number(match[1]) : undefined
+ const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
+
+ if (title && !isDefaultTitle) return title
+ if (number > 0) return input.t("terminal.title.numbered", { number })
+ if (title) return title
+ return input.t("terminal.title")
+}
diff --git a/packages/app/src/pages/session/terminal-panel.test.ts b/packages/app/src/pages/session/terminal-panel.test.ts
new file mode 100644
index 000000000..43eeec32f
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-panel.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { terminalTabLabel } from "./terminal-label"
+
+const t = (key: string, vars?: Record<string, string | number | boolean>) => {
+ if (key === "terminal.title.numbered") return `Terminal ${vars?.number}`
+ if (key === "terminal.title") return "Terminal"
+ return key
+}
+
+describe("terminalTabLabel", () => {
+ test("returns custom title unchanged", () => {
+ const label = terminalTabLabel({ title: "server", titleNumber: 3, t })
+ expect(label).toBe("server")
+ })
+
+ test("normalizes default numbered title", () => {
+ const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t })
+ expect(label).toBe("Terminal 2")
+ })
+
+ test("falls back to generic title", () => {
+ const label = terminalTabLabel({ title: "", titleNumber: 0, t })
+ expect(label).toBe("Terminal")
+ })
+})
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
new file mode 100644
index 000000000..09095d689
--- /dev/null
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -0,0 +1,169 @@
+import { createMemo, For, Show } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import { SortableTerminalTab } from "@/components/session"
+import { Terminal } from "@/components/terminal"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+
+export function TerminalPanel(props: {
+ open: boolean
+ height: number
+ resize: (value: number) => void
+ close: () => void
+ terminal: ReturnType<typeof useTerminal>
+ language: ReturnType<typeof useLanguage>
+ command: ReturnType<typeof useCommand>
+ handoff: () => string[]
+ activeTerminalDraggable: () => string | undefined
+ handleTerminalDragStart: (event: unknown) => void
+ handleTerminalDragOver: (event: DragEvent) => void
+ handleTerminalDragEnd: () => void
+ onCloseTab: () => void
+}) {
+ return (
+ <Show when={props.open}>
+ <div
+ id="terminal-panel"
+ role="region"
+ aria-label={props.language.t("terminal.title")}
+ class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+ style={{ height: `${props.height}px` }}
+ >
+ <ResizeHandle
+ direction="vertical"
+ size={props.height}
+ min={100}
+ max={window.innerHeight * 0.6}
+ collapseThreshold={50}
+ onResize={props.resize}
+ onCollapse={props.close}
+ />
+ <Show
+ when={props.terminal.ready()}
+ fallback={
+ <div class="flex flex-col h-full pointer-events-none">
+ <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
+ <For each={props.handoff()}>
+ {(title) => (
+ <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
+ {title}
+ </div>
+ )}
+ </For>
+ <div class="flex-1" />
+ <div class="text-text-weak pr-2">
+ {props.language.t("common.loading")}
+ {props.language.t("common.loading.ellipsis")}
+ </div>
+ </div>
+ <div class="flex-1 flex items-center justify-center text-text-weak">
+ {props.language.t("terminal.loading")}
+ </div>
+ </div>
+ }
+ >
+ <DragDropProvider
+ onDragStart={props.handleTerminalDragStart}
+ onDragEnd={props.handleTerminalDragEnd}
+ onDragOver={props.handleTerminalDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <div class="flex flex-col h-full">
+ <Tabs
+ variant="alt"
+ value={props.terminal.active()}
+ onChange={(id) => props.terminal.open(id)}
+ class="!h-auto !flex-none"
+ >
+ <Tabs.List class="h-10">
+ <SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}>
+ <For each={props.terminal.all()}>
+ {(pty) => (
+ <SortableTerminalTab
+ terminal={pty}
+ onClose={() => {
+ props.close()
+ props.onCloseTab()
+ }}
+ />
+ )}
+ </For>
+ </SortableProvider>
+ <div class="h-full flex items-center justify-center">
+ <TooltipKeybind
+ title={props.language.t("command.terminal.new")}
+ keybind={props.command.keybind("terminal.new")}
+ class="flex items-center"
+ >
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={props.terminal.new}
+ aria-label={props.language.t("command.terminal.new")}
+ />
+ </TooltipKeybind>
+ </div>
+ </Tabs.List>
+ </Tabs>
+ <div class="flex-1 min-h-0 relative">
+ <For each={props.terminal.all()}>
+ {(pty) => (
+ <div
+ id={`terminal-wrapper-${pty.id}`}
+ class="absolute inset-0"
+ style={{
+ display: props.terminal.active() === pty.id ? "block" : "none",
+ }}
+ >
+ <Show when={pty.id} keyed>
+ <Terminal
+ pty={pty}
+ onCleanup={props.terminal.update}
+ onConnectError={() => props.terminal.clone(pty.id)}
+ />
+ </Show>
+ </div>
+ )}
+ </For>
+ </div>
+ </div>
+ <DragOverlay>
+ <Show when={props.activeTerminalDraggable()}>
+ {(draggedId) => {
+ const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ return (
+ <Show when={pty()}>
+ {(t) => (
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+ {terminalTabLabel({
+ title: t().title,
+ titleNumber: t().titleNumber,
+ t: props.language.t as (
+ key: string,
+ vars?: Record<string, string | number | boolean>,
+ ) => string,
+ })}
+ </div>
+ )}
+ </Show>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ </Show>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts
new file mode 100644
index 000000000..ada1871e1
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-commands.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import { canAddSelectionContext } from "./session-command-helpers"
+
+describe("canAddSelectionContext", () => {
+ test("returns false without active tab", () => {
+ expect(
+ canAddSelectionContext({
+ active: undefined,
+ pathFromTab: () => "src/a.ts",
+ selectedLines: () => ({ start: 1, end: 1 }),
+ }),
+ ).toBe(false)
+ })
+
+ test("returns false when active tab is not a file", () => {
+ expect(
+ canAddSelectionContext({
+ active: "context",
+ pathFromTab: () => undefined,
+ selectedLines: () => ({ start: 1, end: 1 }),
+ }),
+ ).toBe(false)
+ })
+
+ test("returns false without selected lines", () => {
+ expect(
+ canAddSelectionContext({
+ active: "file://src/a.ts",
+ pathFromTab: () => "src/a.ts",
+ selectedLines: () => null,
+ }),
+ ).toBe(false)
+ })
+
+ test("returns true when file and selection exist", () => {
+ expect(
+ canAddSelectionContext({
+ active: "file://src/a.ts",
+ pathFromTab: () => "src/a.ts",
+ selectedLines: () => ({ start: 1, end: 2 }),
+ }),
+ ).toBe(true)
+ })
+})
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
new file mode 100644
index 000000000..ae845a657
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -0,0 +1,439 @@
+import { createMemo } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { useCommand } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+import { usePermission } from "@/context/permission"
+import { usePrompt } from "@/context/prompt"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
+import { DialogFork } from "@/components/dialog-fork"
+import { showToast } from "@opencode-ai/ui/toast"
+import { findLast } from "@opencode-ai/util/array"
+import { extractPromptFromParts } from "@/utils/prompt"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+import { combineCommandSections } from "@/pages/session/helpers"
+import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
+
+export const useSessionCommands = (input: {
+ command: ReturnType<typeof useCommand>
+ dialog: ReturnType<typeof useDialog>
+ file: ReturnType<typeof useFile>
+ language: ReturnType<typeof useLanguage>
+ local: ReturnType<typeof useLocal>
+ permission: ReturnType<typeof usePermission>
+ prompt: ReturnType<typeof usePrompt>
+ sdk: ReturnType<typeof useSDK>
+ sync: ReturnType<typeof useSync>
+ terminal: ReturnType<typeof useTerminal>
+ layout: ReturnType<typeof useLayout>
+ params: ReturnType<typeof useParams>
+ navigate: ReturnType<typeof useNavigate>
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
+ status: () => { type: string }
+ userMessages: () => UserMessage[]
+ visibleUserMessages: () => UserMessage[]
+ activeMessage: () => UserMessage | undefined
+ showAllFiles: () => void
+ navigateMessageByOffset: (offset: number) => void
+ setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
+ setActiveMessage: (message: UserMessage | undefined) => void
+ addSelectionToContext: (path: string, selection: FileSelection) => void
+}) => {
+ const sessionCommands = createMemo(() => [
+ {
+ id: "session.new",
+ title: input.language.t("command.session.new"),
+ category: input.language.t("command.category.session"),
+ keybind: "mod+shift+s",
+ slash: "new",
+ onSelect: () => input.navigate(`/${input.params.dir}/session`),
+ },
+ ])
+
+ const fileCommands = createMemo(() => [
+ {
+ id: "file.open",
+ title: input.language.t("command.file.open"),
+ description: input.language.t("palette.search.placeholder"),
+ category: input.language.t("command.category.file"),
+ keybind: "mod+p",
+ slash: "open",
+ onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
+ },
+ {
+ id: "tab.close",
+ title: input.language.t("command.tab.close"),
+ category: input.language.t("command.category.file"),
+ keybind: "mod+w",
+ disabled: !input.tabs().active(),
+ onSelect: () => {
+ const active = input.tabs().active()
+ if (!active) return
+ input.tabs().close(active)
+ },
+ },
+ ])
+
+ const contextCommands = createMemo(() => [
+ {
+ id: "context.addSelection",
+ title: input.language.t("command.context.addSelection"),
+ description: input.language.t("command.context.addSelection.description"),
+ category: input.language.t("command.category.context"),
+ keybind: "mod+shift+l",
+ disabled: !canAddSelectionContext({
+ active: input.tabs().active(),
+ pathFromTab: input.file.pathFromTab,
+ selectedLines: input.file.selectedLines,
+ }),
+ onSelect: () => {
+ const active = input.tabs().active()
+ if (!active) return
+ const path = input.file.pathFromTab(active)
+ if (!path) return
+
+ const range = input.file.selectedLines(path)
+ if (!range) {
+ showToast({
+ title: input.language.t("toast.context.noLineSelection.title"),
+ description: input.language.t("toast.context.noLineSelection.description"),
+ })
+ return
+ }
+
+ input.addSelectionToContext(path, selectionFromLines(range))
+ },
+ },
+ ])
+
+ const viewCommands = createMemo(() => [
+ {
+ id: "terminal.toggle",
+ title: input.language.t("command.terminal.toggle"),
+ description: "",
+ category: input.language.t("command.category.view"),
+ keybind: "ctrl+`",
+ slash: "terminal",
+ onSelect: () => input.view().terminal.toggle(),
+ },
+ {
+ id: "review.toggle",
+ title: input.language.t("command.review.toggle"),
+ description: "",
+ category: input.language.t("command.category.view"),
+ keybind: "mod+shift+r",
+ onSelect: () => input.view().reviewPanel.toggle(),
+ },
+ {
+ id: "fileTree.toggle",
+ title: input.language.t("command.fileTree.toggle"),
+ description: "",
+ category: input.language.t("command.category.view"),
+ onSelect: () => {
+ const opening = !input.layout.fileTree.opened()
+ if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open()
+ input.layout.fileTree.toggle()
+ },
+ },
+ {
+ id: "terminal.new",
+ title: input.language.t("command.terminal.new"),
+ description: input.language.t("command.terminal.new.description"),
+ category: input.language.t("command.category.terminal"),
+ keybind: "ctrl+alt+t",
+ onSelect: () => {
+ if (input.terminal.all().length > 0) input.terminal.new()
+ input.view().terminal.open()
+ },
+ },
+ {
+ id: "steps.toggle",
+ title: input.language.t("command.steps.toggle"),
+ description: input.language.t("command.steps.toggle.description"),
+ category: input.language.t("command.category.view"),
+ keybind: "mod+e",
+ slash: "steps",
+ disabled: !input.params.id,
+ onSelect: () => {
+ const msg = input.activeMessage()
+ if (!msg) return
+ input.setExpanded(msg.id, (open: boolean | undefined) => !open)
+ },
+ },
+ ])
+
+ const messageCommands = createMemo(() => [
+ {
+ id: "message.previous",
+ title: input.language.t("command.message.previous"),
+ description: input.language.t("command.message.previous.description"),
+ category: input.language.t("command.category.session"),
+ keybind: "mod+arrowup",
+ disabled: !input.params.id,
+ onSelect: () => input.navigateMessageByOffset(-1),
+ },
+ {
+ id: "message.next",
+ title: input.language.t("command.message.next"),
+ description: input.language.t("command.message.next.description"),
+ category: input.language.t("command.category.session"),
+ keybind: "mod+arrowdown",
+ disabled: !input.params.id,
+ onSelect: () => input.navigateMessageByOffset(1),
+ },
+ ])
+
+ const agentCommands = createMemo(() => [
+ {
+ id: "model.choose",
+ title: input.language.t("command.model.choose"),
+ description: input.language.t("command.model.choose.description"),
+ category: input.language.t("command.category.model"),
+ keybind: "mod+'",
+ slash: "model",
+ onSelect: () => input.dialog.show(() => <DialogSelectModel />),
+ },
+ {
+ id: "mcp.toggle",
+ title: input.language.t("command.mcp.toggle"),
+ description: input.language.t("command.mcp.toggle.description"),
+ category: input.language.t("command.category.mcp"),
+ keybind: "mod+;",
+ slash: "mcp",
+ onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
+ },
+ {
+ id: "agent.cycle",
+ title: input.language.t("command.agent.cycle"),
+ description: input.language.t("command.agent.cycle.description"),
+ category: input.language.t("command.category.agent"),
+ keybind: "mod+.",
+ slash: "agent",
+ onSelect: () => input.local.agent.move(1),
+ },
+ {
+ id: "agent.cycle.reverse",
+ title: input.language.t("command.agent.cycle.reverse"),
+ description: input.language.t("command.agent.cycle.reverse.description"),
+ category: input.language.t("command.category.agent"),
+ keybind: "shift+mod+.",
+ onSelect: () => input.local.agent.move(-1),
+ },
+ {
+ id: "model.variant.cycle",
+ title: input.language.t("command.model.variant.cycle"),
+ description: input.language.t("command.model.variant.cycle.description"),
+ category: input.language.t("command.category.model"),
+ keybind: "shift+mod+d",
+ onSelect: () => {
+ input.local.model.variant.cycle()
+ },
+ },
+ ])
+
+ const permissionCommands = createMemo(() => [
+ {
+ id: "permissions.autoaccept",
+ title:
+ input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
+ ? input.language.t("command.permissions.autoaccept.disable")
+ : input.language.t("command.permissions.autoaccept.enable"),
+ category: input.language.t("command.category.permissions"),
+ keybind: "mod+shift+a",
+ disabled: !input.params.id || !input.permission.permissionsEnabled(),
+ onSelect: () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
+ showToast({
+ title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+ ? input.language.t("toast.permissions.autoaccept.on.title")
+ : input.language.t("toast.permissions.autoaccept.off.title"),
+ description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+ ? input.language.t("toast.permissions.autoaccept.on.description")
+ : input.language.t("toast.permissions.autoaccept.off.description"),
+ })
+ },
+ },
+ ])
+
+ const sessionActionCommands = createMemo(() => [
+ {
+ id: "session.undo",
+ title: input.language.t("command.session.undo"),
+ description: input.language.t("command.session.undo.description"),
+ category: input.language.t("command.category.session"),
+ slash: "undo",
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: async () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ if (input.status()?.type !== "idle") {
+ await input.sdk.client.session.abort({ sessionID }).catch(() => {})
+ }
+ const revert = input.info()?.revert?.messageID
+ const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
+ if (!message) return
+ await input.sdk.client.session.revert({ sessionID, messageID: message.id })
+ const parts = input.sync.data.part[message.id]
+ if (parts) {
+ const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
+ input.prompt.set(restored)
+ }
+ const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
+ input.setActiveMessage(priorMessage)
+ },
+ },
+ {
+ id: "session.redo",
+ title: input.language.t("command.session.redo"),
+ description: input.language.t("command.session.redo.description"),
+ category: input.language.t("command.category.session"),
+ slash: "redo",
+ disabled: !input.params.id || !input.info()?.revert?.messageID,
+ onSelect: async () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ const revertMessageID = input.info()?.revert?.messageID
+ if (!revertMessageID) return
+ const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
+ if (!nextMessage) {
+ await input.sdk.client.session.unrevert({ sessionID })
+ input.prompt.reset()
+ const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
+ input.setActiveMessage(lastMsg)
+ return
+ }
+ await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+ const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
+ input.setActiveMessage(priorMsg)
+ },
+ },
+ {
+ id: "session.compact",
+ title: input.language.t("command.session.compact"),
+ description: input.language.t("command.session.compact.description"),
+ category: input.language.t("command.category.session"),
+ slash: "compact",
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: async () => {
+ const sessionID = input.params.id
+ if (!sessionID) return
+ const model = input.local.model.current()
+ if (!model) {
+ showToast({
+ title: input.language.t("toast.model.none.title"),
+ description: input.language.t("toast.model.none.description"),
+ })
+ return
+ }
+ await input.sdk.client.session.summarize({
+ sessionID,
+ modelID: model.id,
+ providerID: model.provider.id,
+ })
+ },
+ },
+ {
+ id: "session.fork",
+ title: input.language.t("command.session.fork"),
+ description: input.language.t("command.session.fork.description"),
+ category: input.language.t("command.category.session"),
+ slash: "fork",
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: () => input.dialog.show(() => <DialogFork />),
+ },
+ ])
+
+ const shareCommands = createMemo(() => {
+ if (input.sync.data.config.share === "disabled") return []
+ return [
+ {
+ id: "session.share",
+ title: input.language.t("command.session.share"),
+ description: input.language.t("command.session.share.description"),
+ category: input.language.t("command.category.session"),
+ slash: "share",
+ disabled: !input.params.id || !!input.info()?.share?.url,
+ onSelect: async () => {
+ if (!input.params.id) return
+ await input.sdk.client.session
+ .share({ sessionID: input.params.id })
+ .then((res) => {
+ navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
+ showToast({
+ title: input.language.t("toast.session.share.copyFailed.title"),
+ variant: "error",
+ }),
+ )
+ })
+ .then(() =>
+ showToast({
+ title: input.language.t("toast.session.share.success.title"),
+ description: input.language.t("toast.session.share.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: input.language.t("toast.session.share.failed.title"),
+ description: input.language.t("toast.session.share.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ {
+ id: "session.unshare",
+ title: input.language.t("command.session.unshare"),
+ description: input.language.t("command.session.unshare.description"),
+ category: input.language.t("command.category.session"),
+ slash: "unshare",
+ disabled: !input.params.id || !input.info()?.share?.url,
+ onSelect: async () => {
+ if (!input.params.id) return
+ await input.sdk.client.session
+ .unshare({ sessionID: input.params.id })
+ .then(() =>
+ showToast({
+ title: input.language.t("toast.session.unshare.success.title"),
+ description: input.language.t("toast.session.unshare.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: input.language.t("toast.session.unshare.failed.title"),
+ description: input.language.t("toast.session.unshare.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ ]
+ })
+
+ input.command.register("session", () =>
+ combineCommandSections([
+ sessionCommands(),
+ fileCommands(),
+ contextCommands(),
+ viewCommands(),
+ messageCommands(),
+ agentCommands(),
+ permissionCommands(),
+ sessionActionCommands(),
+ shareCommands(),
+ ]),
+ )
+}
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts
new file mode 100644
index 000000000..844f5451e
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { messageIdFromHash } from "./use-session-hash-scroll"
+
+describe("messageIdFromHash", () => {
+ test("parses hash with leading #", () => {
+ expect(messageIdFromHash("#message-abc123")).toBe("abc123")
+ })
+
+ test("parses raw hash fragment", () => {
+ expect(messageIdFromHash("message-42")).toBe("42")
+ })
+
+ test("ignores non-message anchors", () => {
+ expect(messageIdFromHash("#review-panel")).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
new file mode 100644
index 000000000..8952bbd98
--- /dev/null
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -0,0 +1,174 @@
+import { createEffect, on, onCleanup } from "solid-js"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+
+export const messageIdFromHash = (hash: string) => {
+ const value = hash.startsWith("#") ? hash.slice(1) : hash
+ const match = value.match(/^message-(.+)$/)
+ if (!match) return
+ return match[1]
+}
+
+export const useSessionHashScroll = (input: {
+ sessionKey: () => string
+ sessionID: () => string | undefined
+ messagesReady: () => boolean
+ visibleUserMessages: () => UserMessage[]
+ turnStart: () => number
+ currentMessageId: () => string | undefined
+ pendingMessage: () => string | undefined
+ setPendingMessage: (value: string | undefined) => void
+ setActiveMessage: (message: UserMessage | undefined) => void
+ setTurnStart: (value: number) => void
+ scheduleTurnBackfill: () => void
+ autoScroll: { pause: () => void; forceScrollToBottom: () => void }
+ scroller: () => HTMLDivElement | undefined
+ anchor: (id: string) => string
+ scheduleScrollState: (el: HTMLDivElement) => void
+ consumePendingMessage: (key: string) => string | undefined
+}) => {
+ const clearMessageHash = () => {
+ if (!window.location.hash) return
+ window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
+ }
+
+ const updateHash = (id: string) => {
+ window.history.replaceState(null, "", `#${input.anchor(id)}`)
+ }
+
+ const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
+ const root = input.scroller()
+ if (!root) return false
+
+ const a = el.getBoundingClientRect()
+ const b = root.getBoundingClientRect()
+ const top = a.top - b.top + root.scrollTop
+ root.scrollTo({ top, behavior })
+ return true
+ }
+
+ const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+ input.setActiveMessage(message)
+
+ const msgs = input.visibleUserMessages()
+ const index = msgs.findIndex((m) => m.id === message.id)
+ if (index !== -1 && index < input.turnStart()) {
+ input.setTurnStart(index)
+ input.scheduleTurnBackfill()
+
+ requestAnimationFrame(() => {
+ const el = document.getElementById(input.anchor(message.id))
+ if (!el) {
+ requestAnimationFrame(() => {
+ const next = document.getElementById(input.anchor(message.id))
+ if (!next) return
+ scrollToElement(next, behavior)
+ })
+ return
+ }
+ scrollToElement(el, behavior)
+ })
+
+ updateHash(message.id)
+ return
+ }
+
+ const el = document.getElementById(input.anchor(message.id))
+ if (!el) {
+ updateHash(message.id)
+ requestAnimationFrame(() => {
+ const next = document.getElementById(input.anchor(message.id))
+ if (!next) return
+ if (!scrollToElement(next, behavior)) return
+ })
+ return
+ }
+ if (scrollToElement(el, behavior)) {
+ updateHash(message.id)
+ return
+ }
+
+ requestAnimationFrame(() => {
+ const next = document.getElementById(input.anchor(message.id))
+ if (!next) return
+ if (!scrollToElement(next, behavior)) return
+ })
+ updateHash(message.id)
+ }
+
+ const applyHash = (behavior: ScrollBehavior) => {
+ const hash = window.location.hash.slice(1)
+ if (!hash) {
+ input.autoScroll.forceScrollToBottom()
+ const el = input.scroller()
+ if (el) input.scheduleScrollState(el)
+ return
+ }
+
+ const messageId = messageIdFromHash(hash)
+ if (messageId) {
+ input.autoScroll.pause()
+ const msg = input.visibleUserMessages().find((m) => m.id === messageId)
+ if (msg) {
+ scrollToMessage(msg, behavior)
+ return
+ }
+ return
+ }
+
+ const target = document.getElementById(hash)
+ if (target) {
+ input.autoScroll.pause()
+ scrollToElement(target, behavior)
+ return
+ }
+
+ input.autoScroll.forceScrollToBottom()
+ const el = input.scroller()
+ if (el) input.scheduleScrollState(el)
+ }
+
+ createEffect(
+ on(input.sessionKey, (key) => {
+ if (!input.sessionID()) return
+ const messageID = input.consumePendingMessage(key)
+ if (!messageID) return
+ input.setPendingMessage(messageID)
+ }),
+ )
+
+ createEffect(() => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ requestAnimationFrame(() => applyHash("auto"))
+ })
+
+ createEffect(() => {
+ if (!input.sessionID() || !input.messagesReady()) return
+
+ input.visibleUserMessages().length
+ input.turnStart()
+
+ const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
+ if (!targetId) return
+ if (input.currentMessageId() === targetId) return
+
+ const msg = input.visibleUserMessages().find((m) => m.id === targetId)
+ if (!msg) return
+
+ if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
+ input.autoScroll.pause()
+ requestAnimationFrame(() => scrollToMessage(msg, "auto"))
+ })
+
+ createEffect(() => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ const handler = () => requestAnimationFrame(() => applyHash("auto"))
+ window.addEventListener("hashchange", handler)
+ onCleanup(() => window.removeEventListener("hashchange", handler))
+ })
+
+ return {
+ clearMessageHash,
+ scrollToMessage,
+ applyHash,
+ }
+}