diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index c189b71e88e..f718c9b7bee 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/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 { 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, } diff --git a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts index 8cdf4fa8cfa..61e954e4f4f 100644 --- a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts +++ b/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}`, diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c2da99d976a..52db902d296 100644 --- a/packages/app/src/app.tsx +++ b/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 }) }) diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 5779c3a423f..6455068bb6e 100644 --- a/packages/app/src/components/command-palette.ts +++ b/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, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 56f81acf58f..2433b8f20c1 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/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"), diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 4116f4a6208..8507846f877 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/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 }) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 949a38bf599..20d7c1f80be 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/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() diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index f60a4399e36..4c26db564b2 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/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 href: string server: ServerConnection.Key - session: () => Session | undefined + session: () => SessionInfo | undefined fallbackTitle?: string onRename: (title: string) => Promise 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) => ( diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 242de395c40..72bfb2f51e2 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/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 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, }) }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 649a8f57773..a0ccbadf90f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/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(sessionTab, "prompt")?.model.current() - tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model) + tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model) return } diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 89892e18d32..4615a9a4cc6 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/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) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 4254267fa51..250505c8801 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/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, session: Session) { +function mergeSession(setStore: SetStoreFunction, 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, []) } diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 31afc29e773..e5067e844b0 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/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) => ({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 2fc05056eaa..540447a1c58 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/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, setStore: SetStoreFunction, - 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 & { sessionID: string } + const properties = event.properties as Pick & { 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 diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts index 93e4734763f..64268674607 100644 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ b/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", diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index e1ac18e044f..1f6bc95f7d1 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/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, - } -} diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts index 4b82d577e02..b448599cced 100644 --- a/packages/app/src/context/global-sync/session-load.ts +++ b/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; directory: string; limit: number }) { const result = await input.api.list({ @@ -9,7 +8,7 @@ export async function loadRootSessions(input: { api: Pick; d order: "desc", }) return { - data: result.data.map(normalizeSessionInfo), + data: result.data, limit: input.limit, limited: true, } as const diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts index 94ecf53996d..1aa6c90a529 100644 --- a/packages/app/src/context/global-sync/session-trim.test.ts +++ b/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", () => { diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts index 1474ca6bfdc..57136e276ef 100644 --- a/packages/app/src/context/global-sync/session-trim.ts +++ b/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() 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; now?: number }, ) { const limit = Math.max(0, options.limit) diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 83acbf742cc..9f91c1f791a 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/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 diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 054e5f9e0ff..ee8f5404f22 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/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) => ({ diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index ce11822dbad..1cec5aa0de3 100644 --- a/packages/app/src/context/permission.tsx +++ b/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(() => { diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index d0d3252fb04..57cbf50565c 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/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) { +function setup(sessions: Record) { 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([]) }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index f971d4fa228..377e4ff201b 100644 --- a/packages/app/src/context/server-session.ts +++ b/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, + info: {} as Record, session_status: {} as Record, session_diff: {} as Record, todo: {} as Record, @@ -203,7 +201,7 @@ export function createServerSession( return (this.session_status[id]?.type ?? "idle") !== "idle" }, }) - const requests = new Map>() + const requests = new Map>() const inflight = new Map>() const inflightTodo = new Map>() const optimistic = new Map>() @@ -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) diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index ac415de514e..ff27cdac683 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/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" }]) }) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b73cd1f1fe3..023b8fbfcba 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -451,7 +451,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { } const indexSession = (info: Parameters[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[0]) } diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 7bebc07664d..d741a46be07 100644 --- a/packages/app/src/context/tabs.tsx +++ b/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) }, diff --git a/packages/app/src/pages/home-session-archive.test.ts b/packages/app/src/pages/home-session-archive.test.ts index 2d04e808f46..7f67651dc3c 100644 --- a/packages/app/src/pages/home-session-archive.test.ts +++ b/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 diff --git a/packages/app/src/pages/home-session-archive.ts b/packages/app/src/pages/home-session-archive.ts index bafca66e72d..b8fa74e0421 100644 --- a/packages/app/src/pages/home-session-archive.ts +++ b/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 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], }) }) diff --git a/packages/app/src/pages/home/home-session-search-controller.ts b/packages/app/src/pages/home/home-session-search-controller.ts index 5e55c4b7469..6fec2ed0b5c 100644 --- a/packages/app/src/pages/home/home-session-search-controller.ts +++ b/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() diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 4742bacc50c..848cef9a2f6 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/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 }) { 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): 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({ diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index e83ca9b86d9..161e742ab93 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/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 + onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void + onArchiveSession: (session: SessionInfo) => Promise onSetHoverTarget: (element: HTMLElement) => void onSetThumbTrack: (element: HTMLDivElement) => void onSetContent: (element: HTMLDivElement) => void @@ -192,7 +192,7 @@ function HomeSessionLeading(props: { 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 ( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 26ab0b3b457..2ec1af1cdda 100644 --- a/packages/app/src/pages/layout.tsx +++ b/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 () => { diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 96105247eea..5a8ec4b6bf2 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/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 & Pick) => +const session = (input: Partial & Pick & { 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", () => { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index dec03c6178c..428998bc991 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/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( 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( - session: Session, + session: SessionInfo, projects: T[], byID: Map = 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), diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index cc4390c09db..5c438cbf416 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/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: SessionInfo + list: SessionInfo[] + navList?: Accessor slug: string mobile?: boolean dense?: boolean @@ -85,12 +85,12 @@ export type SessionItemProps = { level?: number sidebarExpanded: Accessor clearHoverProjectSoon: () => void - prefetchSession: (session: Session, priority?: "high" | "low") => void - archiveSession: (session: Session) => Promise + prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void + archiveSession: (session: SessionInfo) => Promise } 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 ( { 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={ diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 9d29a1b495f..9ca77075a29 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/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) }) }), diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 2e300f3a8fe..a0e925aa1a8 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/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 - navList: Accessor + navList: Accessor sidebarExpanded: Accessor sidebarHovering: Accessor clearHoverProjectSoon: () => void - prefetchSession: (session: Session, priority?: "high" | "low") => void - archiveSession: (session: Session) => Promise + prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void + archiveSession: (session: SessionInfo) => Promise 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 loading: Accessor - sessions: Accessor + sessions: Accessor hasMore: Accessor loadMore: () => Promise language: ReturnType diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e673901521a..81bd41bfd48 100644 --- a/packages/app/src/pages/session.tsx +++ b/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(() => { diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index 2b8273c08df..b0c462e7032 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/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) => ({ diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts index 31b1aafaa23..5174d1ac86f 100644 --- a/packages/app/src/pages/session/composer/session-request-tree.ts +++ b/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( - session: Session[], + session: SessionInfo[], request: Record, sessionID?: string, include: (item: T) => boolean = () => true, @@ -35,7 +35,7 @@ function sessionTreeRequest( } export function sessionPermissionRequest( - session: Session[], + session: SessionInfo[], request: Record, sessionID?: string, include?: (item: PermissionRequest) => boolean, @@ -44,7 +44,7 @@ export function sessionPermissionRequest( } export function sessionQuestionRequest( - session: Session[], + session: SessionInfo[], request: Record, sessionID?: string, include?: (item: QuestionRequest) => boolean, diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index fc52cb364ad..fddb1f96bbd 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/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 diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index bd10cac4dfa..82db3876545 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/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 diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index a71ce5d0f30..2e157aea615 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -6,27 +6,6 @@ import type { import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export type Project = Omit & { 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 - 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 } diff --git a/packages/app/src/utils/session-title.ts b/packages/app/src/utils/session-title.ts index ca04c010470..ccab3aa2e7f 100644 --- a/packages/app/src/utils/session-title.ts +++ b/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) { + return displayLabel(session) +} + export function sessionTitle(title?: string) { if (!title) return title const match = title.match(pattern) diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts index b15c23b6604..d5ca75511f3 100644 --- a/packages/app/src/utils/session.test.ts +++ b/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 () => { diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts index de015411316..a299354f503 100644 --- a/packages/app/src/utils/session.ts +++ b/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, input: Omit) { - const load = async (cursor?: string): Promise => { + const load = async (cursor?: string): Promise => { 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))] } diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 081ce87e1e6..76a713e970d 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/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, 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 } diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 056fc9c0fd9..110391c1426 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/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 + export type NormalizedProviderListResponse = { all: Map default: { @@ -17,7 +19,7 @@ type Data = { color?: string }[] provider?: NormalizedProviderListResponse - session: Session[] + session: SessionSummary[] session_status: { [sessionID: string]: SessionStatus }