Explorar o código

app: better session id handling

Brendan Allan hai 3 semanas
pai
achega
cf3df010ce

+ 6 - 1
packages/app/src/app.tsx

@@ -8,10 +8,11 @@ import { Font } from "@opencode-ai/ui/font"
 import { Splash } from "@opencode-ai/ui/logo"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
-import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { type BaseRouterProps, Navigate, Route, Router, useLocation } from "@solidjs/router"
 import { type Duration, Effect } from "effect"
 import {
   type Component,
+  createEffect,
   createMemo,
   createResource,
   createSignal,
@@ -114,6 +115,10 @@ function SessionProviders(props: ParentProps) {
 }
 
 function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
+  const l = useLocation()
+  createEffect(() => {
+    console.log("pathname", l.pathname)
+  })
   return (
     <AppShellProviders>
       <Suspense fallback={<Loading />}>

+ 92 - 60
packages/app/src/components/prompt-input.tsx

@@ -1,61 +1,62 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
-import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { Select } from "@opencode-ai/ui/select"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { type Component, createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
 import { createStore } from "solid-js/store"
+import { ModelSelectorPopover } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
+import { useCommand } from "@/context/command"
+import { useComments } from "@/context/comments"
+import { type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
-import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
+import { usePermission } from "@/context/permission"
+import { usePlatform } from "@/context/platform"
 import {
-  ContentPart,
+  type AgentPart,
+  type ContentPart,
   DEFAULT_PROMPT,
+  type FileAttachmentPart,
+  type ImageAttachmentPart,
   isPromptEqual,
-  Prompt,
+  type Prompt,
   usePrompt,
-  ImageAttachmentPart,
-  AgentPart,
-  FileAttachmentPart,
 } from "@/context/prompt"
-import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
-import { useComments } from "@/context/comments"
-import { Button } from "@opencode-ai/ui/button"
-import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
-import { Icon } from "@opencode-ai/ui/icon"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Select } from "@opencode-ai/ui/select"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { ModelSelectorPopover } from "@/components/dialog-select-model"
-import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
-import { useCommand } from "@/context/command"
-import { Persist, persisted } from "@/utils/persist"
-import { usePermission } from "@/context/permission"
-import { useLanguage } from "@/context/language"
-import { usePlatform } from "@/context/platform"
-import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
+import { useSessionLayout } from "@/pages/session/session-layout"
 import { promptEnabled, promptProbe } from "@/testing/prompt"
-import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
+import { Optional } from "@/utils/optional"
+import { Persist, persisted } from "@/utils/persist"
 import { createPromptAttachments } from "./prompt-input/attachments"
+import { PromptContextItems } from "./prompt-input/context-items"
+import { PromptDragOverlay } from "./prompt-input/drag-overlay"
+import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
 import {
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
-  prependHistoryEntry,
   type PromptHistoryComment,
   type PromptHistoryEntry,
   type PromptHistoryStoredEntry,
+  prependHistoryEntry,
   promptLength,
 } from "./prompt-input/history"
-import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
-import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
-import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
-import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { promptPlaceholder } from "./prompt-input/placeholder"
-import { ImagePreview } from "@opencode-ai/ui/image-preview"
+import { type AtOption, PromptPopover, type SlashCommand } from "./prompt-input/slash-popover"
+import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
 
 interface PromptInputProps {
   class?: string
@@ -171,10 +172,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }).activeFileTab
 
   const commentInReview = (path: string) => {
-    const sessionID = params.id
-    if (!sessionID) return false
+    const id = params.id
+    if (!id) return false
 
-    const diffs = sync.data.session_diff[sessionID]
+    const diffs = sync.data.session_diff[id]
     if (!diffs) return false
     return diffs.some((diff) => diff.file === path)
   }
@@ -236,10 +237,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     return paths
   })
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const info = createMemo(() => Optional.map(params.id, (s) => sync.session.get(s)))
   const status = createMemo(
     () =>
-      sync.data.session_status[params.id ?? ""] ?? {
+      Optional.map(params.id, (id) => sync.data.session_status[id]) ?? {
         type: "idle",
       },
   )
@@ -283,7 +284,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     applyingHistory: false,
   })
 
-  const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
+  const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), {
+    visualDuration: 0.2,
+    bounce: 0,
+  })
   const motion = (value: number) => ({
     opacity: value,
     transform: `scale(${0.95 + value * 0.05})`,
@@ -306,9 +310,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   })
 
   const hasUserPrompt = createMemo(() => {
-    const sessionID = params.id
-    if (!sessionID) return false
-    const messages = sync.data.message[sessionID]
+    const id = params.id
+    if (!id) return false
+    const messages = sync.data.message[id]
     if (!messages) return false
     return messages.some((m) => m.role === "user")
   })
@@ -510,9 +514,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   createEffect(() => {
-    params.id
-    if (params.id) return
-    if (!suggest()) return
+    if (params.id || !suggest()) return
     const interval = setInterval(() => {
       setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
     }, 6500)
@@ -542,16 +544,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const agentList = createMemo(() =>
     sync.data.agent
       .filter((agent) => !agent.hidden && agent.mode !== "primary")
-      .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
+      .map(
+        (agent): AtOption => ({
+          type: "agent",
+          name: agent.name,
+          display: agent.name,
+        }),
+      ),
   )
   const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
 
   const handleAtSelect = (option: AtOption | undefined) => {
     if (!option) return
     if (option.type === "agent") {
-      addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
+      addPart({
+        type: "agent",
+        name: option.name,
+        content: "@" + option.name,
+        start: 0,
+        end: 0,
+      })
     } else {
-      addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
+      addPart({
+        type: "file",
+        path: option.path,
+        content: "@" + option.path,
+        start: 0,
+        end: 0,
+      })
     }
   }
 
@@ -571,7 +591,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const agents = agentList()
       const open = recent()
       const seen = new Set(open)
-      const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
+      const pinned: AtOption[] = open.map((path) => ({
+        type: "file",
+        path,
+        display: path,
+        recent: true,
+      }))
       const paths = await files.searchFilesAndDirectories(query)
       const fileOptions: AtOption[] = paths
         .filter((path) => !seen.has(path))
@@ -780,7 +805,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
       buffer = ""
       if (!content) return
-      parts.push({ type: "text", content, start: position, end: position + content.length })
+      parts.push({
+        type: "text",
+        content,
+        start: position,
+        end: position + content.length,
+      })
       position += content.length
     }
 
@@ -1057,20 +1087,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const variants = createMemo(() => ["default", ...local.model.variant.list()])
   const accepting = createMemo(() => {
-    const id = params.id
-    if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
-    return permission.isAutoAccepting(id, sdk.directory)
+    if (!params.id) return permission.isAutoAcceptingDirectory(sdk.directory)
+    return permission.isAutoAccepting(params.id, sdk.directory)
   })
   const acceptLabel = createMemo(() =>
     language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
   )
   const toggleAccept = () => {
-    if (!params.id) {
-      permission.toggleAutoAcceptDirectory(sdk.directory)
-      return
-    }
-
-    permission.toggleAutoAccept(params.id, sdk.directory)
+    const id = params.id
+    if (!id) permission.toggleAutoAcceptDirectory(sdk.directory)
+    else permission.toggleAutoAccept(id, sdk.directory)
   }
 
   const { abort, handleSubmit } = createPromptSubmit({
@@ -1503,7 +1529,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                             <ProviderIcon
                               id={local.model.current()!.provider.id}
                               class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                              style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                              style={{
+                                "will-change": "opacity",
+                                transform: "translateZ(0)",
+                              }}
                             />
                           </Show>
                           <span class="truncate">
@@ -1535,7 +1564,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           <ProviderIcon
                             id={local.model.current()!.provider.id}
                             class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                            style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+                            style={{
+                              "will-change": "opacity",
+                              transform: "translateZ(0)",
+                            }}
                           />
                         </Show>
                         <span class="truncate">

+ 12 - 11
packages/app/src/components/session/session-context-tab.tsx

@@ -1,21 +1,22 @@
-import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
-import type { JSX } from "solid-js"
-import { useSync } from "@/context/sync"
-import { checksum } from "@opencode-ai/util/encode"
-import { findLast } from "@opencode-ai/util/array"
-import { same } from "@/utils/same"
-import { Icon } from "@opencode-ai/ui/icon"
+import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
 import { Accordion } from "@opencode-ai/ui/accordion"
-import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { File } from "@opencode-ai/ui/file"
+import { Icon } from "@opencode-ai/ui/icon"
 import { Markdown } from "@opencode-ai/ui/markdown"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
+import { findLast } from "@opencode-ai/util/array"
+import { checksum } from "@opencode-ai/util/encode"
+import type { JSX } from "solid-js"
+import { createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
+import { useSync } from "@/context/sync"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { getSessionContextMetrics } from "./session-context-metrics"
+import { Optional } from "@/utils/optional"
+import { same } from "@/utils/same"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
 import { createSessionContextFormatter } from "./session-context-format"
+import { getSessionContextMetrics } from "./session-context-metrics"
 
 const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
   system: "var(--syntax-info)",
@@ -94,7 +95,7 @@ export function SessionContextTab() {
   const language = useLanguage()
   const { params, view } = useSessionLayout()
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const info = createMemo(() => Optional.map(params.id, (s) => sync.session.get(s)))
 
   const messages = createMemo(
     () => {

+ 30 - 28
packages/app/src/pages/session.tsx

@@ -1,44 +1,44 @@
 import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
+import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
+import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Select } from "@opencode-ai/ui/select"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode, checksum } from "@opencode-ai/util/encode"
+import { createMediaQuery } from "@solid-primitives/media"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { useNavigate, useSearchParams } from "@solidjs/router"
 import {
   batch,
-  onCleanup,
-  Show,
-  Match,
-  Switch,
-  createMemo,
-  createEffect,
   createComputed,
+  createEffect,
+  createMemo,
+  Match,
   on,
+  onCleanup,
   onMount,
+  Show,
+  Switch,
   untrack,
 } from "solid-js"
-import { createMediaQuery } from "@solid-primitives/media"
-import { createResizeObserver } from "@solid-primitives/resize-observer"
-import { useLocal } from "@/context/local"
-import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
 import { createStore } from "solid-js/store"
-import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
-import { Select } from "@opencode-ai/ui/select"
-import { Tabs } from "@opencode-ai/ui/tabs"
-import { createAutoScroll } from "@opencode-ai/ui/hooks"
-import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
-import { Button } from "@opencode-ai/ui/button"
-import { showToast } from "@opencode-ai/ui/toast"
-import { base64Encode, checksum } from "@opencode-ai/util/encode"
-import { useNavigate, useSearchParams } from "@solidjs/router"
+import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { useComments } from "@/context/comments"
-import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
+import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
 import { useGlobalSync } from "@/context/global-sync"
+import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
-import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
 import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
@@ -76,6 +76,8 @@ type SessionHistoryWindowInput = {
  * small batches while scrolling upward, and prefetches older history near top.
  */
 function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+  const { params } = useSessionLayout()
+
   const turnInit = 10
   const turnBatch = 8
   const turnScrollThreshold = 200
@@ -93,7 +95,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
   const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
 
   const turnStart = createMemo(() => {
-    const id = input.sessionID()
+    const id = params.id
     const len = input.visibleUserMessages().length
     if (!id || len <= 0) return 0
     if (state.turnID !== id) return initialTurnStart(len)
@@ -103,7 +105,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
   })
 
   const setTurnStart = (start: number) => {
-    const id = input.sessionID()
+    const id = params.id
     const next = start > 0 ? start : 0
     if (!id) {
       setState({ turnID: undefined, turnStart: next })
@@ -153,7 +155,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 
   /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
   const loadAndReveal = async () => {
-    const id = input.sessionID()
+    const id = params.id
     if (!id) return
 
     const start = turnStart()
@@ -169,7 +171,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 
     while (true) {
       await input.loadMore(id)
-      if (input.sessionID() !== id) return
+      if (params.id !== id) return
 
       afterVisible = input.visibleUserMessages().length
       const nextLoaded = input.loaded()
@@ -195,7 +197,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 
   /** Scroll/prefetch path: fetch older history from server. */
   const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
-    const id = input.sessionID()
+    const id = params.id
     if (!id) return
     if (!input.historyMore() || input.historyLoading()) return
 
@@ -215,7 +217,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 
     while (true) {
       await input.loadMore(id)
-      if (input.sessionID() !== id) return
+      if (params.id !== id) return
 
       const nextLoaded = input.loaded()
       const raw = nextLoaded - loaded
@@ -279,7 +281,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 
   createEffect(
     on(
-      () => [input.sessionID(), input.messagesReady()] as const,
+      () => [params.id, input.messagesReady()] as const,
       ([id, ready]) => {
         if (!id || !ready) return
         setTurnStart(initialTurnStart(input.visibleUserMessages().length))

+ 8 - 8
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -1,18 +1,19 @@
-import { Show, createEffect, createMemo, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { createEffect, createMemo, onCleanup, Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { PromptInput } from "@/components/prompt-input"
+import type { FollowupDraft } from "@/components/prompt-input/submit"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
-import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
-import { useSessionKey } from "@/pages/session/session-layout"
+import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
+import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
 import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
 import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
-import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
 import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
-import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
 import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
-import type { FollowupDraft } from "@/components/prompt-input/submit"
+import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
+
+import { useSessionKey } from "@/pages/session/session-layout"
 
 export function SessionComposerRegion(props: {
   state: SessionComposerState
@@ -194,7 +195,6 @@ export function SessionComposerRegion(props: {
               >
                 <div ref={(el) => setStore("body", el)}>
                   <SessionTodoDock
-                    sessionID={route.params.id}
                     todos={props.state.todos()}
                     collapseLabel={language.t("session.todo.collapse")}
                     expandLabel={language.t("session.todo.expand")}

+ 7 - 11
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -1,14 +1,15 @@
-import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
-import { createStore } from "solid-js/store"
 import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
-import { useParams } from "@solidjs/router"
 import { showToast } from "@opencode-ai/ui/toast"
+import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { usePermission } from "@/context/permission"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
+import { Optional } from "@/utils/optional"
+import { useSessionLayout } from "../session-layout"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
 export const todoState = (input: {
@@ -25,12 +26,12 @@ export const todoState = (input: {
 const idle = { type: "idle" as const }
 
 export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
-  const params = useParams()
   const sdk = useSDK()
   const sync = useSync()
   const globalSync = useGlobalSync()
   const language = useLanguage()
   const permission = usePermission()
+  const { params } = useSessionLayout()
 
   const questionRequest = createMemo((): QuestionRequest | undefined => {
     return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
@@ -43,8 +44,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   })
 
   const blocked = createMemo(() => {
-    const id = params.id
-    if (!id) return false
+    if (!params.id) return false
     return !!permissionRequest() || !!questionRequest()
   })
 
@@ -101,11 +101,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
     () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
   )
 
-  const status = createMemo(() => {
-    const id = params.id
-    if (!id) return idle
-    return sync.data.session_status[id] ?? idle
-  })
+  const status = createMemo(() => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle)
 
   const busy = createMemo(() => status().type !== "idle")
   const live = createMemo(() => {

+ 5 - 4
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -6,10 +6,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
-import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createEffect, createMemo, Index, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { composerEnabled, composerProbe } from "@/testing/session-composer"
 import { useLanguage } from "@/context/language"
+import { composerEnabled, composerProbe } from "@/testing/session-composer"
+import { useSessionLayout } from "../session-layout"
 
 const doneToken = "\u0000done\u0000"
 const totalToken = "\u0000total\u0000"
@@ -40,7 +41,6 @@ function dot(status: Todo["status"]) {
 }
 
 export function SessionTodoDock(props: {
-  sessionID?: string
   todos: Todo[]
   collapseLabel: string
   expandLabel: string
@@ -51,6 +51,7 @@ export function SessionTodoDock(props: {
     collapsed: false,
     height: 320,
   })
+  const { params } = useSessionLayout()
 
   const toggle = () => setStore("collapsed", (value) => !value)
 
@@ -81,7 +82,7 @@ export function SessionTodoDock(props: {
   const turn = createMemo(() => Math.max(0, Math.min(1, value())))
   const full = createMemo(() => Math.max(78, store.height))
   const e2e = composerEnabled()
-  const probe = composerProbe(props.sessionID)
+  const probe = composerProbe(params.id)
   let contentRef: HTMLDivElement | undefined
 
   createEffect(() => {

+ 30 - 42
packages/app/src/pages/session/message-timeline.tsx

@@ -1,34 +1,35 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { useNavigate } from "@solidjs/router"
+import { Popover as KobaltePopover } from "@kobalte/core/popover"
+import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
 import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Dialog } from "@opencode-ai/ui/dialog"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
-import { Spinner } from "@opencode-ai/ui/spinner"
-import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { Spinner } from "@opencode-ai/ui/spinner"
 import { TextField } from "@opencode-ai/ui/text-field"
-import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
 import { showToast } from "@opencode-ai/ui/toast"
 import { Binary } from "@opencode-ai/util/binary"
 import { getFilename } from "@opencode-ai/util/path"
-import { Popover as KobaltePopover } from "@kobalte/core/popover"
-import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+import { useNavigate } from "@solidjs/router"
+import { createEffect, createMemo, For, Index, type JSX, on, onCleanup, Show } from "solid-js"
+import { createStore, produce } from "solid-js/store"
 import { SessionContextUsage } from "@/components/session-context-usage"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useLanguage } from "@/context/language"
-import { useSessionKey } from "@/pages/session/session-layout"
 import { useGlobalSDK } from "@/context/global-sdk"
+import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
-import { useSettings } from "@/context/settings"
 import { useSDK } from "@/context/sdk"
+import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
+import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture"
+import { useSessionKey } from "@/pages/session/session-layout"
 import { messageAgentColor } from "@/utils/agent"
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import { Optional } from "@/utils/optional"
 
 type MessageComment = {
   path: string
@@ -230,22 +231,13 @@ export function MessageTimeline(props: {
   const platform = usePlatform()
 
   const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
-  const sessionID = createMemo(() => params.id)
-  const sessionMessages = createMemo(() => {
-    const id = sessionID()
-    if (!id) return emptyMessages
-    return sync.data.message[id] ?? emptyMessages
-  })
+  const sessionMessages = createMemo(() => Optional.map(params.id, (id) => sync.data.message[id]) ?? emptyMessages)
   const pending = createMemo(() =>
     sessionMessages().findLast(
       (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
     ),
   )
-  const sessionStatus = createMemo(() => {
-    const id = sessionID()
-    if (!id) return idle
-    return sync.data.session_status[id] ?? idle
-  })
+  const sessionStatus = createMemo(() => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle)
   const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
   const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
 
@@ -300,11 +292,7 @@ export function MessageTimeline(props: {
 
     return undefined
   })
-  const info = createMemo(() => {
-    const id = sessionID()
-    if (!id) return
-    return sync.session.get(id)
-  })
+  const info = createMemo(() => Optional.map(params.id, (id) => sync.session.get(id)))
   const titleValue = createMemo(() => info()?.title)
   const shareUrl = createMemo(() => info()?.share?.url)
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
@@ -338,7 +326,7 @@ export function MessageTimeline(props: {
   const [req, setReq] = createStore({ share: false, unshare: false })
 
   const shareSession = () => {
-    const id = sessionID()
+    const id = params.id
     if (!id || req.share) return
     if (!shareEnabled()) return
     setReq("share", true)
@@ -353,7 +341,7 @@ export function MessageTimeline(props: {
   }
 
   const unshareSession = () => {
-    const id = sessionID()
+    const id = params.id
     if (!id || req.unshare) return
     if (!shareEnabled()) return
     setReq("unshare", true)
@@ -399,7 +387,7 @@ export function MessageTimeline(props: {
   )
 
   const openTitleEditor = () => {
-    if (!sessionID()) return
+    if (!params.id) return
     setTitle({ editing: true, draft: titleValue() ?? "" })
     requestAnimationFrame(() => {
       titleRef?.focus()
@@ -413,7 +401,7 @@ export function MessageTimeline(props: {
   }
 
   const saveTitleEditor = async () => {
-    const id = sessionID()
+    const id = params.id
     if (!id) return
     if (title.saving) return
 
@@ -444,17 +432,17 @@ export function MessageTimeline(props: {
       })
   }
 
-  const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
-    if (params.id !== sessionID) return
+  const navigateAfterSessionRemoval = (id: string, parentID?: string, nextSessionID?: string) => {
+    if (params.id !== id) return
     if (parentID) {
-      navigate(`/${params.dir}/session/${parentID}`)
+      navigate(`../${parentID}`)
       return
     }
     if (nextSessionID) {
-      navigate(`/${params.dir}/session/${nextSessionID}`)
+      navigate(`../${nextSessionID}`)
       return
     }
-    navigate(`/${params.dir}/session`)
+    navigate("../")
   }
 
   const archiveSession = async (sessionID: string) => {
@@ -547,7 +535,7 @@ export function MessageTimeline(props: {
   const navigateParent = () => {
     const id = parentID()
     if (!id) return
-    navigate(`/${params.dir}/session/${id}`)
+    navigate(`../${id}`)
   }
 
   function DialogDeleteSession(props: { sessionID: string }) {
@@ -734,7 +722,7 @@ export function MessageTimeline(props: {
                       </Show>
                     </div>
                   </div>
-                  <Show when={sessionID()}>
+                  <Show when={params.id}>
                     {(id) => (
                       <div class="shrink-0 flex items-center gap-3">
                         <SessionContextUsage placement="bottom" />
@@ -999,7 +987,7 @@ export function MessageTimeline(props: {
                         </div>
                       </Show>
                       <SessionTurn
-                        sessionID={sessionID() ?? ""}
+                        sessionID={params.id ?? ""}
                         messageID={messageID}
                         actions={props.actions}
                         active={active()}

+ 1 - 1
packages/app/src/pages/session/session-layout.ts

@@ -3,7 +3,7 @@ import { createMemo } from "solid-js"
 import { useLayout } from "@/context/layout"
 
 export const useSessionKey = () => {
-  const params = useParams()
+  const params = useParams<{ dir: string; id: string & { __brand: "SessionID" } }>()
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   return { params, sessionKey }
 }

+ 17 - 17
packages/app/src/pages/session/session-side-panel.tsx

@@ -1,30 +1,31 @@
-import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
-import { createStore } from "solid-js/store"
-import { createMediaQuery } from "@solid-primitives/media"
-import { Tabs } from "@opencode-ai/ui/tabs"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
-import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { createMediaQuery } from "@solid-primitives/media"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
-import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { closestCenter, DragDropProvider, DragDropSensors, DragOverlay, SortableProvider } from "@thisbeyond/solid-dnd"
+import { createEffect, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
+import { DialogSelectFile } from "@/components/dialog-select-file"
 
 import FileTree from "@/components/file-tree"
+import { FileVisual, SessionContextTab, SortableTab } from "@/components/session"
 import { SessionContextUsage } from "@/components/session-context-usage"
-import { DialogSelectFile } from "@/components/dialog-select-file"
-import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
 import { useCommand } from "@/context/command"
-import { useFile, type SelectedLineRange } from "@/context/file"
+import { type SelectedLineRange, useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
-import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 import { setSessionHandoff } from "@/pages/session/handoff"
+import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { Optional } from "@/utils/optional"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 
 export function SessionSidePanel(props: {
   reviewPanel: () => JSX.Element
@@ -54,14 +55,13 @@ export function SessionSidePanel(props: {
   })
   const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-  const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+  const info = createMemo(() => Optional.map(params.id, (id) => sync.session.get(id)))
+  const diffs = createMemo(() => Optional.map(params.id, (id) => sync.data.session_diff[id]) ?? [])
   const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
   const hasReview = createMemo(() => reviewCount() > 0)
   const diffsReady = createMemo(() => {
     const id = params.id
-    if (!id) return true
-    if (!hasReview()) return true
+    if (!id || !hasReview()) return true
     return sync.data.session_diff[id] !== undefined
   })
 

+ 53 - 45
packages/app/src/pages/session/use-session-commands.tsx

@@ -1,8 +1,15 @@
-import { useNavigate } from "@solidjs/router"
-import { useCommand, type CommandOption } from "@/context/command"
+import type { UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
-import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
+import { showToast } from "@opencode-ai/ui/toast"
+import { findLast } from "@opencode-ai/util/array"
+import { useNavigate } from "@solidjs/router"
+import { DialogFork } from "@/components/dialog-fork"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { type CommandOption, useCommand } from "@/context/command"
+import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
@@ -11,16 +18,10 @@ 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 { createSessionTabs } from "@/pages/session/helpers"
-import { extractPromptFromParts } from "@/utils/prompt"
-import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { Optional } from "@/utils/optional"
+import { extractPromptFromParts } from "@/utils/prompt"
 
 export type SessionCommandContext = {
   navigateMessageByOffset: (offset: number) => void
@@ -51,11 +52,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const navigate = useNavigate()
   const { params, tabs, view } = useSessionLayout()
 
-  const info = () => {
-    const id = params.id
-    if (!id) return
-    return sync.session.get(id)
-  }
+  const info = () => Optional.map(params.id, (id) => sync.session.get(id))
+
   const hasReview = () => {
     const id = params.id
     if (!id) return false
@@ -76,12 +74,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const closableTab = tabState.closableTab
 
   const idle = { type: "idle" as const }
-  const status = () => sync.data.session_status[params.id ?? ""] ?? idle
-  const messages = () => {
-    const id = params.id
-    if (!id) return []
-    return sync.data.message[id] ?? []
-  }
+  const status = () => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle
+  const messages = () => Optional.map(params.id, (id) => sync.data.message[id]) ?? []
+
   const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
   const visibleUserMessages = () => {
     const revert = info()?.revert?.messageID
@@ -97,7 +92,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const selectionPreview = (path: string, selection: FileSelection) => {
     const content = file.get(path)?.content?.content
     if (!content) return undefined
-    return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
+    return previewSelectedLines(content, {
+      start: selection.startLine,
+      end: selection.endLine,
+    })
   }
 
   const addSelectionToContext = (path: string, selection: FileSelection) => {
@@ -128,8 +126,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const permissionsCommand = withCategory(language.t("command.category.permissions"))
 
   const isAutoAcceptActive = () => {
-    const sessionID = params.id
-    if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
+    const id = params.id
+    if (id) return permission.isAutoAccepting(id, sdk.directory)
     return permission.isAutoAcceptingDirectory(sdk.directory)
   }
   command.register("session", () => {
@@ -148,7 +146,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
               slash: "share",
               disabled: !params.id,
               onSelect: async () => {
-                if (!params.id) return
+                const id = params.id
+                if (!id) return
 
                 const write = (value: string) => {
                   const body = typeof document === "undefined" ? undefined : document.body
@@ -200,7 +199,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
                 }
 
                 const url = await sdk.client.session
-                  .share({ sessionID: params.id })
+                  .share({ sessionID: id })
                   .then((res) => res.data?.share?.url)
                   .catch(() => undefined)
                 if (!url) {
@@ -222,9 +221,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
               slash: "unshare",
               disabled: !params.id || !info()?.share?.url,
               onSelect: async () => {
-                if (!params.id) return
+                const id = params.id
+                if (!id) return
                 await sdk.client.session
-                  .unshare({ sessionID: params.id })
+                  .unshare({ sessionID: id })
                   .then(() =>
                     showToast({
                       title: language.t("toast.session.unshare.success.title"),
@@ -391,12 +391,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         keybind: "mod+shift+a",
         disabled: false,
         onSelect: () => {
-          const sessionID = params.id
-          if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
+          const id = params.id
+          if (id) permission.toggleAutoAccept(id, sdk.directory)
           else permission.toggleAutoAcceptDirectory(sdk.directory)
 
-          const active = sessionID
-            ? permission.isAutoAccepting(sessionID, sdk.directory)
+          const active = id
+            ? permission.isAutoAccepting(id, sdk.directory)
             : permission.isAutoAcceptingDirectory(sdk.directory)
           showToast({
             title: active
@@ -415,18 +415,23 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         slash: "undo",
         disabled: !params.id || visibleUserMessages().length === 0,
         onSelect: async () => {
-          const sessionID = params.id
-          if (!sessionID) return
+          const id = params.id
+          if (!id) return
           if (status().type !== "idle") {
-            await sdk.client.session.abort({ sessionID }).catch(() => {})
+            await sdk.client.session.abort({ sessionID: id }).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 })
+          await sdk.client.session.revert({
+            sessionID: id,
+            messageID: message.id,
+          })
           const parts = sync.data.part[message.id]
           if (parts) {
-            const restored = extractPromptFromParts(parts, { directory: sdk.directory })
+            const restored = extractPromptFromParts(parts, {
+              directory: sdk.directory,
+            })
             prompt.set(restored)
           }
           const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
@@ -440,19 +445,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         slash: "redo",
         disabled: !params.id || !info()?.revert?.messageID,
         onSelect: async () => {
-          const sessionID = params.id
-          if (!sessionID) return
+          const id = params.id
+          if (!id) 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 })
+            await sdk.client.session.unrevert({ sessionID: id })
             prompt.reset()
             const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
             setActiveMessage(lastMsg)
             return
           }
-          await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+          await sdk.client.session.revert({
+            sessionID: id,
+            messageID: nextMessage.id,
+          })
           const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
           setActiveMessage(priorMsg)
         },
@@ -464,8 +472,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         slash: "compact",
         disabled: !params.id || visibleUserMessages().length === 0,
         onSelect: async () => {
-          const sessionID = params.id
-          if (!sessionID) return
+          const id = params.id
+          if (!id) return
           const model = local.model.current()
           if (!model) {
             showToast({
@@ -475,7 +483,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
             return
           }
           await sdk.client.session.summarize({
-            sessionID,
+            sessionID: id,
             modelID: model.id,
             providerID: model.provider.id,
           })

+ 4 - 2
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -2,6 +2,7 @@ import type { UserMessage } from "@opencode-ai/sdk/v2"
 import { useLocation, useNavigate } from "@solidjs/router"
 import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { messageIdFromHash } from "./message-id-from-hash"
+import { useSessionLayout } from "./session-layout"
 
 export const useSessionHashScroll = (input: {
   sessionKey: () => string
@@ -28,6 +29,7 @@ export const useSessionHashScroll = (input: {
 
   const location = useLocation()
   const navigate = useNavigate()
+  const { params } = useSessionLayout()
 
   const frames = new Set<number>()
   const queue = (fn: () => void) => {
@@ -142,13 +144,13 @@ export const useSessionHashScroll = (input: {
   createEffect(() => {
     const hash = location.hash
     if (!hash) clearing = false
-    if (!input.sessionID() || !input.messagesReady()) return
+    if (!params.id || !input.messagesReady()) return
     cancel()
     queue(() => applyHash("auto"))
   })
 
   createEffect(() => {
-    if (!input.sessionID() || !input.messagesReady()) return
+    if (!params.id || !input.messagesReady()) return
 
     visibleUserMessages()
     input.turnStart()

+ 11 - 0
packages/app/src/utils/optional.ts

@@ -0,0 +1,11 @@
+import { dual } from "effect/Function"
+
+export namespace Optional {
+  export const map = dual<
+    <I, O>(f: (value: I) => O) => (opt: I | undefined) => O | undefined,
+    <I, O>(opt: I | undefined, f: (value: I) => O) => O | undefined
+  >(2, (opt, f) => {
+    if (opt === undefined) return undefined
+    return f(opt)
+  })
+}

+ 2 - 2
packages/function/src/api.ts

@@ -1,9 +1,9 @@
-import { Hono } from "hono"
 import { DurableObject } from "cloudflare:workers"
 import { randomUUID } from "node:crypto"
-import { jwtVerify, createRemoteJWKSet } from "jose"
 import { createAppAuth } from "@octokit/auth-app"
 import { Octokit } from "@octokit/rest"
+import { Hono } from "hono"
+import { createRemoteJWKSet, jwtVerify } from "jose"
 import { Resource } from "sst"
 
 type Env = {

+ 6 - 6
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -1,15 +1,15 @@
-import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect } from "@tui/ui/dialog-select"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
-import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { createMemo, createResource, createSignal, onMount } from "solid-js"
 import { Locale } from "@/util/locale"
 import { useKeybind } from "../context/keybind"
-import { useTheme } from "../context/theme"
-import { useSDK } from "../context/sdk"
-import { DialogSessionRename } from "./dialog-session-rename"
 import { useKV } from "../context/kv"
+import { useSDK } from "../context/sdk"
+import { useTheme } from "../context/theme"
 import { createDebouncedSignal } from "../util/signal"
+import { DialogSessionRename } from "./dialog-session-rename"
 import { Spinner } from "./spinner"
 
 export function DialogSessionList() {

+ 7 - 7
packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx

@@ -1,17 +1,17 @@
-import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect } from "@tui/ui/dialog-select"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
-import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { createMemo, createResource, createSignal, onMount } from "solid-js"
 import { Locale } from "@/util/locale"
 import { useKeybind } from "../../context/keybind"
-import { useTheme } from "../../context/theme"
-import { useSDK } from "../../context/sdk"
-import { DialogSessionRename } from "../dialog-session-rename"
 import { useKV } from "../../context/kv"
+import { useSDK } from "../../context/sdk"
+import { useTheme } from "../../context/theme"
+import { useToast } from "../../ui/toast"
 import { createDebouncedSignal } from "../../util/signal"
+import { DialogSessionRename } from "../dialog-session-rename"
 import { Spinner } from "../spinner"
-import { useToast } from "../../ui/toast"
 
 export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
   const dialog = useDialog()

+ 91 - 46
packages/ui/src/components/message-part.tsx

@@ -1,60 +1,59 @@
+import type {
+  AgentPart,
+  AssistantMessage,
+  FilePart,
+  Message as MessageType,
+  Part as PartType,
+  QuestionAnswer,
+  QuestionInfo,
+  ReasoningPart,
+  TextPart,
+  Todo,
+  ToolPart,
+  UserMessage,
+} from "@opencode-ai/sdk/v2"
+import { checksum } from "@opencode-ai/util/encode"
+import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
+import { useLocation } from "@solidjs/router"
+import { animate } from "motion"
 import {
-  Component,
+  type Component,
   createEffect,
   createMemo,
   createSignal,
   For,
+  Index,
+  type JSX,
   Match,
+  onCleanup,
   onMount,
   Show,
   Switch,
-  onCleanup,
-  Index,
-  type JSX,
 } from "solid-js"
 import { createStore } from "solid-js/store"
-import stripAnsi from "strip-ansi"
 import { Dynamic } from "solid-js/web"
-import {
-  AgentPart,
-  AssistantMessage,
-  FilePart,
-  Message as MessageType,
-  Part as PartType,
-  ReasoningPart,
-  TextPart,
-  ToolPart,
-  UserMessage,
-  Todo,
-  QuestionAnswer,
-  QuestionInfo,
-} from "@opencode-ai/sdk/v2"
+import stripAnsi from "strip-ansi"
 import { useData } from "../context"
-import { useFileComponent } from "../context/file"
 import { useDialog } from "../context/dialog"
+import { useFileComponent } from "../context/file"
 import { type UiI18n, useI18n } from "../context/i18n"
-import { BasicTool, GenericTool } from "./basic-tool"
 import { Accordion } from "./accordion"
-import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { Card } from "./card"
+import { BasicTool, GenericTool } from "./basic-tool"
+import { Checkbox } from "./checkbox"
 import { Collapsible } from "./collapsible"
+import { DiffChanges } from "./diff-changes"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
-import { ToolErrorCard } from "./tool-error-card"
-import { Checkbox } from "./checkbox"
-import { DiffChanges } from "./diff-changes"
-import { Markdown } from "./markdown"
-import { ImagePreview } from "./image-preview"
-import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
-import { checksum } from "@opencode-ai/util/encode"
-import { Tooltip } from "./tooltip"
 import { IconButton } from "./icon-button"
+import { ImagePreview } from "./image-preview"
+import { Markdown } from "./markdown"
+import { attached, inline, kind } from "./message-file"
+import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { TextShimmer } from "./text-shimmer"
 import { AnimatedCountList } from "./tool-count-summary"
+import { ToolErrorCard } from "./tool-error-card"
 import { ToolStatusTitle } from "./tool-status-title"
-import { animate } from "motion"
-import { useLocation } from "@solidjs/router"
-import { attached, inline, kind } from "./message-file"
+import { Tooltip } from "./tooltip"
 
 function ShellSubmessage(props: { text: string; animate?: boolean }) {
   let widthRef: HTMLSpanElement | undefined
@@ -1087,10 +1086,18 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents:
     const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [
       ...props.references
         .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined)
-        .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })),
+        .map((r) => ({
+          start: r.source!.text!.start,
+          end: r.source!.text!.end,
+          type: "file" as const,
+        })),
       ...props.agents
         .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined)
-        .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })),
+        .map((a) => ({
+          start: a.source!.start,
+          end: a.source!.end,
+          type: "agent" as const,
+        })),
     ].sort((a, b) => a.start - b.start)
 
     const result: HighlightSegment[] = []
@@ -1334,7 +1341,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
           : -1
     if (!(ms >= 0)) return ""
     const total = Math.round(ms / 1000)
-    if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
+    if (total < 60)
+      return i18n.t("ui.message.duration.seconds", {
+        count: numfmt().format(total),
+      })
     const minutes = Math.floor(total / 60)
     const seconds = total % 60
     return i18n.t("ui.message.duration.minutesSeconds", {
@@ -1475,7 +1485,10 @@ ToolRegistry.register({
       <BasicTool
         {...props}
         icon="bullet-list"
-        trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
+        trigger={{
+          title: i18n.t("ui.tool.list"),
+          subtitle: getDirectory(props.input.path || "/"),
+        }}
       >
         <Show when={props.output}>
           <div data-component="tool-output" data-scrollable>
@@ -1974,7 +1987,12 @@ ToolRegistry.register({
                             <Accordion.Trigger>
                               <div data-slot="apply-patch-trigger-content">
                                 <div data-slot="apply-patch-file-info">
-                                  <FileIcon node={{ path: file.relativePath, type: "file" }} />
+                                  <FileIcon
+                                    node={{
+                                      path: file.relativePath,
+                                      type: "file",
+                                    }}
+                                  />
                                   <div data-slot="apply-patch-file-name-container">
                                     <Show when={file.relativePath.includes("/")}>
                                       <span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
@@ -2000,7 +2018,12 @@ ToolRegistry.register({
                                       </span>
                                     </Match>
                                     <Match when={true}>
-                                      <DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
+                                      <DiffChanges
+                                        changes={{
+                                          additions: file.additions,
+                                          deletions: file.deletions,
+                                        }}
+                                      />
                                     </Match>
                                   </Switch>
                                   <Icon name="chevron-grabber-vertical" size="small" />
@@ -2014,8 +2037,14 @@ ToolRegistry.register({
                                 <Dynamic
                                   component={fileComponent}
                                   mode="diff"
-                                  before={{ name: file.filePath, contents: file.before }}
-                                  after={{ name: file.movePath ?? file.filePath, contents: file.after }}
+                                  before={{
+                                    name: file.filePath,
+                                    contents: file.before,
+                                  }}
+                                  after={{
+                                    name: file.movePath ?? file.filePath,
+                                    contents: file.after,
+                                  }}
                                 />
                               </div>
                             </Show>
@@ -2054,7 +2083,12 @@ ToolRegistry.register({
                 </div>
                 <div data-slot="message-part-actions">
                   <Show when={!pending()}>
-                    <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
+                    <DiffChanges
+                      changes={{
+                        additions: single()!.additions,
+                        deletions: single()!.deletions,
+                      }}
+                    />
                   </Show>
                 </div>
               </div>
@@ -2080,7 +2114,12 @@ ToolRegistry.register({
                     </span>
                   </Match>
                   <Match when={true}>
-                    <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
+                    <DiffChanges
+                      changes={{
+                        additions: single()!.additions,
+                        deletions: single()!.deletions,
+                      }}
+                    />
                   </Match>
                 </Switch>
               }
@@ -2089,8 +2128,14 @@ ToolRegistry.register({
                 <Dynamic
                   component={fileComponent}
                   mode="diff"
-                  before={{ name: single()!.filePath, contents: single()!.before }}
-                  after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }}
+                  before={{
+                    name: single()!.filePath,
+                    contents: single()!.before,
+                  }}
+                  after={{
+                    name: single()!.movePath ?? single()!.filePath,
+                    contents: single()!.after,
+                  }}
                 />
               </div>
             </ToolFileAccordion>