Browse Source

chore: cleanup (#17184)

Adam 1 month ago
parent
commit
12efbbfa4c

+ 3 - 5
packages/app/src/components/dialog-select-file.tsx

@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
 import { List } from "@opencode-ai/ui/list"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useNavigate } from "@solidjs/router"
 import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
 import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
 import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,6 +14,7 @@ import { useGlobalSync } from "@/context/global-sync"
 import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
+import { useSessionLayout } from "@/pages/session/session-layout"
 import { decode64 } from "@/utils/base64"
 import { getRelativeTime } from "@/utils/time"
 
@@ -259,14 +260,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
   const layout = useLayout()
   const file = useFile()
   const dialog = useDialog()
-  const params = useParams()
   const navigate = useNavigate()
   const globalSDK = useGlobalSDK()
   const globalSync = useGlobalSync()
+  const { params, tabs, view } = useSessionLayout()
   const filesOnly = () => props.mode === "files"
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
   const state = { cleanup: undefined as (() => void) | void, committed: false }
   const [grouped, setGrouped] = createSignal(false)
   const commandEntries = createCommandEntries({ filesOnly, command, language })

+ 18 - 48
packages/app/src/components/prompt-input.tsx

@@ -17,7 +17,6 @@ import {
 } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
-import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { Button } from "@opencode-ai/ui/button"
@@ -37,6 +36,7 @@ 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 { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
 import {
@@ -102,13 +102,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const prompt = usePrompt()
   const layout = useLayout()
   const comments = useComments()
-  const params = useParams()
   const dialog = useDialog()
   const providers = useProviders()
   const command = useCommand()
   const permission = usePermission()
   const language = useLanguage()
   const platform = usePlatform()
+  const { params, tabs, view } = useSessionLayout()
   let editorRef!: HTMLDivElement
   let fileInputRef: HTMLInputElement | undefined
   let scrollRef!: HTMLDivElement
@@ -154,10 +154,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     requestAnimationFrame(scrollCursorIntoView)
   }
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
-
   const commentInReview = (path: string) => {
     const sessionID = params.id
     if (!sessionID) return false
@@ -255,6 +251,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   })
 
   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})`,
+    filter: `blur(${(1 - value) * 2}px)`,
+    "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
+  })
+  const buttons = createMemo(() => motion(buttonsSpring()))
+  const shell = createMemo(() => motion(1 - buttonsSpring()))
+  const control = createMemo(() => ({ height: "28px", ...buttons() }))
 
   const commentCount = createMemo(() => {
     if (store.mode === "shell") return 0
@@ -1275,11 +1280,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   type="button"
                   variant="ghost"
                   class="size-8 p-0"
-                  style={{
-                    opacity: buttonsSpring(),
-                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                  }}
+                  style={buttons()}
                   onClick={pick}
                   disabled={store.mode !== "normal"}
                   tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1317,11 +1318,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   icon={working() ? "stop" : "arrow-up"}
                   variant="primary"
                   class="size-8"
-                  style={{
-                    opacity: buttonsSpring(),
-                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                  }}
+                  style={buttons()}
                   aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
                 />
               </Tooltip>
@@ -1379,10 +1376,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
                 style={{
                   padding: "0 4px 0 8px",
-                  opacity: 1 - buttonsSpring(),
-                  transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
-                  filter: `blur(${buttonsSpring() * 2}px)`,
-                  "pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
+                  ...shell(),
                 }}
               >
                 <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
@@ -1402,13 +1396,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={local.agent.set}
                     class="capitalize max-w-[160px]"
                     valueClass="truncate text-13-regular"
-                    triggerStyle={{
-                      height: "28px",
-                      opacity: buttonsSpring(),
-                      transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                      filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                      "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                    }}
+                    triggerStyle={control()}
                     variant="ghost"
                   />
                 </TooltipKeybind>
@@ -1426,13 +1414,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         variant="ghost"
                         size="normal"
                         class="min-w-0 max-w-[320px] text-13-regular group"
-                        style={{
-                          height: "28px",
-                          opacity: buttonsSpring(),
-                          transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                          filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                          "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                        }}
+                        style={control()}
                         onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
                       >
                         <Show when={local.model.current()?.provider?.id}>
@@ -1461,13 +1443,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       triggerProps={{
                         variant: "ghost",
                         size: "normal",
-                        style: {
-                          height: "28px",
-                          opacity: buttonsSpring(),
-                          transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                          filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                          "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                        },
+                        style: control(),
                         class: "min-w-0 max-w-[320px] text-13-regular group",
                       }}
                     >
@@ -1499,13 +1475,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
                     class="capitalize max-w-[160px]"
                     valueClass="truncate text-13-regular"
-                    triggerStyle={{
-                      height: "28px",
-                      opacity: buttonsSpring(),
-                      transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
-                      filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
-                      "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
-                    }}
+                    triggerStyle={control()}
                     variant="ghost"
                   />
                 </TooltipKeybind>

+ 2 - 5
packages/app/src/components/session-context-usage.tsx

@@ -2,12 +2,12 @@ import { Match, Show, Switch, createMemo } from "solid-js"
 import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { Button } from "@opencode-ai/ui/button"
-import { useParams } from "@solidjs/router"
 
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
+import { useSessionLayout } from "@/pages/session/session-layout"
 
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
@@ -27,14 +27,11 @@ function openSessionContext(args: {
 
 export function SessionContextUsage(props: SessionContextUsageProps) {
   const sync = useSync()
-  const params = useParams()
   const layout = useLayout()
   const language = useLanguage()
+  const { params, tabs, view } = useSessionLayout()
 
   const variant = createMemo(() => props.variant ?? "button")
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
 
   const usd = createMemo(

+ 2 - 6
packages/app/src/components/session/session-context-tab.tsx

@@ -1,8 +1,6 @@
 import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
 import type { JSX } from "solid-js"
-import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
-import { useLayout } from "@/context/layout"
 import { checksum } from "@opencode-ai/util/encode"
 import { findLast } from "@opencode-ai/util/array"
 import { same } from "@/utils/same"
@@ -14,6 +12,7 @@ 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 { useLanguage } from "@/context/language"
+import { useSessionLayout } from "@/pages/session/session-layout"
 import { getSessionContextMetrics } from "./session-context-metrics"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
 import { createSessionContextFormatter } from "./session-context-format"
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
 const emptyUserMessages: UserMessage[] = []
 
 export function SessionContextTab() {
-  const params = useParams()
   const sync = useSync()
-  const layout = useLayout()
   const language = useLanguage()
+  const { params, view } = useSessionLayout()
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const view = createMemo(() => layout.view(sessionKey))
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
 
   const messages = createMemo(

+ 3 - 8
packages/app/src/components/session/session-header.tsx

@@ -10,7 +10,6 @@ import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
 import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
@@ -23,6 +22,7 @@ import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { focusTerminalById } from "@/pages/session/helpers"
+import { useSessionLayout } from "@/pages/session/session-layout"
 import { decode64 } from "@/utils/base64"
 import { Persist, persisted } from "@/utils/persist"
 import { StatusPopover } from "../status-popover"
@@ -225,13 +225,13 @@ function useSessionShare(args: {
 export function SessionHeader() {
   const globalSDK = useGlobalSDK()
   const layout = useLayout()
-  const params = useParams()
   const command = useCommand()
   const server = useServer()
   const sync = useSync()
   const platform = usePlatform()
   const language = useLanguage()
   const terminal = useTerminal()
+  const { params, view } = useSessionLayout()
 
   const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
   const project = createMemo(() => {
@@ -249,8 +249,6 @@ export function SessionHeader() {
   const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
   const showShare = createMemo(() => shareEnabled() && !!params.id)
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const view = createMemo(() => layout.view(sessionKey))
   const os = createMemo(() => detectOS(platform))
 
   const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
@@ -282,10 +280,7 @@ export function SessionHeader() {
         Promise.resolve(platform.checkAppExists?.(app.openWith))
           .then((value) => Boolean(value))
           .catch(() => false)
-          .then((ok) => {
-            console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
-            return [app.id, ok] as const
-          }),
+          .then((ok) => [app.id, ok] as const),
       ),
     ).then((entries) => {
       setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)

+ 37 - 59
packages/app/src/pages/layout.tsx

@@ -2136,6 +2136,41 @@ export default function Layout(props: ParentProps) {
     )
   }
 
+  const projects = () => layout.projects.list()
+  const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
+  const sidebarContent = (mobile?: boolean) => (
+    <SidebarContent
+      mobile={mobile}
+      opened={() => layout.sidebar.opened()}
+      aimMove={aim.move}
+      projects={projects}
+      renderProject={(project) => (
+        <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
+      )}
+      handleDragStart={handleDragStart}
+      handleDragEnd={handleDragEnd}
+      handleDragOver={handleDragOver}
+      openProjectLabel={language.t("command.project.open")}
+      openProjectKeybind={() => command.keybind("project.open")}
+      onOpenProject={chooseProject}
+      renderProjectOverlay={projectOverlay}
+      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={() =>
+        mobile ? (
+          <SidebarPanel project={currentProject()} mobile />
+        ) : (
+          <Show when={currentProject()} keyed>
+            {(project) => <SidebarPanel project={project} merged />}
+          </Show>
+        )
+      }
+    />
+  )
+
   return (
     <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
       <Titlebar />
@@ -2164,38 +2199,7 @@ export default function Layout(props: ParentProps) {
                 arm()
               }}
             >
-              <div class="@container w-full h-full contain-strict">
-                <SidebarContent
-                  opened={() => layout.sidebar.opened()}
-                  aimMove={aim.move}
-                  projects={() => layout.projects.list()}
-                  renderProject={(project) => (
-                    <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
-                  )}
-                  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={() => (
-                    <Show when={currentProject()} keyed>
-                      {(project) => <SidebarPanel project={project} merged />}
-                    </Show>
-                  )}
-                />
-              </div>
+              <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
               <Show when={layout.sidebar.opened()}>
                 <div onPointerDown={() => setSizing(true)}>
                   <ResizeHandle
@@ -2242,33 +2246,7 @@ export default function Layout(props: ParentProps) {
                 }}
                 onClick={(e) => e.stopPropagation()}
               >
-                <SidebarContent
-                  mobile
-                  opened={() => layout.sidebar.opened()}
-                  aimMove={aim.move}
-                  projects={() => layout.projects.list()}
-                  renderProject={(project) => (
-                    <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} 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 />}
-                />
+                {sidebarContent(true)}
               </nav>
             </div>
 

+ 2 - 2
packages/app/src/pages/layout/helpers.test.ts

@@ -9,10 +9,10 @@ import {
 import { type Session } from "@opencode-ai/sdk/v2/client"
 import {
   displayName,
+  effectiveWorkspaceOrder,
   errorMessage,
   hasProjectPermissions,
   latestRootSession,
-  syncWorkspaceOrder,
   workspaceKey,
 } from "./helpers"
 
@@ -116,7 +116,7 @@ describe("layout workspace helpers", () => {
   })
 
   test("keeps local first while preserving known order", () => {
-    const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
+    const result = effectiveWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
     expect(result).toEqual(["/root", "/c", "/b"])
   })
 

+ 2 - 4
packages/app/src/pages/layout/helpers.ts

@@ -8,7 +8,7 @@ export const workspaceKey = (directory: string) => {
   return directory.replace(/[\\/]+$/, "")
 }
 
-export function sortSessions(now: number) {
+function sortSessions(now: number) {
   const oneMinuteAgo = now - 60 * 1000
   return (a: Session, b: Session) => {
     const aUpdated = a.time.updated ?? a.time.created
@@ -22,7 +22,7 @@ export function sortSessions(now: number) {
   }
 }
 
-export const isRootVisibleSession = (session: Session, directory: string) =>
+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) =>
@@ -90,5 +90,3 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
 
   return [...result, ...live.values()]
 }
-
-export const syncWorkspaceOrder = effectiveWorkspaceOrder

+ 5 - 10
packages/app/src/pages/session.tsx

@@ -20,10 +20,11 @@ import { createStore } from "solid-js/store"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Select } from "@opencode-ai/ui/select"
 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, useParams, useSearchParams } from "@solidjs/router"
+import { useNavigate, useSearchParams } from "@solidjs/router"
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { useComments } from "@/context/comments"
 import { useGlobalSync } from "@/context/global-sync"
@@ -37,6 +38,7 @@ import { createSessionComposerState, SessionComposerRegion } from "@/pages/sessi
 import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
+import { useSessionLayout } from "@/pages/session/session-layout"
 import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
@@ -264,13 +266,13 @@ export default function Page() {
   const sync = useSync()
   const dialog = useDialog()
   const language = useLanguage()
-  const params = useParams()
   const navigate = useNavigate()
   const sdk = useSDK()
   const prompt = usePrompt()
   const comments = useComments()
   const terminal = useTerminal()
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
+  const { params, sessionKey, tabs, view } = useSessionLayout()
 
   createEffect(() => {
     if (!untrack(() => prompt.ready())) return
@@ -298,11 +300,8 @@ export default function Page() {
 
   const composer = createSessionComposerState()
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const workspaceKey = createMemo(() => params.dir ?? "")
   const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
 
   createEffect(
     on(
@@ -670,11 +669,7 @@ export default function Page() {
   const selectionPreview = (path: string, selection: FileSelection) => {
     const content = file.get(path)?.content?.content
     if (!content) return undefined
-    const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
-    const end = Math.max(selection.startLine, selection.endLine)
-    const lines = content.split("\n").slice(start - 1, end)
-    if (lines.length === 0) return undefined
-    return lines.slice(0, 2).join("\n")
+    return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
   }
 
   const addCommentToContext = (input: {

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

@@ -1,11 +1,10 @@
 import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
-import { useParams } from "@solidjs/router"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { PromptInput } from "@/components/prompt-input"
 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 { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
 import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
 import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
@@ -27,29 +26,11 @@ export function SessionComposerRegion(props: {
     onRestore: (id: string) => void
   }
   setPromptDockRef: (el: HTMLDivElement) => void
-  visualDuration?: number
-  bounce?: number
-  dockOpenVisualDuration?: number
-  dockOpenBounce?: number
-  dockCloseVisualDuration?: number
-  dockCloseBounce?: number
-  drawerExpandVisualDuration?: number
-  drawerExpandBounce?: number
-  drawerCollapseVisualDuration?: number
-  drawerCollapseBounce?: number
-  subtitleDuration?: number
-  subtitleTravel?: number
-  subtitleEdge?: number
-  countDuration?: number
-  countMask?: number
-  countMaskHeight?: number
-  countWidthDuration?: number
 }) {
-  const params = useParams()
   const prompt = usePrompt()
   const language = useLanguage()
+  const { sessionKey } = useSessionKey()
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
 
   const previewPrompt = () =>
@@ -69,9 +50,7 @@ export function SessionComposerRegion(props: {
     setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
   })
 
-  const [gate, setGate] = createStore({
-    ready: false,
-  })
+  const [ready, setReady] = createSignal(false)
   let timer: number | undefined
   let frame: number | undefined
 
@@ -88,17 +67,17 @@ export function SessionComposerRegion(props: {
 
   createEffect(() => {
     sessionKey()
-    const ready = props.ready
+    const active = props.ready
     const delay = 140
 
     clear()
-    setGate("ready", false)
-    if (!ready) return
+    setReady(false)
+    if (!active) return
 
     frame = requestAnimationFrame(() => {
       frame = undefined
       timer = window.setTimeout(() => {
-        setGate("ready", true)
+        setReady(true)
         timer = undefined
       }, delay)
     })
@@ -106,22 +85,11 @@ export function SessionComposerRegion(props: {
 
   onCleanup(clear)
 
-  const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
-  const config = createMemo(() =>
-    open()
-      ? {
-          visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.dockOpenBounce ?? props.bounce ?? 0,
-        }
-      : {
-          visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.dockCloseBounce ?? props.bounce ?? 0,
-        },
-  )
-  const progress = useSpring(() => (open() ? 1 : 0), config)
+  const open = createMemo(() => ready() && props.state.dock() && !props.state.closing())
+  const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
   const value = createMemo(() => Math.max(0, Math.min(1, progress())))
   const [height, setHeight] = createSignal(320)
-  const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
+  const dock = createMemo(() => (ready() && props.state.dock()) || value() > 0.001)
   const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
   const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
   const full = createMemo(() => Math.max(78, height()))
@@ -213,19 +181,6 @@ export function SessionComposerRegion(props: {
                     collapseLabel={language.t("session.todo.collapse")}
                     expandLabel={language.t("session.todo.expand")}
                     dockProgress={value()}
-                    visualDuration={props.visualDuration}
-                    bounce={props.bounce}
-                    expandVisualDuration={props.drawerExpandVisualDuration}
-                    expandBounce={props.drawerExpandBounce}
-                    collapseVisualDuration={props.drawerCollapseVisualDuration}
-                    collapseBounce={props.drawerCollapseBounce}
-                    subtitleDuration={props.subtitleDuration}
-                    subtitleTravel={props.subtitleTravel}
-                    subtitleEdge={props.subtitleEdge}
-                    countDuration={props.countDuration}
-                    countMask={props.countMask}
-                    countMaskHeight={props.countMaskHeight}
-                    countWidthDuration={props.countWidthDuration}
                   />
                 </div>
               </div>

+ 17 - 45
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -7,7 +7,6 @@ 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, createSignal, on, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
 
 function dot(status: Todo["status"]) {
   if (status !== "in_progress") return undefined
@@ -39,26 +38,10 @@ export function SessionTodoDock(props: {
   title: string
   collapseLabel: string
   expandLabel: string
-  dockProgress?: number
-  visualDuration?: number
-  bounce?: number
-  expandVisualDuration?: number
-  expandBounce?: number
-  collapseVisualDuration?: number
-  collapseBounce?: number
-  subtitleDuration?: number
-  subtitleTravel?: number
-  subtitleEdge?: number
-  countDuration?: number
-  countMask?: number
-  countMaskHeight?: number
-  countWidthDuration?: number
+  dockProgress: number
 }) {
-  const [store, setStore] = createStore({
-    collapsed: false,
-  })
-
-  const toggle = () => setStore("collapsed", (value) => !value)
+  const [collapsed, setCollapsed] = createSignal(false)
+  const toggle = () => setCollapsed((value) => !value)
 
   const total = createMemo(() => props.todos.length)
   const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
@@ -73,19 +56,8 @@ export function SessionTodoDock(props: {
   )
 
   const preview = createMemo(() => active()?.content ?? "")
-  const config = createMemo(() =>
-    store.collapsed
-      ? {
-          visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.collapseBounce ?? props.bounce ?? 0,
-        }
-      : {
-          visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
-          bounce: props.expandBounce ?? props.bounce ?? 0,
-        },
-  )
-  const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
-  const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
+  const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
+  const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
   const shut = createMemo(() => 1 - dock())
   const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
   const hide = createMemo(() => Math.max(value(), shut()))
@@ -133,10 +105,10 @@ export function SessionTodoDock(props: {
             class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
             aria-label={label()}
             style={{
-              "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
-              "--tool-motion-mask": `${props.countMask ?? 18}%`,
-              "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
-              "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
+              "--tool-motion-odometer-ms": "600ms",
+              "--tool-motion-mask": "18%",
+              "--tool-motion-mask-height": "0px",
+              "--tool-motion-spring-ms": "560ms",
               opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
             }}
           >
@@ -155,10 +127,10 @@ export function SessionTodoDock(props: {
           >
             <TextReveal
               class="text-14-regular text-text-base cursor-default"
-              text={store.collapsed ? preview() : undefined}
-              duration={props.subtitleDuration ?? 600}
-              travel={props.subtitleTravel ?? 25}
-              edge={props.subtitleEdge ?? 17}
+              text={collapsed() ? preview() : undefined}
+              duration={600}
+              travel={25}
+              edge={17}
               spring="cubic-bezier(0.34, 1, 0.64, 1)"
               springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
               growOnly
@@ -168,7 +140,7 @@ export function SessionTodoDock(props: {
           <div class="ml-auto">
             <IconButton
               data-action="session-todo-toggle-button"
-              data-collapsed={store.collapsed ? "true" : "false"}
+              data-collapsed={collapsed() ? "true" : "false"}
               icon="chevron-down"
               size="normal"
               variant="ghost"
@@ -181,14 +153,14 @@ export function SessionTodoDock(props: {
                 event.stopPropagation()
                 toggle()
               }}
-              aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
+              aria-label={collapsed() ? props.expandLabel : props.collapseLabel}
             />
           </div>
         </div>
 
         <div
           data-slot="session-todo-list"
-          aria-hidden={store.collapsed || off()}
+          aria-hidden={collapsed() || off()}
           classList={{
             "pointer-events-none": hide() > 0.1,
           }}
@@ -197,7 +169,7 @@ export function SessionTodoDock(props: {
             opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
           }}
         >
-          <TodoList todos={props.todos} open={!store.collapsed} />
+          <TodoList todos={props.todos} open={!collapsed()} />
         </div>
       </div>
     </DockTray>

+ 2 - 8
packages/app/src/pages/session/file-tabs.tsx

@@ -1,7 +1,6 @@
 import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
-import { useParams } from "@solidjs/router"
 import type { FileSearchHandle } from "@opencode-ai/ui/file"
 import { useFileComponent } from "@opencode-ai/ui/context/file"
 import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -12,12 +11,12 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { showToast } from "@opencode-ai/ui/toast"
-import { useLayout } from "@/context/layout"
 import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
 import { useComments } from "@/context/comments"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
 import { getSessionHandoff } from "@/pages/session/handoff"
+import { useSessionLayout } from "@/pages/session/session-layout"
 
 function FileCommentMenu(props: {
   moreLabel: string
@@ -53,17 +52,12 @@ function FileCommentMenu(props: {
 }
 
 export function FileTabContent(props: { tab: string }) {
-  const params = useParams()
-  const layout = useLayout()
   const file = useFile()
   const comments = useComments()
   const language = useLanguage()
   const prompt = usePrompt()
   const fileComponent = useFileComponent()
-
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
+  const { sessionKey, tabs, view } = useSessionLayout()
 
   let scroll: HTMLDivElement | undefined
   let scrollFrame: number | undefined

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

@@ -1,6 +1,6 @@
 import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
 import { createStore, produce } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useNavigate } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -19,6 +19,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
 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 { useSettings } from "@/context/settings"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
@@ -213,16 +214,15 @@ export function MessageTimeline(props: {
 }) {
   let touchGesture: number | undefined
 
-  const params = useParams()
   const navigate = useNavigate()
   const sdk = useSDK()
   const sync = useSync()
   const settings = useSettings()
   const dialog = useDialog()
   const language = useLanguage()
+  const { params, sessionKey } = useSessionKey()
 
   const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionID = createMemo(() => params.id)
   const sessionMessages = createMemo(() => {
     const id = sessionID()

+ 20 - 0
packages/app/src/pages/session/session-layout.ts

@@ -0,0 +1,20 @@
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+import { useLayout } from "@/context/layout"
+
+export const useSessionKey = () => {
+  const params = useParams()
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  return { params, sessionKey }
+}
+
+export const useSessionLayout = () => {
+  const layout = useLayout()
+  const { params, sessionKey } = useSessionKey()
+  return {
+    params,
+    sessionKey,
+    tabs: createMemo(() => layout.tabs(sessionKey)),
+    view: createMemo(() => layout.view(sessionKey)),
+  }
+}

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

@@ -1,7 +1,6 @@
 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 { useParams } from "@solidjs/router"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
@@ -26,6 +25,7 @@ import { FileTabContent } from "@/pages/session/file-tabs"
 import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 import { StickyAddButton } from "@/pages/session/review-tab"
 import { setSessionHandoff } from "@/pages/session/handoff"
+import { useSessionLayout } from "@/pages/session/session-layout"
 
 export function SessionSidePanel(props: {
   reviewPanel: () => JSX.Element
@@ -34,18 +34,15 @@ export function SessionSidePanel(props: {
   reviewSnap: boolean
   size: Sizing
 }) {
-  const params = useParams()
   const layout = useLayout()
   const sync = useSync()
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
   const dialog = useDialog()
+  const { params, sessionKey, tabs, view } = useSessionLayout()
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
 
   const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
   const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())

+ 2 - 5
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,6 +1,5 @@
 import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { useParams } from "@solidjs/router"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -18,16 +17,14 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { terminalTabLabel } from "@/pages/session/terminal-label"
 import { createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
+import { useSessionLayout } from "@/pages/session/session-layout"
 
 export function TerminalPanel() {
-  const params = useParams()
   const layout = useLayout()
   const terminal = useTerminal()
   const language = useLanguage()
   const command = useCommand()
-
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const view = createMemo(() => layout.view(sessionKey))
+  const { params, view } = useSessionLayout()
 
   const opened = createMemo(() => view().terminal.opened())
   const size = createSizing()

+ 5 - 10
packages/app/src/pages/session/use-session-commands.tsx

@@ -1,7 +1,8 @@
 import { createMemo } from "solid-js"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useNavigate } from "@solidjs/router"
 import { useCommand, type CommandOption } from "@/context/command"
 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 { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
@@ -19,6 +20,7 @@ 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 { useSessionLayout } from "@/pages/session/session-layout"
 
 export type SessionCommandContext = {
   navigateMessageByOffset: (offset: number) => void
@@ -45,12 +47,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const sync = useSync()
   const terminal = useTerminal()
   const layout = useLayout()
-  const params = useParams()
   const navigate = useNavigate()
+  const { params, tabs, view } = useSessionLayout()
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
 
   const idle = { type: "idle" as const }
@@ -71,11 +70,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const selectionPreview = (path: string, selection: FileSelection) => {
     const content = file.get(path)?.content?.content
     if (!content) return undefined
-    const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
-    const end = Math.max(selection.startLine, selection.endLine)
-    const lines = content.split("\n").slice(start - 1, end)
-    if (lines.length === 0) return undefined
-    return lines.slice(0, 2).join("\n")
+    return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
   }
 
   const addSelectionToContext = (path: string, selection: FileSelection) => {