mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 01:23:30 +00:00
refactor(app): use native session info (#40824)
This commit is contained in:
parent
e62918224f
commit
a0c77b71ae
49 changed files with 248 additions and 342 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Event, Session } from "@/types"
|
||||
import type { Event } from "@/types"
|
||||
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
|
@ -9,14 +8,14 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
|||
|
||||
export type HomeSessionEvent = {
|
||||
type: "session.created" | "session.updated" | "session.deleted"
|
||||
properties: { sessionID: string; info: Session }
|
||||
properties: { sessionID: string; info?: SessionInfo }
|
||||
}
|
||||
export type HomeSessionEvents = {
|
||||
sequence: number
|
||||
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||
}
|
||||
export type HomeSessionIndex = {
|
||||
sessions: Session[]
|
||||
sessions: SessionInfo[]
|
||||
eventSequence: number
|
||||
}
|
||||
|
||||
|
|
@ -126,23 +125,28 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st
|
|||
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||
// multiple directories. A bounded page could omit an old session updated today.
|
||||
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||
export function parseHomeSessionIndex(sessions: SessionInfo[]): Session[] {
|
||||
// parentID: null, order: "desc" }) and replace this full-table scan.
|
||||
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
|
||||
return sessions.flatMap((item) => {
|
||||
if (item.parentID || typeof item.time.archived === "number") return []
|
||||
return [toLegacySummary(item)]
|
||||
return [item]
|
||||
})
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
|
||||
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||
}
|
||||
|
||||
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
const index = sessions.findIndex((session) => session.id === info.id)
|
||||
if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") {
|
||||
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID))
|
||||
if (event.type === "session.deleted") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
if (!info) return sessions
|
||||
if (info.parentID || typeof info.time.archived === "number") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
|
|
@ -150,22 +154,3 @@ export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEve
|
|||
if (index === -1) return [...sessions, info]
|
||||
return sessions.with(index, info)
|
||||
}
|
||||
|
||||
function toLegacySummary(session: SessionInfo): Session {
|
||||
return {
|
||||
id: session.id,
|
||||
slug: session.id,
|
||||
projectID: session.projectID,
|
||||
workspaceID: session.location.workspaceID,
|
||||
directory: session.location.directory,
|
||||
path: session.subpath,
|
||||
parentID: session.parentID,
|
||||
cost: session.cost,
|
||||
tokens: session.tokens,
|
||||
title: withTimestampedFallback(session),
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
version: "",
|
||||
time: session.time,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type HomeSession = {
|
||||
id: string
|
||||
directory: string
|
||||
}
|
||||
type HomeSession = Pick<SessionInfo, "id" | "location">
|
||||
|
||||
export async function archiveHomeSession(input: {
|
||||
server: ServerConnection.Key
|
||||
|
|
@ -19,7 +17,7 @@ export async function archiveHomeSession(input: {
|
|||
input.remove()
|
||||
notifySessionTabsRemoved({
|
||||
server: input.server,
|
||||
directory: input.session.directory,
|
||||
directory: input.session.location.directory,
|
||||
sessionIDs: [input.session.id],
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,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()
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionLabel(session: Pick<SessionInfo, "title" | "parentID">) {
|
||||
return displayLabel(session)
|
||||
}
|
||||
|
||||
export function sessionTitle(title?: string) {
|
||||
if (!title) return title
|
||||
const match = title.match(pattern)
|
||||
|
|
|
|||
|
|
@ -1,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 () => {
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue