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

This commit is contained in:
Brendan Allan 2026-08-06 17:58:56 +08:00 committed by GitHub
parent e62918224f
commit a0c77b71ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 248 additions and 342 deletions

View file

@ -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,
}

View file

@ -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}`,

View file

@ -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 })
})

View file

@ -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,

View file

@ -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"),

View file

@ -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
})

View file

@ -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()

View file

@ -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}
/>

View file

@ -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,
})
})

View file

@ -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
}

View file

@ -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)

View file

@ -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, [])
}

View file

@ -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) =>
({

View file

@ -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

View file

@ -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",

View file

@ -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,
}
}

View file

@ -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

View file

@ -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", () => {

View file

@ -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)

View file

@ -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

View file

@ -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) =>
({

View file

@ -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(() => {

View file

@ -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([])
})

View file

@ -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)

View file

@ -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" }])
})

View file

@ -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])
}

View file

@ -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)
},

View file

@ -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

View file

@ -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],
})
})

View file

@ -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()

View file

@ -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({

View file

@ -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 (

View file

@ -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 () => {

View file

@ -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", () => {

View file

@ -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),

View file

@ -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"
>

View file

@ -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)
})
}),

View file

@ -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>

View file

@ -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(() => {

View file

@ -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) =>
({

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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)

View file

@ -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 () => {

View file

@ -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))]
}

View file

@ -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
}

View file

@ -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
}