瀏覽代碼

refactor(app): use native session info (#40824)

Brendan Allan 1 周之前
父節點
當前提交
a0c77b71ae
共有 49 個文件被更改,包括 248 次插入342 次删除
  1. 5 5
      packages/app/e2e/performance/timeline-stability/fixture.ts
  2. 2 1
      packages/app/e2e/performance/timeline-stability/interaction.spec.ts
  3. 1 1
      packages/app/src/app.tsx
  4. 2 4
      packages/app/src/components/command-palette.ts
  5. 4 5
      packages/app/src/components/prompt-input/submit.ts
  6. 1 1
      packages/app/src/components/settings-v2/dialog-settings-v2.tsx
  7. 1 1
      packages/app/src/components/settings-v2/general.tsx
  8. 10 6
      packages/app/src/components/titlebar-tab-nav.tsx
  9. 4 4
      packages/app/src/components/titlebar-tab-strip.tsx
  10. 1 3
      packages/app/src/components/titlebar.tsx
  11. 5 6
      packages/app/src/context/directory-sync.ts
  12. 5 8
      packages/app/src/context/global-sync/bootstrap.ts
  13. 3 3
      packages/app/src/context/global-sync/event-reducer.test.ts
  14. 8 10
      packages/app/src/context/global-sync/event-reducer.ts
  15. 7 9
      packages/app/src/context/global-sync/home-session-index.test.ts
  16. 16 31
      packages/app/src/context/global-sync/home-session-index.ts
  17. 1 2
      packages/app/src/context/global-sync/session-load.ts
  18. 2 3
      packages/app/src/context/global-sync/session-trim.test.ts
  19. 7 7
      packages/app/src/context/global-sync/session-trim.ts
  20. 2 2
      packages/app/src/context/global-sync/types.ts
  21. 2 3
      packages/app/src/context/permission-auto-respond.test.ts
  22. 1 1
      packages/app/src/context/permission.tsx
  23. 9 19
      packages/app/src/context/server-session.test.ts
  24. 11 13
      packages/app/src/context/server-session.ts
  25. 2 4
      packages/app/src/context/server-sync.test.ts
  26. 18 1
      packages/app/src/context/server-sync.tsx
  27. 5 4
      packages/app/src/context/tabs.tsx
  28. 2 2
      packages/app/src/pages/home-session-archive.test.ts
  29. 3 5
      packages/app/src/pages/home-session-archive.ts
  30. 2 1
      packages/app/src/pages/home/home-session-search-controller.ts
  31. 12 12
      packages/app/src/pages/home/home-sessions-controller.tsx
  32. 7 7
      packages/app/src/pages/home/home-sessions-view.tsx
  33. 17 17
      packages/app/src/pages/layout.tsx
  34. 8 6
      packages/app/src/pages/layout/helpers.test.ts
  35. 8 8
      packages/app/src/pages/layout/helpers.ts
  36. 19 15
      packages/app/src/pages/layout/sidebar-items.tsx
  37. 1 1
      packages/app/src/pages/layout/sidebar-project.tsx
  38. 5 5
      packages/app/src/pages/layout/sidebar-workspace.tsx
  39. 2 2
      packages/app/src/pages/session.tsx
  40. 2 3
      packages/app/src/pages/session/composer/session-composer-state.test.ts
  41. 4 4
      packages/app/src/pages/session/composer/session-request-tree.ts
  42. 1 1
      packages/app/src/pages/session/timeline/message-timeline.tsx
  43. 1 1
      packages/app/src/pages/session/use-session-commands.tsx
  44. 0 21
      packages/app/src/types.ts
  45. 7 0
      packages/app/src/utils/session-title.ts
  46. 1 37
      packages/app/src/utils/session.test.ts
  47. 2 29
      packages/app/src/utils/session.ts
  48. 4 5
      packages/session-ui/src/components/message-part.tsx
  49. 5 3
      packages/session-ui/src/context/data.tsx

+ 5 - 5
packages/app/e2e/performance/timeline-stability/fixture.ts

@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Event } from "@opencode-ai/schema/event"
 import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
 import { SessionV1 } from "@opencode-ai/schema/session-v1"
-import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
+import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
 import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
 import { expect, type Page } from "@playwright/test"
 import { Schema } from "effect"
@@ -18,7 +18,7 @@ export const assistantID = "msg_1001_timeline_assistant"
 export const title = "Timeline visual stability"
 export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
 
-type Session = SessionV1Info
+type Session = SessionInfo
 type GlobalEvent = {
   directory: string
   project?: string
@@ -530,11 +530,11 @@ export function project() {
 export function session(input: Partial<Session> = {}): Session {
   return {
     id: sessionID,
-    slug: "timeline-stability",
     projectID,
-    directory,
+    location: { directory },
     title,
-    version: "dev",
+    cost: 0,
+    tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
     time: { created: 1700000000000, updated: 1700000000000 },
     ...input,
   }

+ 2 - 1
packages/app/e2e/performance/timeline-stability/interaction.spec.ts

@@ -177,7 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
   const firstUser = userMessage(undefined, {
     summary: {
       diffs: Array.from({ length: 12 }, (_, index) => ({
-        file: `src/diff-${index}.ts`,
+         file: `src/diff-${index}.ts`,
+         status: "modified",
         additions: 1,
         deletions: 1,
         patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,

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

@@ -155,7 +155,7 @@ function LegacyTargetSessionRedirect() {
   )
 
   createEffect(() => {
-    const directory = current()?.session.directory
+    const directory = current()?.session.location.directory
     if (!directory) return
     navigate(legacySessionHref(directory, params.id), { replace: true })
   })

+ 2 - 4
packages/app/src/components/command-palette.ts

@@ -14,7 +14,6 @@ import { useTabs } from "@/context/tabs"
 import { displayName, projectForSession } from "@/pages/layout/helpers"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { normalizeSessionInfo } from "@/utils/session"
 
 export type CommandPaletteEntry = {
   id: string
@@ -257,7 +256,6 @@ export function createServerSessionEntries(props: {
       .load(search, current.signal)
       .then((result) =>
         result.data
-          .map(normalizeSessionInfo)
           .filter((session) => !session.time.archived)
           .map((session) => {
             const project =
@@ -266,9 +264,9 @@ export function createServerSessionEntries(props: {
               id: `session:${props.server}:${session.id}`,
               type: "session" as const,
               title: session.title || props.untitled(),
-              description: project ? displayName(project) : getFilename(session.directory),
+              description: project ? displayName(project) : getFilename(session.location.directory),
               category: props.category(),
-              directory: session.directory,
+              directory: session.location.directory,
               sessionID: session.id,
               server: props.server,
               project,

+ 4 - 5
packages/app/src/components/prompt-input/submit.ts

@@ -1,4 +1,5 @@
-import type { Message, Session } from "@/types"
+import type { Message } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { showToast } from "@/utils/toast"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Binary } from "@opencode-ai/core/util/binary"
@@ -21,7 +22,6 @@ import { setCursorPosition } from "./editor-dom"
 import { formatServerError } from "@/utils/server-errors"
 import { ScopedKey } from "@/utils/server-scope"
 import { createPromptSubmissionState } from "./submission-state"
-import { normalizeSessionInfo } from "@/utils/session"
 import { Event } from "@opencode-ai/schema/event"
 
 type PendingPrompt = {
@@ -310,10 +310,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
   }
 
-  const seed = (dir: string, info: Session) => {
+  const seed = (dir: string, info: SessionInfo) => {
     serverSync().session.remember(info)
     const [, setStore] = serverSync().child(dir)
-    setStore("session", (list: Session[]) => {
+    setStore("session", (list: SessionInfo[]) => {
       const result = Binary.search(list, info.id, (item) => item.id)
       const next = [...list]
       if (result.found) {
@@ -407,7 +407,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
           location: { directory: sessionDirectory },
         })
-        .then(normalizeSessionInfo)
         .catch((err) => {
           showToast({
             title: language.t("prompt.toast.sessionCreateFailed.title"),

+ 1 - 1
packages/app/src/components/settings-v2/dialog-settings-v2.tsx

@@ -33,7 +33,7 @@ export const DialogSettings: Component<{
       const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
       return draft?.type === "draft" ? draft.directory : undefined
     }
-    if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
+    if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
     return undefined
   })
 

+ 1 - 1
packages/app/src/components/settings-v2/general.tsx

@@ -96,7 +96,7 @@ export const SettingsGeneralV2: Component<{
 
   const dir = createMemo(() => {
     if (!props.sessionID) return undefined
-    return serverSync().session.lineage.peek(props.sessionID)?.session.directory
+    return serverSync().session.lineage.peek(props.sessionID)?.session.location.directory
   })
   const accepting = createMemo(() => {
     const value = dir()

+ 10 - 6
packages/app/src/components/titlebar-tab-nav.tsx

@@ -8,7 +8,8 @@ import { useGlobal } from "@/context/global"
 import { ServerConnection, serverName } from "@/context/server"
 import { displayName, projectForSession } from "@/pages/layout/helpers"
 import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
+import { sessionLabel } from "@/utils/session-title"
 import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
 import { TabPreviewPopover } from "./titlebar-tab-popover"
 import "./titlebar-tab-nav.css"
@@ -20,7 +21,7 @@ export function TabNavItem(props: {
   ref?: Ref<HTMLDivElement>
   href: string
   server: ServerConnection.Key
-  session: () => Session | undefined
+  session: () => SessionInfo | undefined
   fallbackTitle?: string
   onRename: (title: string) => Promise<void>
   onClose: () => void
@@ -54,18 +55,21 @@ export function TabNavItem(props: {
     if (!session) return
     return projectForSession(session, serverCtx()?.projects.list() ?? [])
   })
-  const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
+  const title = createMemo(() => {
+    const session = props.session()
+    return session ? sessionLabel(session) : props.fallbackTitle
+  })
 
   const projectName = createMemo(() => {
     const session = props.session()
     if (!session) return
-    return displayName(project() ?? { worktree: session.directory })
+    return displayName(project() ?? { worktree: session.location.directory })
   })
   const previewPath = createMemo(() => {
     const session = props.session()
     if (!session) return
     const home = serverCtx()?.sync.data.path.home
-    return home ? session.directory.replace(home, "~") : session.directory
+    return home ? session.location.directory.replace(home, "~") : session.location.directory
   })
   // Only label the server when multiple servers are connected.
   const serverLabel = createMemo(() => {
@@ -231,7 +235,7 @@ export function TabNavItem(props: {
             {(session) => (
               <SessionTabAvatar
                 project={project()}
-                directory={session().directory}
+                directory={session().location.directory}
                 sessionId={session().id}
                 server={props.server}
               />

+ 4 - 4
packages/app/src/components/titlebar-tab-strip.tsx

@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
 import { showToast } from "@/utils/toast"
 import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
 import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 
 function SessionTabSlot(props: {
   tab: SessionTab
@@ -27,7 +27,7 @@ function SessionTabSlot(props: {
   index: () => number
   active: () => boolean
   forceTruncate: boolean
-  session: () => Session | undefined
+  session: () => SessionInfo | undefined
   fallbackTitle?: string
   onRename: (title: string) => Promise<void>
   onNavigate: (element: HTMLDivElement) => void
@@ -127,7 +127,7 @@ function SessionTabEntry(props: {
     createRoot((dispose) => {
       try {
         void ctx.sync
-          .ensureDirSyncContext(value.directory)
+          .ensureDirSyncContext(value.location.directory)
           .session.sync(value.id)
           .catch(() => {})
           .finally(dispose)
@@ -144,7 +144,7 @@ function SessionTabEntry(props: {
     const current = sdk()
     if (!current) return
     createTabPromptState(tabs, props.tab, current.scope, {
-      dir: base64Encode(value.directory),
+      dir: base64Encode(value.location.directory),
       id: value.id,
     })
   })

+ 1 - 3
packages/app/src/components/titlebar.tsx

@@ -27,7 +27,6 @@ import { tabKey, useTabs } from "@/context/tabs"
 import type { PromptSession } from "@/context/prompt"
 import "./titlebar.css"
 import { newTabTooltipKeybind } from "./command-tooltip-keybind"
-import { normalizeSessionInfo } from "@/utils/session"
 
 const legacyTitlebarHeight = 40
 const v2TitlebarHeight = 36
@@ -194,7 +193,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
               ({ route, sdk }) =>
                 sdk.api.session
                   .get({ sessionID: route.sessionId })
-                  .then(normalizeSessionInfo)
                   .catch(() => {}),
             )
 
@@ -256,7 +254,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
                   sessionId: activeSession.id,
                 }
                 const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
-                tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
+                tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
                 return
               }
 

+ 5 - 6
packages/app/src/context/directory-sync.ts

@@ -1,11 +1,11 @@
 import { Binary } from "@opencode-ai/core/util/binary"
-import type { Message, Part, Session } from "@/types"
+import type { Message, Part } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { createMemo } from "solid-js"
 import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
 import type { createServerSdkContext } from "./server-sdk"
 import type { createServerSyncContextInner } from "./server-sync"
 import type { State } from "./global-sync/types"
-import { normalizeSessionInfo } from "@/utils/session"
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 const sessionFields = new Set([
@@ -46,7 +46,7 @@ export const createDirSyncContext = (
 
   const index = (sessionID: string) => {
     const session = serverSync.session.get(sessionID)
-    if (!session || session.directory !== directory) return
+    if (!session || session.location.directory !== directory) return
     const [store, setStore] = current()
     const result = Binary.search(store.session, session.id, (item) => item.id)
     if (result.found) {
@@ -74,13 +74,13 @@ export const createDirSyncContext = (
       if (match.found) return serverSync.data.project[match.index]
     },
     session: {
-      remember(session: Session) {
+      remember(session: SessionInfo) {
         serverSync.session.remember(session)
         index(session.id)
       },
       get(sessionID: string) {
         const session = serverSync.session.get(sessionID)
-        if (session?.directory === directory) return session
+        if (session?.location.directory === directory) return session
       },
       optimistic: {
         add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
@@ -125,7 +125,6 @@ export const createDirSyncContext = (
         setStore("limit", (value) => value + count)
         const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
         const sessions = response.data
-          .map(normalizeSessionInfo)
           .sort((a, b) => cmp(a.id, b.id))
           .slice(0, store.limit)
         sessions.forEach(serverSync.session.remember)

+ 5 - 8
packages/app/src/context/global-sync/bootstrap.ts

@@ -3,7 +3,6 @@ import type {
   Path,
   Project,
   ProviderAuthResponse,
-  Session,
 } from "@/types"
 import type {
   AgentListInput,
@@ -23,6 +22,7 @@ import type {
   ReferenceInfo,
   QuestionRequest,
   SessionApi,
+  SessionInfo,
 } from "@opencode-ai/client/promise"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
@@ -42,7 +42,6 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
 import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { ScopedKey, type ServerScope } from "@/utils/server-scope"
-import { normalizeSessionInfo } from "@/utils/session"
 import type { ServerApi } from "@/utils/server"
 
 type GlobalStore = {
@@ -182,7 +181,7 @@ function projectID(directory: string, projects: Project[]) {
   return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
 }
 
-function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
+function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) {
   setStore("session", (list) => {
     const next = list.slice()
     const idx = next.findIndex((item) => item.id >= session.id)
@@ -207,9 +206,7 @@ function warmSessions(input: {
   if (ids.length === 0) return Promise.resolve()
   return Promise.all(
     ids.map((sessionID) =>
-      retry(() => input.api.get({ sessionID })).then((session) =>
-        mergeSession(input.setStore, normalizeSessionInfo(session)),
-      ),
+      retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)),
     ),
   ).then(() => undefined)
 }
@@ -381,7 +378,7 @@ export async function bootstrapDirectory(input: {
                 const current = input.session?.data.permission ?? input.store.permission
                 for (const sessionID of Object.keys(current)) {
                   if (grouped[sessionID]) continue
-                  if (input.session?.get(sessionID)?.directory !== input.directory) continue
+                   if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
                   if (input.session) input.session.set("permission", sessionID, [])
                   if (!input.session) input.setStore("permission", sessionID, [])
                 }
@@ -415,7 +412,7 @@ export async function bootstrapDirectory(input: {
                 const current = input.session?.data.question ?? input.store.question
                 for (const sessionID of Object.keys(current)) {
                   if (grouped[sessionID]) continue
-                  if (input.session?.get(sessionID)?.directory !== input.directory) continue
+                   if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
                   if (input.session) input.session.set("question", sessionID, [])
                   if (!input.session) input.setStore("question", sessionID, [])
                 }

+ 3 - 3
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import type { Message, Part, Project, Session } from "@/types"
-import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
+import type { Message, Part, Project } from "@/types"
+import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
 import { createStore } from "solid-js/store"
 import type { State } from "./types"
 import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -14,7 +14,7 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
       updated: 1,
       archived: input.archived,
     },
-  }) as Session
+  }) as SessionInfo
 
 const userMessage = (id: string, sessionID: string) =>
   ({

+ 8 - 10
packages/app/src/context/global-sync/event-reducer.ts

@@ -4,10 +4,9 @@ import type {
   Message,
   Part,
   Project,
-  Session,
   Todo,
 } from "@/types"
-import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
+import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
@@ -76,7 +75,7 @@ function cleanupSessionCaches(
 export function cleanupDroppedSessionCaches(
   store: Store<State>,
   setStore: SetStoreFunction<State>,
-  next: Session[],
+  next: SessionInfo[],
   setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
 ) {
   const keep = new Set(next.map((item) => item.id))
@@ -125,7 +124,7 @@ export function applyDirectoryEvent(input: {
       return
     }
     case "session.created": {
-      const info = (event.properties as { info: Session }).info
+      const info = (event.properties as { info: SessionInfo }).info
       const result = Binary.search(input.store.session, info.id, (s) => s.id)
       if (result.found) {
         input.setStore("session", result.index, reconcile(info))
@@ -140,7 +139,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.updated": {
-      const info = (event.properties as { info: Session }).info
+      const info = (event.properties as { info: SessionInfo }).info
       const result = Binary.search(input.store.session, info.id, (s) => s.id)
       if (info.time.archived) {
         if (!result.found) break
@@ -168,7 +167,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.deleted": {
-      const properties = event.properties as { sessionID?: string; info?: Session }
+      const properties = event.properties as { sessionID?: string; info?: SessionInfo }
       const sessionID = properties.info?.id ?? properties.sessionID
       if (!sessionID) break
       const result = Binary.search(input.store.session, sessionID, (s) => s.id)
@@ -198,7 +197,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.usage.updated": {
-      const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
+      const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
       const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
       if (!result.found) break
       input.setStore("session", result.index, (session) => ({
@@ -234,9 +233,8 @@ export function applyDirectoryEvent(input: {
         input.setStore("session", result.index, (session) => ({
           ...session,
           projectID: properties.projectID ?? session.projectID,
-          workspaceID: properties.location.workspaceID,
-          directory: properties.location.directory,
-          path: properties.subpath,
+          location: properties.location,
+          subpath: properties.subpath,
           time: { ...session.time, updated: Date.now() },
         }))
         break

+ 7 - 9
packages/app/src/context/global-sync/home-session-index.test.ts

@@ -81,11 +81,9 @@ describe("Home V2 session index", () => {
     ])
 
     expect(result).toEqual([
-      expect.objectContaining({
-        id: "root",
-        slug: "root",
-        version: "",
-        directory: "/project",
+       expect.objectContaining({
+         id: "root",
+         location: { directory: "/project" },
         projectID: "project",
         title: "root",
         time: { created: 1, updated: 30 },
@@ -101,17 +99,17 @@ describe("Home V2 session index", () => {
     const now = 10 * 60 * 60 * 1000
     const sessions = Array.from({ length: 80 }, (_, index) => ({
       ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
-      directory: index % 2 === 0 ? "/one" : "/two",
+      location: { directory: index % 2 === 0 ? "/one" : "/two" },
     }))
 
     const retained = retainHomeSessions(sessions, 10, now)
-    expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
-    expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
+    expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
+    expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
   })
 
   test("replays session events over the loaded index", () => {
     const initial = parseHomeSessionIndex([session({ id: "old" })])
-    const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
+    const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
 
     const afterCreate = applyHomeSessionEvent(initial, {
       type: "session.created",

+ 16 - 31
packages/app/src/context/global-sync/home-session-index.ts

@@ -1,6 +1,5 @@
-import type { Event, Session } from "@/types"
+import type { Event } from "@/types"
 import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
-import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 import type { QueryClient } from "@tanstack/solid-query"
 import { trimSessions } from "./session-trim"
 import { pathKey } from "@/utils/path-key"
@@ -9,14 +8,14 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
 
 export type HomeSessionEvent = {
   type: "session.created" | "session.updated" | "session.deleted"
-  properties: { sessionID: string; info: Session }
+  properties: { sessionID: string; info?: SessionInfo }
 }
 export type HomeSessionEvents = {
   sequence: number
   entries: Array<{ sequence: number; event: HomeSessionEvent }>
 }
 export type HomeSessionIndex = {
-  sessions: Session[]
+  sessions: SessionInfo[]
   eventSequence: number
 }
 
@@ -126,23 +125,28 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
 // current V2 API orders by creation time and cannot filter roots, archives, or
 // multiple directories. A bounded page could omit an old session updated today.
 // Once released, use client.v2.project.list() and client.v2.session.list({
-// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
-export function parseHomeSessionIndex(sessions: SessionInfo[]): Session[] {
+// parentID: null, order: "desc" }) and replace this full-table scan.
+export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
   return sessions.flatMap((item) => {
     if (item.parentID || typeof item.time.archived === "number") return []
-    return [toLegacySummary(item)]
+    return [item]
   })
 }
 
-export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
-  const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
+export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
+  const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
   return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
 }
 
-export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
+export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
   const info = event.properties.info
-  const index = sessions.findIndex((session) => session.id === info.id)
-  if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
+  const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID))
+  if (event.type === "session.deleted") {
+    if (index === -1) return sessions
+    return sessions.toSpliced(index, 1)
+  }
+  if (!info) return sessions
+  if (info.parentID || typeof info.time.archived === "number") {
     if (index === -1) return sessions
     return sessions.toSpliced(index, 1)
   }
@@ -150,22 +154,3 @@ export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEve
   if (index === -1) return [...sessions, info]
   return sessions.with(index, info)
 }
-
-function toLegacySummary(session: SessionInfo): Session {
-  return {
-    id: session.id,
-    slug: session.id,
-    projectID: session.projectID,
-    workspaceID: session.location.workspaceID,
-    directory: session.location.directory,
-    path: session.subpath,
-    parentID: session.parentID,
-    cost: session.cost,
-    tokens: session.tokens,
-    title: withTimestampedFallback(session),
-    agent: session.agent,
-    model: session.model,
-    version: "",
-    time: session.time,
-  }
-}

+ 1 - 2
packages/app/src/context/global-sync/session-load.ts

@@ -1,5 +1,4 @@
 import type { SessionApi } from "@opencode-ai/client/promise"
-import { normalizeSessionInfo } from "@/utils/session"
 
 export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
   const result = await input.api.list({
@@ -9,7 +8,7 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
     order: "desc",
   })
   return {
-    data: result.data.map(normalizeSessionInfo),
+    data: result.data,
     limit: input.limit,
     limited: true,
   } as const

+ 2 - 3
packages/app/src/context/global-sync/session-trim.test.ts

@@ -1,6 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Session } from "@/types"
-import type { PermissionRequest } from "@opencode-ai/client/promise"
+import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
 import { trimSessions } from "./session-trim"
 
 const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -12,7 +11,7 @@ const session = (input: { id: string; parentID?: string; created: number; update
       updated: input.updated,
       archived: input.archived,
     },
-  }) as Session
+  }) as SessionInfo
 
 describe("trimSessions", () => {
   test("keeps base roots and recent roots beyond the limit", () => {

+ 7 - 7
packages/app/src/context/global-sync/session-trim.ts

@@ -1,22 +1,22 @@
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import type { PermissionRequest } from "@opencode-ai/client/promise"
 import { cmp } from "./utils"
 import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
 
-export function sessionUpdatedAt(session: Session) {
+export function sessionUpdatedAt(session: SessionInfo) {
   return session.time.updated ?? session.time.created
 }
 
-export function compareSessionRecent(a: Session, b: Session) {
+export function compareSessionRecent(a: SessionInfo, b: SessionInfo) {
   const aUpdated = sessionUpdatedAt(a)
   const bUpdated = sessionUpdatedAt(b)
   if (aUpdated !== bUpdated) return bUpdated - aUpdated
   return cmp(a.id, b.id)
 }
 
-export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
-  if (limit <= 0) return [] as Session[]
-  const selected: Session[] = []
+export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) {
+  if (limit <= 0) return [] as SessionInfo[]
+  const selected: SessionInfo[] = []
   const seen = new Set<string>()
   for (const session of sessions) {
     if (!session?.id) continue
@@ -32,7 +32,7 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
 }
 
 export function trimSessions(
-  input: Session[],
+  input: SessionInfo[],
   options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
 ) {
   const limit = Math.max(0, options.limit)

+ 2 - 2
packages/app/src/context/global-sync/types.ts

@@ -5,7 +5,6 @@ import type {
   Message,
   Part,
   Path,
-  Session,
   Todo,
   VcsInfo,
 } from "@/types"
@@ -14,6 +13,7 @@ import type {
   PermissionRequest,
   QuestionRequest,
   ReferenceInfo,
+  SessionInfo,
   SessionStatus,
 } from "@opencode-ai/client/promise"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
@@ -44,7 +44,7 @@ export type State = {
   provider: NormalizedProviderListResponse
   config: Config
   path: Path
-  session: Session[]
+  session: SessionInfo[]
   sessionTotal: number
   session_status: {
     [sessionID: string]: SessionStatus

+ 2 - 3
packages/app/src/context/permission-auto-respond.test.ts

@@ -1,6 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Session } from "@/types"
-import type { PermissionRequest } from "@opencode-ai/client/promise"
+import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
 
@@ -8,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
   ({
     id: input.id,
     parentID: input.parentID,
-  }) as Session
+  }) as SessionInfo
 
 const permission = (sessionID: string) =>
   ({

+ 1 - 1
packages/app/src/context/permission.tsx

@@ -132,7 +132,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       if (draft) return draft.directory
       if (!params.id) return
       if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
-      return selected().sync.session.lineage.peek(params.id)?.session.directory
+      return selected().sync.session.lineage.peek(params.id)?.session.location.directory
     })
 
     createEffect(() => {

+ 9 - 19
packages/app/src/context/server-session.test.ts

@@ -8,19 +8,19 @@ import type {
   SessionMessageAssistantTool,
   SessionMessageInfo,
 } from "@opencode-ai/client/promise"
-import type { Message, Part, Session } from "@/types"
+import type { Message, Part } from "@/types"
 import { createServerSession } from "./server-session"
 import type { ServerApi } from "@/utils/server"
 
 type MessageApi = ServerApi["message"]
 
-const session = (id: string, parentID?: string): Session => ({
+const session = (id: string, parentID?: string): SessionInfo => ({
   id,
-  slug: id,
   projectID: "project",
-  directory: "/repo",
+  location: { directory: "/repo" },
   title: id,
-  version: "1",
+  cost: 0,
+  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
   parentID,
   time: { created: 1, updated: 1 },
 })
@@ -35,18 +35,8 @@ type MessageResponse = {
 }
 type SingleMessageResponse = { data: MessageResponse["data"][number] }
 
-function sessionInfo(value: Session): SessionInfo {
-  return {
-    id: value.id,
-    parentID: value.parentID,
-    projectID: value.projectID,
-    cost: value.cost ?? 0,
-    tokens: value.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-    time: value.time,
-    title: value.title,
-    location: { directory: value.directory, workspaceID: value.workspaceID },
-    subpath: value.path,
-  }
+function sessionInfo(value: SessionInfo): SessionInfo {
+  return value
 }
 
 function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
@@ -272,7 +262,7 @@ const retryImmediately: typeof retry = async (task, options = {}) => {
   }
 }
 
-function setup(sessions: Record<string, Session>) {
+function setup(sessions: Record<string, SessionInfo>) {
   const get: unknown[] = []
   const messages: unknown[] = []
   const client = {
@@ -1687,7 +1677,7 @@ describe("server session", () => {
     ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
     ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
 
-    expect(ctx.store.get("root")?.directory).toBe("/repo")
+    expect(ctx.store.get("root")?.location.directory).toBe("/repo")
     expect(ctx.store.data.session_working("root")).toBe(true)
     expect(ctx.get).toEqual([])
   })

+ 11 - 13
packages/app/src/context/server-session.ts

@@ -1,17 +1,15 @@
 import { Binary } from "@opencode-ai/core/util/binary"
 import { retry } from "@opencode-ai/core/util/retry"
-import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
+import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
 import type {
   Message,
   Part,
-  Session,
   Todo,
 } from "@/types"
 import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
 import { batch } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { rootSession } from "@/utils/session-route"
-import { normalizeSessionInfo } from "@/utils/session"
 import { normalizeSessionMessages } from "@/utils/session-message"
 import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
 import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
@@ -189,7 +187,7 @@ export function createServerSession(
   const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi)
   const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions
   const [data, setData] = createStore({
-    info: {} as Record<string, Session | undefined>,
+    info: {} as Record<string, SessionInfo | undefined>,
     session_status: {} as Record<string, SessionStatus>,
     session_diff: {} as Record<string, FileDiffInfo[]>,
     todo: {} as Record<string, Todo[]>,
@@ -203,7 +201,7 @@ export function createServerSession(
       return (this.session_status[id]?.type ?? "idle") !== "idle"
     },
   })
-  const requests = new Map<string, Promise<Session>>()
+  const requests = new Map<string, Promise<SessionInfo>>()
   const inflight = new Map<string, Promise<void>>()
   const inflightTodo = new Map<string, Promise<void>>()
   const optimistic = new Map<string, Map<string, OptimisticItem>>()
@@ -252,7 +250,7 @@ export function createServerSession(
     )
   }
 
-  const remember = (session: Session) => {
+  const remember = (session: SessionInfo) => {
     setData("info", session.id, reconcile(session))
     infoSeen.delete(session.id)
     infoSeen.add(session.id)
@@ -302,7 +300,7 @@ export function createServerSession(
     const pending = requests.get(sessionID)
     if (pending) return pending
     const active = generation(sessionID)
-    const request = sessionApi.get({ sessionID }).then(normalizeSessionInfo)
+    const request = sessionApi.get({ sessionID })
     const resolved = request.then((result) => {
       if (generations.get(sessionID) !== active) return result
       return remember(result)
@@ -918,9 +916,8 @@ export function createServerSession(
       remember({
         ...info,
         projectID: event.data.projectID ?? info.projectID,
-        workspaceID: event.data.location.workspaceID,
-        directory: event.data.location.directory,
-        path: event.data.subpath,
+        location: event.data.location,
+        subpath: event.data.subpath,
         time: { ...info.time, updated: event.created },
       })
     if (event.type === "session.usage.updated" && info)
@@ -966,16 +963,17 @@ export function createServerSession(
     }
     switch (event.type) {
       case "session.created":
-        remember((event.properties as { info: Session }).info)
+        if ((event.properties as { info?: SessionInfo }).info)
+          remember((event.properties as { info: SessionInfo }).info)
         return
       case "session.updated": {
-        const info = (event.properties as { info: Session }).info
+        const info = (event.properties as { info: SessionInfo }).info
         remember(info)
         if (info.time.archived) evict([info.id])
         return
       }
       case "session.deleted": {
-        const properties = event.properties as { sessionID?: string; info?: Session }
+        const properties = event.properties as { sessionID?: string; info?: SessionInfo }
         const sessionID = properties.info?.id ?? properties.sessionID
         if (!sessionID) return
         infoSeen.delete(sessionID)

+ 2 - 4
packages/app/src/context/server-sync.test.ts

@@ -123,7 +123,7 @@ describe("pickDirectoriesToEvict", () => {
 })
 
 describe("loadRootSessions", () => {
-  test("loads and normalizes a limited page of root sessions", async () => {
+  test("loads a limited page of root sessions", async () => {
     const calls: SessionListInput[] = []
 
     const result = await loadRootSessions({
@@ -137,9 +137,7 @@ describe("loadRootSessions", () => {
       limit: 10,
     })
 
-    expect(result.data).toEqual([
-      expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
-    ])
+    expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })])
     expect(result.limited).toBe(true)
     expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
   })

+ 18 - 1
packages/app/src/context/server-sync.tsx

@@ -451,7 +451,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
   }
 
   const indexSession = (info: Parameters<typeof session.remember>[0]) => {
-    const key = directoryKey(info.directory)
+    const key = directoryKey(info.location.directory)
     const existing = children.children[key]
     if (!existing) return
     applyDirectoryEvent({
@@ -476,6 +476,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
 
     if (event.current) session.applyV2(event.current)
     session.apply(event)
+    if (event.current?.type === "session.created")
+      void session
+        .resolve(event.current.data.sessionID, { force: true })
+        .then((info) => {
+          if (!session.get(info.id)) return
+          indexSession(info)
+          homeSessions.apply({
+            type: "session.created",
+            properties: { sessionID: info.id, info },
+          })
+        })
+        .catch(() => {})
+    if (event.current?.type === "session.deleted")
+      homeSessions.apply({
+        type: "session.deleted",
+        properties: { sessionID: event.current.data.sessionID },
+      })
     if (event.type === "session.created" || event.type === "session.deleted") {
       if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
     }

+ 5 - 4
packages/app/src/context/tabs.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createStore, produce } from "solid-js/store"
 import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
@@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
 
 export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
 
-export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
+export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
   return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
 }
 
@@ -349,10 +349,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
         for (const key of removed) memory.remove(key)
         for (const key of removed) removeInfo(key)
       },
-      rememberSessionInfo(tab: SessionTab, session: Session) {
+      rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
         const key = tabKey(tab)
-        const next = { title: session.title, directory: session.directory }
+        const next = { title: session.title, directory: session.location.directory }
         const current = info[key]
+        console.log({ tab, session, current })
         if (current?.title === next.title && current.directory === next.directory) return
         setInfo(key, next)
       },

+ 2 - 2
packages/app/src/pages/home-session-archive.test.ts

@@ -18,7 +18,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
 
   await archiveHomeSession({
     server: remote,
-    session: { id: "ses_1", directory: "/workspace" },
+    session: { id: "ses_1", location: { directory: "/workspace" } },
     archive: async () => undefined,
     remove: () => {
       removed = true
@@ -36,7 +36,7 @@ test("reports archive failures without removing the session", async () => {
 
   await archiveHomeSession({
     server: remote,
-    session: { id: "ses_1", directory: "/workspace" },
+    session: { id: "ses_1", location: { directory: "/workspace" } },
     archive: async () => Promise.reject(failure),
     remove: () => {
       removed = true

+ 3 - 5
packages/app/src/pages/home-session-archive.ts

@@ -1,10 +1,8 @@
 import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
 import type { ServerConnection } from "@/context/server"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 
-type HomeSession = {
-  id: string
-  directory: string
-}
+type HomeSession = Pick<SessionInfo, "id" | "location">
 
 export async function archiveHomeSession(input: {
   server: ServerConnection.Key
@@ -19,7 +17,7 @@ export async function archiveHomeSession(input: {
       input.remove()
       notifySessionTabsRemoved({
         server: input.server,
-        directory: input.session.directory,
+        directory: input.session.location.directory,
         sessionIDs: [input.session.id],
       })
     })

+ 2 - 1
packages/app/src/pages/home/home-session-search-controller.ts

@@ -2,6 +2,7 @@ import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { serverName } from "@/context/server"
 import { displayName } from "@/pages/layout/helpers"
+import { sessionLabel } from "@/utils/session-title"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
     if (!value) return []
     return sessions.data
       .searchRecords()
-      .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
+      .filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
   })
   const active = createMemo(() => {
     const records = results()

+ 12 - 12
packages/app/src/pages/home/home-sessions-controller.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -26,7 +26,7 @@ import type { HomeController } from "./home-controller"
 
 const HOME_SESSION_LIMIT = 64
 export type HomeSessionRecord = {
-  session: Session
+  session: SessionInfo
   project: LocalProject
   projectName: string
 }
@@ -180,8 +180,8 @@ export function createHomeSessionsController(home: HomeController) {
       server: () => home.selection.value().server,
       canCreate: () => !!home.project.newSession(),
       create: home.project.openNewSession,
-      open: (session: Session, options?: OpenSessionOptions) => {
-        const directoryKey = pathKey(session.directory)
+      open: (session: SessionInfo, options?: OpenSessionOptions) => {
+        const directoryKey = pathKey(session.location.directory)
         const project =
           home.project
             .list()
@@ -192,7 +192,7 @@ export function createHomeSessionsController(home: HomeController) {
             ) ?? projectForSession(session, home.project.list(), projectByID())
         const conn = home.server.focused()
         if (!conn) return
-        const directory = project?.worktree ?? session.directory
+        const directory = project?.worktree ?? session.location.directory
         const ctx = home.server.focusedContext()
         if (!ctx) return
         ctx.projects.open(directory)
@@ -206,11 +206,11 @@ export function createHomeSessionsController(home: HomeController) {
           tabs.select(tab)
         })
       },
-      archive: async (session: Session) => {
+      archive: async (session: SessionInfo) => {
         const conn = home.server.focused()
         const ctx = home.server.focusedContext()
         if (!conn || !ctx) return
-        const [, setStore] = ctx.sync.child(session.directory)
+        const [, setStore] = ctx.sync.child(session.location.directory)
         await archiveHomeSession({
           server: ServerConnection.key(conn),
           session,
@@ -243,17 +243,17 @@ function directories(project: LocalProject) {
 }
 
 function buildHomeSessionRecords(input: {
-  sessions: () => Session[]
+  sessions: () => SessionInfo[]
   projectDirectories: () => string[]
   projects: () => LocalProject[]
   projectByID: () => Map<string, LocalProject>
 }) {
   const directories = new Set(input.projectDirectories().map(pathKey))
-  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
+  const sessions = input.sessions().filter((session) => directories.has(pathKey(session.location.directory)))
   return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
     .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
     .flatMap((session) => {
-      const directory = pathKey(session.directory)
+      const directory = pathKey(session.location.directory)
       const project =
         input
           .projects()
@@ -267,7 +267,7 @@ function buildHomeSessionRecords(input: {
 }
 
 export function homeSessionSearchKey(record: HomeSessionRecord) {
-  return `${pathKey(record.session.directory)}:${record.session.id}`
+  return `${pathKey(record.session.location.directory)}:${record.session.id}`
 }
 
 function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
@@ -304,7 +304,7 @@ export function HomeSessionStatusController(props: {
 }) {
   const avatar = useSessionTabAvatarState(
     props.server,
-    () => props.record.session.directory,
+    () => props.record.session.location.directory,
     () => props.record.session.id,
   )
   return props.render({

+ 7 - 7
packages/app/src/pages/home/home-sessions-view.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { type Accessor, createMemo, For, Show } from "solid-js"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
@@ -9,7 +9,7 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { useLanguage } from "@/context/language"
 import { ServerConnection } from "@/context/server"
 import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
-import { sessionTitle } from "@/utils/session-title"
+import { sessionLabel } from "@/utils/session-title"
 import { shouldOpenSessionInBackground } from "../home-session-open"
 import {
   HomeSessionStatusController,
@@ -53,8 +53,8 @@ export type HomeSessionsViewProps = {
   titleOpacity: (id: HomeSessionGroup["id"]) => number
   isOpenTab: (record: HomeSessionRecord) => boolean
   onCreateSession: () => void
-  onOpenSession: (session: Session, options?: OpenSessionOptions) => void
-  onArchiveSession: (session: Session) => Promise<void>
+  onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
+  onArchiveSession: (session: SessionInfo) => Promise<void>
   onSetHoverTarget: (element: HTMLElement) => void
   onSetThumbTrack: (element: HTMLDivElement) => void
   onSetContent: (element: HTMLDivElement) => void
@@ -192,7 +192,7 @@ function HomeSessionLeading(props: {
       </Show>
       <SessionTabAvatarView
         project={props.record.project}
-        directory={props.record.session.directory}
+        directory={props.record.session.location.directory}
         revealProjectOnHover={props.revealProjectOnHover}
         unread={props.unread}
         loading={props.loading}
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
     selected: boolean
   },
 ) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const title = createMemo(() => sessionLabel(props.record.session))
   const showProjectName = () => props.showProjectName() && props.record.projectName
   const key = () => homeSessionSearchKey(props.record)
 
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
 }
 
 function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const title = createMemo(() => sessionLabel(props.record.session))
   const showProjectName = () => props.showProjectName() && props.record.projectName
 
   return (

+ 17 - 17
packages/app/src/pages/layout.tsx

@@ -26,7 +26,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { createStore, produce, reconcile } from "solid-js/store"
@@ -603,9 +603,9 @@ export default function LegacyLayout(props: ParentProps) {
   const currentSessions = createMemo(() => {
     const now = Date.now()
     const dirs = visibleSessionDirs()
-    if (dirs.length === 0) return [] as Session[]
+    if (dirs.length === 0) return [] as SessionInfo[]
 
-    const result: Session[] = []
+    const result: SessionInfo[] = []
     for (const dir of dirs) {
       const [dirStore] = serverSync().child(dir, { bootstrap: true })
       const dirSessions = sortedRootSessions(dirStore, now)
@@ -717,8 +717,8 @@ export default function LegacyLayout(props: ParentProps) {
     })
   }
 
-  const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
-    const directory = session.directory
+  const prefetchSession = (session: SessionInfo, priority: "high" | "low" = "low") => {
+    const directory = session.location.directory
     if (!directory) return
 
     const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
@@ -753,7 +753,7 @@ export default function LegacyLayout(props: ParentProps) {
     pumpPrefetch(directory)
   }
 
-  const warm = (sessions: Session[], index: number) => {
+  const warm = (sessions: SessionInfo[], index: number) => {
     for (let offset = 1; offset <= span; offset++) {
       const next = sessions[index + offset]
       if (next) prefetchSession(next, offset === 1 ? "high" : "low")
@@ -855,11 +855,11 @@ export default function LegacyLayout(props: ParentProps) {
     }
   }
 
-  async function archiveSession(session: Session) {
+  async function archiveSession(session: SessionInfo) {
     // TODO: Restore archiving when the V2 client exposes a session archive API.
     void session
     return
-    const [store, setStore] = serverSync().child(session.directory)
+    const [store, setStore] = serverSync().child(session.location.directory)
     const sessions = store.session ?? []
     const index = sessions.findIndex((s) => s.id === session.id)
     const nextSession = sessions[index + 1] ?? sessions[index - 1]
@@ -1192,10 +1192,10 @@ export default function LegacyLayout(props: ParentProps) {
         .sync(target.id)
         .then(() => sync.session.get(target.id))
         .catch(() => undefined)
-      if (!resolved?.directory) return false
-      if (!canOpen(resolved.directory)) return false
-      setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
-      navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
+      if (!resolved?.location.directory) return false
+      if (!canOpen(resolved.location.directory)) return false
+      setStore("lastProjectSession", root, { directory: resolved.location.directory, id: resolved.id, at: Date.now() })
+      navigateWithSidebarReset(`/${base64Encode(resolved.location.directory)}/session/${resolved.id}`)
       return true
     }
 
@@ -1211,7 +1211,7 @@ export default function LegacyLayout(props: ParentProps) {
       dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
       Date.now(),
     )
-    if (latest && (await openSession(latest))) {
+    if (latest && (await openSession({ directory: latest.location.directory, id: latest.id }))) {
       return
     }
 
@@ -1228,16 +1228,16 @@ export default function LegacyLayout(props: ParentProps) {
       ),
       Date.now(),
     )
-    if (fetched && (await openSession(fetched))) {
+    if (fetched && (await openSession({ directory: fetched.location.directory, id: fetched.id }))) {
       return
     }
 
     navigateWithSidebarReset(`/${base64Encode(root)}/session`)
   }
 
-  function navigateToSession(session: Session | undefined) {
+  function navigateToSession(session: SessionInfo | undefined) {
     if (!session) return
-    navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
+    navigateWithSidebarReset(`/${base64Encode(session.location.directory)}/session/${session.id}`)
   }
 
   function openProject(directory: string, navigate = true) {
@@ -1540,7 +1540,7 @@ export default function LegacyLayout(props: ParentProps) {
     const [state, setState] = createStore({
       status: "loading" as "loading" | "ready" | "error",
       dirty: false,
-      sessions: [] as Session[],
+      sessions: [] as SessionInfo[],
     })
 
     const refresh = async () => {

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

@@ -6,7 +6,7 @@ import {
   parseDeepLink,
   parseNewSessionDeepLink,
 } from "./deep-links"
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import {
   childSessionOnPath,
   closeHomeProject,
@@ -25,16 +25,18 @@ import { ServerConnection } from "@/context/server"
 
 const serverKey = ServerConnection.Key.make
 
-const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
+const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) =>
   ({
+    projectID: "project",
     title: "",
-    version: "v2",
+    cost: 0,
+    tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
     parentID: undefined,
-    messageCount: 0,
-    permissions: { session: {}, share: {} },
     time: { created: 0, updated: 0, archived: undefined },
     ...input,
-  }) as Session
+    location: { directory: input.directory },
+    directory: undefined,
+  }) as SessionInfo
 
 describe("layout deep links", () => {
   test("parses open-project deep links", () => {

+ 8 - 8
packages/app/src/pages/layout/helpers.ts

@@ -1,17 +1,17 @@
 import { getFilename } from "@opencode-ai/core/util/path"
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { pathKey } from "@/utils/path-key"
 import type { ServerConnection } from "@/context/server"
 import type { HomeProjectSelection } from "@/context/layout"
 
 type SessionStore = {
-  session?: Session[]
+  session?: SessionInfo[]
   path: { directory: string }
 }
 
 function sortSessions(now: number) {
   const oneMinuteAgo = now - 60 * 1000
-  return (a: Session, b: Session) => {
+  return (a: SessionInfo, b: SessionInfo) => {
     const aUpdated = a.time.updated ?? a.time.created
     const bUpdated = b.time.updated ?? b.time.created
     const aRecent = aUpdated > oneMinuteAgo
@@ -23,8 +23,8 @@ function sortSessions(now: number) {
   }
 }
 
-const isRootVisibleSession = (session: Session, directory: string) =>
-  pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived
+const isRootVisibleSession = (session: SessionInfo, directory: string) =>
+  pathKey(session.location.directory) === pathKey(directory) && !session.parentID && !session.time.archived
 
 export const roots = (store: SessionStore) =>
   (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
@@ -41,7 +41,7 @@ export function hasProjectPermissions<T>(
   return Object.values(request ?? {}).some((list) => list?.some(include))
 }
 
-export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
+export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: string, activeID?: string) => {
   if (!activeID || activeID === rootID) return
   const map = new Map((sessions ?? []).map((session) => [session.id, session]))
   let id = activeID
@@ -102,13 +102,13 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
 }
 
 export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
-  session: Session,
+  session: SessionInfo,
   projects: T[],
   byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
 ) {
   const direct = byID.get(session.projectID)
   if (direct) return direct
-  const directory = pathKey(session.directory)
+  const directory = pathKey(session.location.directory)
   return projects.find(
     (project) =>
       pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),

+ 19 - 15
packages/app/src/pages/layout/sidebar-items.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -14,7 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { messageAgentColor } from "@/utils/agent"
-import { sessionTitle } from "@/utils/session-title"
+import { sessionLabel } from "@/utils/session-title"
 import { sessionPermissionRequest } from "../session/composer/session-request-tree"
 import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
 
@@ -35,7 +35,7 @@ export const ProjectIcon = (props: {
   const hasPermissions = createMemo(() =>
     dirs().some((directory) => {
       return hasProjectPermissions(serverSync().session.data.permission, (item) => {
-        if (serverSync().session.get(item.sessionID)?.directory !== directory) return false
+        if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
         return !permission.autoResponds(item, directory)
       })
     }),
@@ -74,9 +74,9 @@ export const ProjectIcon = (props: {
 }
 
 export type SessionItemProps = {
-  session: Session
-  list: Session[]
-  navList?: Accessor<Session[]>
+  session: SessionInfo
+  list: SessionInfo[]
+  navList?: Accessor<SessionInfo[]>
   slug: string
   mobile?: boolean
   dense?: boolean
@@ -85,12 +85,12 @@ export type SessionItemProps = {
   level?: number
   sidebarExpanded: Accessor<boolean>
   clearHoverProjectSoon: () => void
-  prefetchSession: (session: Session, priority?: "high" | "low") => void
-  archiveSession: (session: Session) => Promise<void>
+  prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
+  archiveSession: (session: SessionInfo) => Promise<void>
 }
 
 const SessionRow = (props: {
-  session: Session
+  session: SessionInfo
   slug: string
   mobile?: boolean
   dense?: boolean
@@ -104,7 +104,7 @@ const SessionRow = (props: {
   warmPress: () => void
   warmFocus: () => void
 }): JSX.Element => {
-  const title = () => sessionTitle(props.session.title)
+  const title = () => sessionLabel(props.session)
 
   return (
     <A
@@ -152,14 +152,14 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
   const serverSync = useServerSync()
   const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
   const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
-  const [sessionStore] = serverSync().child(props.session.directory)
+  const [sessionStore] = serverSync().child(props.session.location.directory)
   const hasPermissions = createMemo(() => {
     return !!sessionPermissionRequest(
       sessionStore.session,
       serverSync().session.data.permission,
       props.session.id,
       (item) => {
-        return !permission.autoResponds(item, props.session.directory)
+        return !permission.autoResponds(item, props.session.location.directory)
       },
     )
   })
@@ -179,13 +179,17 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
 
   const warm = (span: number, priority: "high" | "low") => {
     const nav = props.navList?.()
-    const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
+    const list = nav?.some(
+      (item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
+    )
       ? nav
       : props.list
 
     props.prefetchSession(props.session, priority)
 
-    const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
+    const idx = list.findIndex(
+      (item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
+    )
     if (idx === -1) return
 
     for (let step = 1; step <= span; step++) {
@@ -229,7 +233,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
               fallback={
                 <Tooltip
                   placement={props.mobile ? "bottom" : "right"}
-                  value={sessionTitle(props.session.title)}
+                  value={sessionLabel(props.session)}
                   gutter={10}
                   class="min-w-0 w-full"
                 >

+ 1 - 1
packages/app/src/pages/layout/sidebar-project.tsx

@@ -305,7 +305,7 @@ export const SortableProject = (props: {
   const isWorking = createMemo(() =>
     dirs().some((directory) => {
       return Object.keys(serverSync().session.data.session_status).some((id) => {
-        if (serverSync().session.get(id)?.directory !== directory) return false
+        if (serverSync().session.get(id)?.location.directory !== directory) return false
         return serverSync().session.data.session_working(id)
       })
     }),

+ 5 - 5
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import { type LocalProject } from "@/context/layout"
 import { useServerSync, useQueryOptions } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
@@ -36,12 +36,12 @@ type InlineEditorComponent = (props: {
 
 export type WorkspaceSidebarContext = {
   currentDir: Accessor<string>
-  navList: Accessor<Session[]>
+  navList: Accessor<SessionInfo[]>
   sidebarExpanded: Accessor<boolean>
   sidebarHovering: Accessor<boolean>
   clearHoverProjectSoon: () => void
-  prefetchSession: (session: Session, priority?: "high" | "low") => void
-  archiveSession: (session: Session) => Promise<void>
+  prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
+  archiveSession: (session: SessionInfo) => Promise<void>
   workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
   renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
   editorOpen: (id: string) => boolean
@@ -243,7 +243,7 @@ const WorkspaceSessionList = (props: {
   ctx: WorkspaceSidebarContext
   showNew: Accessor<boolean>
   loading: Accessor<boolean>
-  sessions: Accessor<Session[]>
+  sessions: Accessor<SessionInfo[]>
   hasMore: Accessor<boolean>
   loadMore: () => Promise<void>
   language: ReturnType<typeof useLanguage>

+ 2 - 2
packages/app/src/pages/session.tsx

@@ -160,7 +160,7 @@ export function SessionPage() {
 export function TargetSessionRouteContent() {
   const params = useParams<{ serverKey: string; id: string }>()
   const serverSync = useServerSync()
-  const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
+  const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.location.directory)
   return (
     // Settings must keep the target-server SDK, sync, and models context and remain registered
     // when session content falls back to the route error boundary.
@@ -253,7 +253,7 @@ function ResolvedTargetSessionRoute() {
     () => params.id,
     () => sync().session.lineage,
   )
-  const directory = createMemo(() => current()?.session.directory)
+  const directory = createMemo(() => current()?.session.location.directory)
   const targetDirectory = () => directory()!
 
   createEffect(() => {

+ 2 - 3
packages/app/src/pages/session/composer/session-composer-state.test.ts

@@ -1,6 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Session } from "@/types"
-import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
+import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
 import { todoDockAtBoundary, todoState } from "./session-composer-state"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
@@ -8,7 +7,7 @@ const session = (input: { id: string; parentID?: string }) =>
   ({
     id: input.id,
     parentID: input.parentID,
-  }) as Session
+  }) as SessionInfo
 
 const permission = (id: string, sessionID: string) =>
   ({

+ 4 - 4
packages/app/src/pages/session/composer/session-request-tree.ts

@@ -1,8 +1,8 @@
-import type { Session } from "@/types"
+import type { SessionInfo } from "@opencode-ai/client/promise"
 import type { PermissionRequest, QuestionRequest } from "@opencode-ai/client/promise"
 
 function sessionTreeRequest<T>(
-  session: Session[],
+  session: SessionInfo[],
   request: Record<string, T[] | undefined>,
   sessionID?: string,
   include: (item: T) => boolean = () => true,
@@ -35,7 +35,7 @@ function sessionTreeRequest<T>(
 }
 
 export function sessionPermissionRequest(
-  session: Session[],
+  session: SessionInfo[],
   request: Record<string, PermissionRequest[] | undefined>,
   sessionID?: string,
   include?: (item: PermissionRequest) => boolean,
@@ -44,7 +44,7 @@ export function sessionPermissionRequest(
 }
 
 export function sessionQuestionRequest(
-  session: Session[],
+  session: SessionInfo[],
   request: Record<string, QuestionRequest[] | undefined>,
   sessionID?: string,
   include?: (item: QuestionRequest) => boolean,

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

@@ -298,7 +298,7 @@ export function MessageTimeline(props: {
   })
   const titleValue = createMemo(() => info()?.title)
   const titleLabel = createMemo(() => sessionTitle(titleValue()))
-  const shareUrl = createMemo(() => info()?.share?.url)
+  const shareUrl = (): string | undefined => undefined
   // TODO: Restore these actions when the V2 client exposes session sharing.
   // const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
   const shareEnabled = () => false

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

@@ -187,7 +187,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     const sessionID = params.id
     if (!sessionID) return
 
-    const existing = info()?.share?.url
+    const existing = undefined
     if (existing) {
       await copyShare(existing, true)
       return

+ 0 - 21
packages/app/src/types.ts

@@ -6,27 +6,6 @@ import type {
 import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 
 export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
-export type Session = {
-  id: string
-  slug: string
-  projectID: string
-  workspaceID?: string
-  directory: string
-  path?: string
-  parentID?: string
-  summary?: { additions: number; deletions: number; files: number; diffs?: FileDiffInfo[] }
-  cost?: number
-  tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
-  share?: { url: string }
-  title: string
-  agent?: string
-  model?: { id: string; providerID: string; variant?: string }
-  version: string
-  metadata?: Record<string, unknown>
-  time: { created: number; updated: number; compacting?: number; archived?: number }
-  permission?: Array<{ permission: string; pattern: string; action: "allow" | "deny" | "ask" }>
-  revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
-}
 
 type CurrentEvent = EventSubscribeOutput extends infer Item
   ? Item extends { type: infer Type extends string; data: infer Data }

+ 7 - 0
packages/app/src/utils/session-title.ts

@@ -1,5 +1,12 @@
+import type { SessionInfo } from "@opencode-ai/client/promise"
+import { displayLabel } from "@opencode-ai/util/session-title-fallback"
+
 const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
 
+export function sessionLabel(session: Pick<SessionInfo, "title" | "parentID">) {
+  return displayLabel(session)
+}
+
 export function sessionTitle(title?: string) {
   if (!title) return title
   const match = title.match(pattern)

+ 1 - 37
packages/app/src/utils/session.test.ts

@@ -1,42 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
-import { listAllSessions, normalizeSessionInfo } from "./session"
-
-describe("normalizeSessionInfo", () => {
-  test("adapts a current session to the app session shape", () => {
-    const result = normalizeSessionInfo({
-      id: "session-1",
-      projectID: "project-1",
-      agent: "build",
-      model: { id: "gpt-5", providerID: "openai", variant: "high" },
-      cost: 0,
-      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-      time: { created: 1, updated: 1 },
-      title: "New session",
-      location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
-      subpath: "worktree",
-      revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
-    } as SessionInfo)
-
-    expect(result).toEqual({
-      id: "session-1",
-      slug: "session-1",
-      projectID: "project-1",
-      workspaceID: "workspace-1",
-      directory: "/repo/worktree",
-      path: "worktree",
-      parentID: undefined,
-      cost: 0,
-      tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-      title: "New session",
-      agent: "build",
-      model: { id: "gpt-5", providerID: "openai", variant: "high" },
-      version: "",
-      time: { created: 1, updated: 1 },
-      revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
-    })
-  })
-})
+import { listAllSessions } from "./session"
 
 describe("listAllSessions", () => {
   test("loads every page in server order and retains the query", async () => {

+ 2 - 29
packages/app/src/utils/session.ts

@@ -1,36 +1,9 @@
 import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
-import type { Session } from "@/types"
-import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
-
-export function normalizeSessionInfo(input: SessionInfo | Session): Session {
-  if (!("location" in input)) return input
-  return {
-    id: input.id,
-    slug: input.id,
-    projectID: input.projectID,
-    workspaceID: input.location.workspaceID,
-    directory: input.location.directory,
-    path: input.subpath,
-    parentID: input.parentID,
-    cost: input.cost,
-    tokens: input.tokens,
-    title: withTimestampedFallback(input),
-    agent: input.agent,
-    model: input.model,
-    version: "",
-    time: input.time,
-    revert: input.revert && {
-      messageID: input.revert.messageID,
-      partID: input.revert.partID,
-      snapshot: input.revert.snapshot,
-    },
-  }
-}
 
 export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
-  const load = async (cursor?: string): Promise<Session[]> => {
+  const load = async (cursor?: string): Promise<SessionInfo[]> => {
     const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
-    const sessions = result.data.map(normalizeSessionInfo)
+    const sessions = result.data
     if (result.data.length === 0 || !result.cursor.next) return sessions
     return [...sessions, ...(await load(result.cursor.next))]
   }

+ 4 - 5
packages/session-ui/src/components/message-part.tsx

@@ -23,7 +23,6 @@ import {
   Message as MessageType,
   Part as PartType,
   ReasoningPart,
-  Session,
   TextPart,
   ToolPart,
   UserMessage,
@@ -31,7 +30,7 @@ import {
   QuestionAnswer,
   QuestionInfo,
 } from "@opencode-ai/sdk/v2"
-import { useData } from "../context"
+import { type SessionSummary, useData } from "../context"
 import { useFileComponent } from "@opencode-ai/ui/context/file"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
@@ -601,7 +600,7 @@ function currentSession(path: string) {
 function taskSession(
   input: Record<string, any>,
   path: string,
-  sessions: Session[] | undefined,
+  sessions: SessionSummary[] | undefined,
   agents?: readonly { name: string; color?: string }[],
 ) {
   const parentID = currentSession(path)
@@ -610,8 +609,8 @@ function taskSession(
   const agent = taskAgent(input.subagent_type, agents).name
   return (sessions ?? [])
     .filter((session) => session.parentID === parentID && !session.time?.archived)
-    .filter((session) => (description ? session.title.startsWith(description) : true))
-    .filter((session) => (agent ? session.title.includes(`@${agent}`) : true))
+    .filter((session) => (description ? session.title?.startsWith(description) : true))
+    .filter((session) => (agent ? session.title?.includes(`@${agent}`) : true))
     .sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
 }
 

+ 5 - 3
packages/session-ui/src/context/data.tsx

@@ -1,8 +1,10 @@
-import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
-import type { FileDiffInfo } from "@opencode-ai/client/promise"
+import type { Message, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo, SessionInfo } from "@opencode-ai/client/promise"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 
+export type SessionSummary = Pick<SessionInfo, "id" | "parentID" | "title" | "time">
+
 export type NormalizedProviderListResponse = {
   all: Map<string, Provider>
   default: {
@@ -17,7 +19,7 @@ type Data = {
     color?: string
   }[]
   provider?: NormalizedProviderListResponse
-  session: Session[]
+  session: SessionSummary[]
   session_status: {
     [sessionID: string]: SessionStatus
   }