From a0fc75428b6895a6b227a5a7b3f95c66200a1eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Raposo?= <57062437+JohnFox191@users.noreply.github.com> Date: Sat, 2 May 2026 19:14:15 +0100 Subject: [PATCH] feat: add session history browser to desktop sidebar Adds a session history browser to the desktop app sidebar, allowing users to browse and switch between past sessions organized by directory or project. --- .../session-browser/directory-node.tsx | 162 ++++++++++++ .../src/components/session-browser/panel.tsx | 248 ++++++++++++++++++ .../session-browser/session-node.tsx | 26 ++ packages/app/src/context/global-sdk.tsx | 28 +- packages/app/src/custom-elements.d.ts | 18 +- packages/app/src/pages/layout.tsx | 164 +++++++++++- .../app/src/pages/layout/sidebar-project.tsx | 5 + .../app/src/pages/layout/sidebar-shell.tsx | 14 +- .../server/routes/instance/experimental.ts | 130 +++++++++ .../instance/httpapi/groups/experimental.ts | 84 ++++++ .../instance/httpapi/handlers/experimental.ts | 63 ++++- packages/opencode/src/session/session.sql.ts | 1 + packages/opencode/src/session/session.ts | 94 ++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 136 ++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 94 +++++++ packages/ui/src/components/icon.tsx | 1 + 16 files changed, 1256 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/components/session-browser/directory-node.tsx create mode 100644 packages/app/src/components/session-browser/panel.tsx create mode 100644 packages/app/src/components/session-browser/session-node.tsx diff --git a/packages/app/src/components/session-browser/directory-node.tsx b/packages/app/src/components/session-browser/directory-node.tsx new file mode 100644 index 000000000000..fa339f37f664 --- /dev/null +++ b/packages/app/src/components/session-browser/directory-node.tsx @@ -0,0 +1,162 @@ +import { For, Show, createSignal, onCleanup } from "solid-js" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { SessionNode } from "./session-node" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export type TreeNode = { + name: string + fullPath: string + count: number + ownCount: number + isLeaf: boolean + children: TreeNode[] +} + +export type DirectoryNodeProps = { + node: TreeNode + depth: number + sessionsByDir: Record + metaByDir: Record + expandedDirs: Record + sessionsVisible: Record + onToggleDir: (directory: string) => void + onToggleSessions: (directory: string) => void + onSelectSession: (directory: string, sessionId: string) => void + onLoadMore: (directory: string) => void + currentSessionId?: string +} + +function useSentinel(onVisible: () => void) { + return (el: HTMLDivElement) => { + if (typeof IntersectionObserver === "undefined") return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) onVisible() + }, + { threshold: 0.1 }, + ) + observer.observe(el) + onCleanup(() => observer.disconnect()) + } +} + +export function DirectoryNode(props: DirectoryNodeProps) { + const [treeExpanded, setTreeExpanded] = createSignal(false) + + const isOpen = () => + props.node.isLeaf + ? (props.expandedDirs[props.node.fullPath] ?? false) + : treeExpanded() + + const toggle = () => { + if (props.node.isLeaf) { + props.onToggleDir(props.node.fullPath) + } else { + setTreeExpanded((prev) => !prev) + } + } + + const sessionsShown = () => props.sessionsVisible[props.node.fullPath] ?? false + const sessions = () => props.sessionsByDir[props.node.fullPath] ?? [] + const meta = () => props.metaByDir[props.node.fullPath] + const loading = () => meta()?.loading ?? false + const hasMore = () => !(meta()?.complete ?? true) + + const indent = () => props.depth * 12 + 8 + + return ( + + + + + + + {props.node.name} + + {props.node.count} + + + + + + + + +
+ +
+ + 0 || loading()}> +
+ + {(session) => ( + props.onSelectSession(session.directory, session.id)} + /> + )} + + +
{ + if (!loading()) props.onLoadMore(props.node.fullPath) + })} + class="h-4" + /> + +
+
+ +
+ No sessions +
+
+ + + {(child) => ( + + )} + + + + ) +} diff --git a/packages/app/src/components/session-browser/panel.tsx b/packages/app/src/components/session-browser/panel.tsx new file mode 100644 index 000000000000..917888af3dd0 --- /dev/null +++ b/packages/app/src/components/session-browser/panel.tsx @@ -0,0 +1,248 @@ +import { For, Show, createMemo } from "solid-js" +import { Spinner } from "@opencode-ai/ui/spinner" +import { DirectoryNode } from "./directory-node" +import type { TreeNode } from "./directory-node" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export type ViewMode = "directories" | "projects" + +export type PanelProps = { + directories: Array<{ directory: string; count: number }> + projectCounts: Array<{ project_id: string; count: number; worktree: string | null; name: string | null }> + sessionsByDir: Record + metaByDir: Record + currentSessionId?: string + expandedDirs: Record + sessionsVisible: Record + onToggleDir: (directory: string) => void + onToggleSessions: (directory: string) => void + onSelectSession: (directory: string, sessionId: string) => void + onLoadMore: (directory: string) => void + loading?: boolean + error?: string + onRetry?: () => void + searchQuery: string + onSearchChange: (query: string) => void + viewMode: ViewMode + onViewModeChange: (mode: ViewMode) => void +} + +function normalizePath(p: string) { + return p.replace(/\\/g, "/") +} + +function pathName(p: string) { + return normalizePath(p).split("/").filter(Boolean).pop() ?? p +} + +function buildProjectNodes( + projectCounts: Array<{ project_id: string; count: number; worktree: string | null; name: string | null }>, + query: string, +): TreeNode[] { + // Aggregate by normalized worktree (multiple project IDs can share a worktree) + const worktreeMap = new Map() + for (const pc of projectCounts) { + if (!pc.worktree) continue + const key = normalizePath(pc.worktree).toLowerCase() + const existing = worktreeMap.get(key) + if (existing) { + existing.count += pc.count + } else { + worktreeMap.set(key, { worktree: pc.worktree, name: pc.name, count: pc.count }) + } + } + return [...worktreeMap.values()] + .filter((entry) => entry.count > 0) + .map((entry) => { + const name = entry.name || pathName(entry.worktree) + return { name, fullPath: normalizePath(entry.worktree), count: entry.count, ownCount: entry.count, isLeaf: true, children: [] as TreeNode[] } + }) + .filter((node) => !query || node.name.toLowerCase().includes(query)) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +function buildTree(directories: Array<{ directory: string; count: number }>): TreeNode[] { + if (directories.length === 0) return [] + + type TrieNode = { + segment: string + fullPath: string + children: Map + originalCount: number | null + } + + const root = new Map() + + for (const dir of directories) { + const parts = dir.directory.replace(/\\/g, "/").split("/").filter(Boolean) + let current = root + + for (let i = 0; i < parts.length; i++) { + const segment = parts[i]! + const pathSoFar = parts.slice(0, i + 1).join("/") + + if (!current.has(segment)) { + current.set(segment, { + segment, + fullPath: pathSoFar, + children: new Map(), + originalCount: null, + }) + } + + if (i === parts.length - 1) { + current.get(segment)!.originalCount = dir.count + } + + current = current.get(segment)!.children + } + } + + function toTreeNode(trie: TrieNode): TreeNode { + const children: TreeNode[] = [] + for (const child of trie.children.values()) { + children.push(toTreeNode(child)) + } + const isLeaf = trie.children.size === 0 + const ownCount = trie.originalCount ?? 0 + const childSum = children.reduce((sum, c) => sum + c.count, 0) + const count = ownCount + childSum + return { name: trie.segment, fullPath: trie.fullPath, count, ownCount, isLeaf, children } + } + + function collapse(node: TreeNode): TreeNode { + const children = node.children.map(collapse) + if (children.length === 1 && !node.isLeaf) { + const child = children[0]! + return { + name: node.name + "/" + child.name, + fullPath: child.isLeaf ? child.fullPath : node.fullPath + "/" + child.name, + count: node.ownCount + child.count, + ownCount: node.ownCount + child.ownCount, + isLeaf: child.isLeaf, + children: child.children, + } + } + return { ...node, children } + } + + return [...root.values()].map((trie) => { + const processed = toTreeNode(trie) + return { ...processed, children: processed.children.map(collapse) } + }) +} + +const viewModes: { key: ViewMode; label: string }[] = [ + { key: "directories", label: "Directory Tree" }, + { key: "projects", label: "Projects" }, +] + +export function SessionBrowserPanel(props: PanelProps) { + const query = createMemo(() => props.searchQuery.toLowerCase().trim()) + + const filteredDirectories = createMemo(() => { + const q = query() + if (!q) return props.directories + return props.directories.filter((d) => d.directory.toLowerCase().includes(q)) + }) + + const tree = createMemo(() => { + if (props.viewMode === "projects") { + return buildProjectNodes(props.projectCounts, query()) + } + return buildTree(filteredDirectories()) + }) + + const emptyLabel = createMemo(() => { + if (query()) return "No matches" + if (props.viewMode === "projects") return "No projects with sessions" + return "No directories found" + }) + + return ( +
+
+
+ Session History + props.onSearchChange(e.currentTarget.value)} + class="h-6 min-w-0 flex-1 rounded bg-surface-raised-base px-2 text-12-regular text-text-standard placeholder:text-text-weak focus:outline-none focus:ring-1 focus:ring-border-interactive-base" + /> +
+
+ + {(mode) => ( + + )} + +
+
+
+ 0} + fallback={ +
+ +
+ } + > + + {props.error} + {props.onRetry && ( + + )} +
+ } + > + 0} + fallback={ +
+ {emptyLabel()} +
+ } + > + + {(node) => ( + + )} + +
+ + +
+
+ ) +} diff --git a/packages/app/src/components/session-browser/session-node.tsx b/packages/app/src/components/session-browser/session-node.tsx new file mode 100644 index 000000000000..230a9f343203 --- /dev/null +++ b/packages/app/src/components/session-browser/session-node.tsx @@ -0,0 +1,26 @@ +import { DateTime } from "luxon" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export type SessionNodeProps = { + session: Session + active?: boolean + onClick: () => void +} + +export function SessionNode(props: SessionNodeProps) { + const title = () => props.session.title || "Untitled" + const relativeTime = () => DateTime.fromMillis(props.session.time.updated).toRelative() + + return ( +
+ {title()} + {relativeTime()} +
+ ) +} diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index e53d60d5a0ea..2b912de2b41d 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -2,7 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" -import { batch, onCleanup, onMount } from "solid-js" +import { batch, createEffect, createSignal, onCleanup, onMount } from "solid-js" import z from "zod" import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" @@ -228,15 +228,31 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo flush() }) - const sdk = createSdkForServer({ - server: server.current.http, - fetch: platform.fetch, - throwOnError: true, + const [client, setClient] = createSignal( + createSdkForServer({ + server: server.current!.http, + fetch: platform.fetch, + throwOnError: true, + }), + ) + + createEffect(() => { + const current = server.current + if (!current) return + setClient( + createSdkForServer({ + server: current.http, + fetch: platform.fetch, + throwOnError: true, + }), + ) }) return { url: currentServer.http.url, - client: sdk, + get client() { + return client() + }, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebda..49ec4449fa20 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1,17 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +/** + * TypeScript declaration for the custom element. + * This tells TypeScript that is a valid JSX element in SolidJS. + * Required for using the @pierre/diffs web component in .tsx files. + */ + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7e9e2d32aaba..2ac51873630a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -3,6 +3,7 @@ import { createEffect, createMemo, createResource, + createSignal, For, on, onCleanup, @@ -87,6 +88,7 @@ import { } from "./layout/sidebar-workspace" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" +import { SessionBrowserPanel, type ViewMode } from "@/components/session-browser/panel" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -100,6 +102,7 @@ export default function Layout(props: ParentProps) { workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, gettingStartedDismissed: false, + browserOpen: false, }), ) @@ -199,6 +202,130 @@ export default function Layout(props: ParentProps) { }, }) + // Browser state (non-persisted, in-memory) + const [browserExpandedDirs, setBrowserExpandedDirs] = createStore({} as Record) + const [browserDirectories, setBrowserDirectories] = createSignal>([]) + const [browserProjectCounts, setBrowserProjectCounts] = createSignal>([]) + const [browserLoading, setBrowserLoading] = createSignal(false) + const [browserSessions, setBrowserSessions] = createStore({} as Record) + const [browserMeta, setBrowserMeta] = createStore( + {} as Record, + ) + const [browserSessionsVisible, setBrowserSessionsVisible] = createStore({} as Record) + const [browserSearch, setBrowserSearch] = createSignal("") + const [browserViewMode, setBrowserViewMode] = createSignal("directories") + + // Map worktree path → project IDs (for fetching sessions by project) + const worktreeProjectIds = createMemo(() => { + const map = new Map() + for (const pc of browserProjectCounts()) { + if (!pc.worktree) continue + const key = pc.worktree.replace(/\\/g, "/").toLowerCase() + const ids = map.get(key) ?? [] + ids.push(pc.project_id) + map.set(key, ids) + } + return map + }) + + async function fetchDirectories() { + setBrowserLoading(true) + const result = await globalSDK.client.experimental.session.directories().catch((e) => { + console.error("[session-browser] fetchDirectories failed:", e) + return undefined + }) + if (result?.data) setBrowserDirectories(result.data as Array<{ directory: string; count: number }>) + setBrowserLoading(false) + } + + async function fetchProjectCounts() { + const result = await globalSDK.client.experimental.session.projectCounts().catch((e) => { + console.error("[session-browser] fetchProjectCounts failed:", e) + return undefined + }) + if (result?.data) setBrowserProjectCounts(result.data as Array<{ project_id: string; count: number; worktree: string | null; name: string | null }>) + } + + async function fetchSessions(rawDirectory: string, cursor?: number, byProject?: boolean) { + const directory = rawDirectory.replace(/\\/g, "/") + setBrowserMeta(directory, { loading: true, cursor, complete: false }) + + const projectIds = byProject + ? worktreeProjectIds().get(directory.toLowerCase()) + : undefined + const result = projectIds + ? await fetchSessionsByProject(directory, projectIds, cursor) + : await fetchSessionsByDirectory(directory, cursor) + + if (!result) { + setBrowserMeta(directory, "loading", false) + return + } + if (cursor) { + setBrowserSessions(directory, (prev) => [...(prev ?? []), ...result.sessions]) + } else { + setBrowserSessions(directory, result.sessions) + } + setBrowserMeta(directory, { loading: false, cursor: result.nextCursor, complete: !result.nextCursor }) + } + + async function fetchSessionsByDirectory(directory: string, cursor?: number) { + const result = await globalSDK.client.experimental.session + .browse({ directory, roots: true, limit: 20, cursor }) + .catch((e) => { + console.error("[session-browser] fetchSessions failed:", e) + return undefined + }) + if (!result) return undefined + const sessions = (result.data ?? []) as Session[] + const nextCursorHeader = result.response.headers.get("x-next-cursor") + return { sessions, nextCursor: nextCursorHeader ? Number(nextCursorHeader) : undefined } + } + + async function fetchSessionsByProject(_storeKey: string, projectIds: string[], cursor?: number) { + const result = await globalSDK.client.experimental.session.browseProject({ project_id: projectIds.join(","), limit: 20, cursor }).catch((e) => { + console.error("[session-browser] fetchProjectSessions failed:", e) + return undefined + }) + if (!result) return undefined + const sessions = (result.data ?? []) as Session[] + const nextCursorHeader = result.response.headers.get("x-next-cursor") + return { sessions, nextCursor: nextCursorHeader ? Number(nextCursorHeader) : undefined } + } + + function toggleBrowserDir(rawDirectory: string) { + const directory = rawDirectory.replace(/\\/g, "/") + setBrowserExpandedDirs(directory, !browserExpandedDirs[directory]) + } + + function toggleBrowserSessions(rawDirectory: string) { + const directory = rawDirectory.replace(/\\/g, "/") + const visible = browserSessionsVisible[directory] + setBrowserSessionsVisible(directory, !visible) + if (!visible && !browserSessions[directory]?.length) { + fetchSessions(directory, undefined, browserViewMode() === "projects") + } + } + + function loadMoreSessions(directory: string) { + const meta = browserMeta[directory] + if (!meta || meta.loading || meta.complete) return + fetchSessions(directory, meta.cursor, browserViewMode() === "projects") + } + + function selectBrowserSession(directory: string, sessionId: string) { + layout.projects.open(directory) + server.projects.touch(directory) + navigateWithSidebarReset(`/${base64Encode(directory)}/session/${sessionId}`) + } + + createEffect(() => { + if (store.browserOpen) { + fetchDirectories() + fetchProjectCounts() + } + }) + onCleanup(() => { dialogDead = true dialogRun += 1 @@ -1999,6 +2126,7 @@ export default function Layout(props: ParentProps) { setState("hoverProject", hoverOpen ? worktree : undefined) }, navigateToProject, + closeBrowser: () => setStore("browserOpen", false), openSidebar: () => layout.sidebar.open(), closeProject, showEditProjectDialog, @@ -2346,8 +2474,42 @@ export default function Layout(props: ParentProps) { helpLabel={() => language.t("sidebar.help")} onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} renderPanel={() => - mobile ? : + store.browserOpen ? ( +
+ +
+ ) : mobile ? ( + + ) : ( + + ) } + browserOpen={() => store.browserOpen} + onToggleBrowser={() => { + const opening = !store.browserOpen + setStore("browserOpen", opening) + if (opening && !layout.sidebar.opened()) layout.sidebar.open() + }} /> ) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 2ba20092c585..6da1bead7662 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -24,6 +24,7 @@ export type ProjectSidebarContext = { onProjectFocus: (worktree: string) => void onHoverOpenChanged: (worktree: string, hovered: boolean) => void navigateToProject: (directory: string) => void + closeBrowser: () => void openSidebar: () => void closeProject: (directory: string) => void showEditProjectDialog: (project: LocalProject) => void @@ -63,6 +64,7 @@ const ProjectTile = (props: { onProjectMouseLeave: (worktree: string) => void onProjectFocus: (worktree: string) => void navigateToProject: (directory: string) => void + closeBrowser: () => void showEditProjectDialog: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void workspacesEnabled: (project: LocalProject) => boolean @@ -135,6 +137,7 @@ const ProjectTile = (props: { }} onClick={() => { props.setOpen(false) + props.closeBrowser() if (props.selected()) { layout.sidebar.toggle() return @@ -255,6 +258,7 @@ const ProjectPreviewPanel = (props: { variant="ghost" class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" onClick={() => { + props.ctx.closeBrowser() props.ctx.openSidebar() props.ctx.onHoverOpenChanged(props.project.worktree, false) if (props.selected()) return @@ -320,6 +324,7 @@ export const SortableProject = (props: { onProjectMouseLeave={props.ctx.onProjectMouseLeave} onProjectFocus={props.ctx.onProjectFocus} navigateToProject={props.ctx.navigateToProject} + closeBrowser={props.ctx.closeBrowser} showEditProjectDialog={props.ctx.showEditProjectDialog} toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces} workspacesEnabled={props.ctx.workspacesEnabled} diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index ca36af2a421c..5866f01925e5 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" import { DragDropProvider, DragDropSensors, @@ -31,6 +31,8 @@ export const SidebarContent = (props: { helpLabel: Accessor onOpenHelp: () => void renderPanel: () => JSX.Element + browserOpen?: Accessor + onToggleBrowser?: () => void }): JSX.Element => { const expanded = createMemo(() => !!props.mobile || props.opened()) const placement = () => (props.mobile ? "bottom" : "right") @@ -85,6 +87,16 @@ export const SidebarContent = (props: { aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined} /> + + + {props.renderProjectOverlay()} diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 7e09fb9ad322..6d1c2b56364d 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -393,6 +393,136 @@ export const ExperimentalRoutes = lazy(() => return c.json(list) }, ) + .get( + "/session/directories", + describeRoute({ + summary: "List session directories", + description: "Get a list of all directories that contain sessions, with their session counts.", + operationId: "experimental.session.directories", + responses: { + 200: { + description: "Directories with session counts", + content: { + "application/json": { + schema: resolver(z.array(z.object({ directory: z.string(), count: z.number() }))), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.session.directories", c, function* () { + return Array.from(Session.listDirectories()) + }), + ) + .get( + "/session/project-counts", + describeRoute({ + summary: "Session counts per project", + description: "Get the number of sessions grouped by project_id.", + operationId: "experimental.session.projectCounts", + responses: { + 200: { + description: "Project session counts", + content: { + "application/json": { + schema: resolver(z.array(z.object({ project_id: z.string(), count: z.number(), worktree: z.string().nullable(), name: z.string().nullable() }))), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ExperimentalRoutes.session.projectCounts", c, function* () { + return Array.from(Session.listProjectCounts()) + }), + ) + .get( + "/session/browse", + describeRoute({ + summary: "Browse sessions by directory", + description: "Get a list of sessions in a specific directory, sorted by most recently updated.", + operationId: "experimental.session.browse", + responses: { + 200: { + description: "Sessions in directory", + content: { + "application/json": { + schema: resolver(Session.Info.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + directory: z.string(), + roots: QueryBoolean.optional(), + cursor: z.coerce.number().optional(), + limit: z.coerce.number().optional(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const limit = query.limit ?? 50 + const sessions = Array.from( + Session.listByNormalizedDirectory({ + directory: query.directory, + roots: queryBoolean(query.roots), + cursor: query.cursor, + limit: limit + 1, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + if (sessions.length > limit && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) + }, + ) + .get( + "/session/browse-project", + describeRoute({ + summary: "Browse sessions by project", + description: "Get a list of sessions for a specific project, sorted by most recently updated.", + operationId: "experimental.session.browseProject", + responses: { + 200: { + description: "Sessions in project", + content: { + "application/json": { + schema: resolver(Session.Info.zod.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + project_id: z.string(), + cursor: z.coerce.number().optional(), + limit: z.coerce.number().optional(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const limit = query.limit ?? 50 + const sessions = Array.from( + Session.listByProjectIds({ + projectIds: query.project_id.split(","), + cursor: query.cursor, + limit: limit + 1, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + if (sessions.length > limit && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) + }, + ) .get( "/resource", describeRoute({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index e4a86ca1394d..3f5cf80db56b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -73,9 +73,35 @@ export const ExperimentalPaths = { worktree: "/experimental/worktree", worktreeReset: "/experimental/worktree/reset", session: "/experimental/session", + sessionDirectories: "/experimental/session/directories", + sessionProjectCounts: "/experimental/session/project-counts", + sessionBrowse: "/experimental/session/browse", + sessionBrowseProject: "/experimental/session/browse-project", resource: "/experimental/resource", } as const +export const SessionDirectoryListQuery = Schema.Struct({ + directory: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + cursor: Schema.optional(Schema.NumberFromString), + limit: Schema.optional(Schema.NumberFromString), +}) + +export const SessionProjectCountsQuery = Schema.Struct({}) + +export const SessionBrowseQuery = Schema.Struct({ + directory: Schema.String, + roots: Schema.optional(QueryBoolean), + cursor: Schema.optional(Schema.NumberFromString), + limit: Schema.optional(Schema.NumberFromString), +}) + +export const SessionBrowseProjectQuery = Schema.Struct({ + project_id: Schema.String, + cursor: Schema.optional(Schema.NumberFromString), + limit: Schema.optional(Schema.NumberFromString), +}) + export const ExperimentalApi = HttpApi.make("experimental") .add( HttpApiGroup.make("experimental") @@ -185,6 +211,64 @@ export const ExperimentalApi = HttpApi.make("experimental") "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", }), ), + HttpApiEndpoint.get("sessionDirectories", ExperimentalPaths.sessionDirectories, { + success: described( + Schema.Array( + Schema.Struct({ + directory: Schema.String, + count: NonNegativeInt, + }), + ), + "Directories with session counts", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.session.directories", + summary: "List session directories", + description: + "Get a list of all directories that contain sessions, with their session counts.", + }), + ), + HttpApiEndpoint.get("sessionProjectCounts", ExperimentalPaths.sessionProjectCounts, { + success: described( + Schema.Array( + Schema.Struct({ + project_id: Schema.String, + count: NonNegativeInt, + worktree: Schema.NullOr(Schema.String), + name: Schema.NullOr(Schema.String), + }), + ), + "Project session counts", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.session.projectCounts", + summary: "Session counts per project", + description: "Get the number of sessions grouped by project_id.", + }), + ), + HttpApiEndpoint.get("sessionBrowse", ExperimentalPaths.sessionBrowse, { + query: SessionBrowseQuery, + success: described(Schema.Array(Session.Info), "Sessions in directory"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.session.browse", + summary: "Browse sessions by directory", + description: + "Get a list of sessions in a specific directory, sorted by most recently updated.", + }), + ), + HttpApiEndpoint.get("sessionBrowseProject", ExperimentalPaths.sessionBrowseProject, { + query: SessionBrowseProjectQuery, + success: described(Schema.Array(Session.Info), "Sessions in project"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.session.browseProject", + summary: "Browse sessions by project", + description: "Get a list of sessions for a specific project, sorted by most recently updated.", + }), + ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index cc958da30379..a19eb71dc080 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -12,7 +12,15 @@ import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" +import { + ConsoleSwitchPayload, + SessionBrowseProjectQuery, + SessionBrowseQuery, + SessionDirectoryListQuery, + SessionListQuery, + SessionProjectCountsQuery, + ToolListQuery, +} from "../groups/experimental" export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => Effect.gen(function* () { @@ -135,6 +143,55 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper }) }) + const sessionDirectories = Effect.fn("ExperimentalHttpApi.sessionDirectories")(function* () { + return Array.from(Session.listDirectories()) + }) + + const sessionProjectCounts = Effect.fn("ExperimentalHttpApi.sessionProjectCounts")(function* () { + return Array.from(Session.listProjectCounts()) + }) + + const sessionBrowse = Effect.fn("ExperimentalHttpApi.sessionBrowse")(function* (ctx: { + query: typeof SessionBrowseQuery.Type + }) { + const limit = ctx.query.limit ?? 50 + const sessions = Array.from( + Session.listByNormalizedDirectory({ + directory: ctx.query.directory, + roots: ctx.query.roots, + cursor: ctx.query.cursor, + limit: limit + 1, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + return HttpServerResponse.jsonUnsafe(list, { + headers: + sessions.length > limit && list.length > 0 + ? { "x-next-cursor": String(list[list.length - 1].time.updated) } + : undefined, + }) + }) + + const sessionBrowseProject = Effect.fn("ExperimentalHttpApi.sessionBrowseProject")(function* (ctx: { + query: typeof SessionBrowseProjectQuery.Type + }) { + const limit = ctx.query.limit ?? 50 + const sessions = Array.from( + Session.listByProjectIds({ + projectIds: ctx.query.project_id.split(","), + cursor: ctx.query.cursor, + limit: limit + 1, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + return HttpServerResponse.jsonUnsafe(list, { + headers: + sessions.length > limit && list.length > 0 + ? { "x-next-cursor": String(list[list.length - 1].time.updated) } + : undefined, + }) + }) + const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { return yield* mcp.resources() }) @@ -150,6 +207,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper .handle("worktreeRemove", worktreeRemove) .handle("worktreeReset", worktreeReset) .handle("session", session) + .handle("sessionDirectories", sessionDirectories) + .handle("sessionProjectCounts", sessionProjectCounts) + .handle("sessionBrowse", sessionBrowse) + .handle("sessionBrowseProject", sessionBrowseProject) .handle("resource", resource) }), ) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d65c7..1c6e534de0dc 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -42,6 +42,7 @@ export const SessionTable = sqliteTable( index("session_project_idx").on(table.project_id), index("session_workspace_idx").on(table.workspace_id), index("session_parent_idx").on(table.parent_id), + index("session_directory_idx").on(table.directory), ], ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e1d0c527aa86..478bb2101435 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -11,6 +11,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@/storage/db" import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" +import { sql } from "drizzle-orm" import { and } from "drizzle-orm" import { gte } from "drizzle-orm" import { isNull } from "drizzle-orm" @@ -142,8 +143,9 @@ const Share = Schema.Struct({ url: Schema.String, }) -// Legacy HTTP accepted negative values here. Keep archive timestamps permissive -// while excluding non-finite values that cannot round-trip through JSON. +// Legacy HTTP accepted any number here, and persisted data may contain +// negative values (but not Infinity/-Infinity/NaN). Keep archive timestamps +// permissive while other clocks stay non-negative. export const ArchivedTimestamp = Schema.Finite const Time = Schema.Struct({ @@ -899,4 +901,92 @@ export function* listGlobal(input?: { } } +export function* listDirectories() { + const rows = Database.use((db) => + db + .select({ + directory: sql`REPLACE(${SessionTable.directory}, '\\', '/')`, + count: sql`COUNT(*)`.mapWith(Number), + }) + .from(SessionTable) + .where(and(isNull(SessionTable.time_archived), isNull(SessionTable.parent_id))) + .groupBy(sql`REPLACE(${SessionTable.directory}, '\\', '/')`) + .orderBy(sql`COUNT(*) DESC`) + .all() + ) + for (const row of rows) yield row +} + +export function* listProjectCounts() { + const rows = Database.use((db) => + db + .select({ + project_id: SessionTable.project_id, + count: sql`COUNT(*)`.mapWith(Number), + worktree: ProjectTable.worktree, + name: ProjectTable.name, + }) + .from(SessionTable) + .leftJoin(ProjectTable, eq(SessionTable.project_id, ProjectTable.id)) + .where(and(isNull(SessionTable.time_archived), isNull(SessionTable.parent_id))) + .groupBy(SessionTable.project_id) + .orderBy(sql`COUNT(*) DESC`) + .all(), + ) + for (const row of rows) yield row +} + +export function* listByProjectIds(input: { + projectIds: string[] + cursor?: number + limit?: number +}) { + const conditions: SQL[] = [ + input.projectIds.length === 1 + ? eq(SessionTable.project_id, input.projectIds[0]!) + : inArray(SessionTable.project_id, input.projectIds), + isNull(SessionTable.time_archived), + isNull(SessionTable.parent_id), + ] + if (input.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor)) + + const limit = input.limit ?? 50 + const rows = Database.use((db) => + db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + .limit(limit + 1) + .all(), + ) + for (const row of rows) yield fromRow(row) +} + +export function* listByNormalizedDirectory(input: { + directory: string + roots?: boolean + cursor?: number + limit?: number +}) { + const conditions: SQL[] = [ + sql`REPLACE(${SessionTable.directory}, '\\', '/') = ${input.directory}`, + isNull(SessionTable.time_archived), + ] + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor)) + + const limit = input.limit ?? 50 + const rows = Database.use((db) => + db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + .limit(limit + 1) + .all() + ) + for (const row of rows) yield fromRow(row) +} + export * as Session from "./session" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67261d7499a8..5bd6a4dad1c5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -28,7 +28,11 @@ import type { ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, + ExperimentalSessionBrowseProjectResponses, + ExperimentalSessionBrowseResponses, + ExperimentalSessionDirectoriesResponses, ExperimentalSessionListResponses, + ExperimentalSessionProjectCountsResponses, ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, @@ -880,6 +884,138 @@ export class Session extends HeyApiClient { ...params, }) } + + /** + * List session directories + * + * Get a list of all directories that contain sessions, with their session counts. + */ + public directories( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session/directories", + ...options, + ...params, + }) + } + + /** + * Session counts per project + * + * Get the number of sessions grouped by project_id. + */ + public projectCounts( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session/project-counts", + ...options, + ...params, + }) + } + + /** + * Browse sessions by directory + * + * Get a list of sessions in a specific directory, sorted by most recently updated. + */ + public browse( + parameters: { + directory: string + workspace?: string + roots?: boolean | "true" | "false" + cursor?: number + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "cursor" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session/browse", + ...options, + ...params, + }) + } + + /** + * Browse sessions by project + * + * Get a list of sessions for a specific project, sorted by most recently updated. + */ + public browseProject( + parameters: { + directory?: string + workspace?: string + project_id: string + cursor?: number + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "project_id" }, + { in: "query", key: "cursor" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session/browse-project", + ...options, + ...params, + }) + } } export class Resource extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b925ec60969d..ec39280d4b8f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3257,6 +3257,100 @@ export type ExperimentalSessionListResponses = { export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] +export type ExperimentalSessionDirectoriesData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/session/directories" +} + +export type ExperimentalSessionDirectoriesResponses = { + /** + * Directories with session counts + */ + 200: Array<{ + directory: string + count: number + }> +} + +export type ExperimentalSessionDirectoriesResponse = + ExperimentalSessionDirectoriesResponses[keyof ExperimentalSessionDirectoriesResponses] + +export type ExperimentalSessionProjectCountsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/session/project-counts" +} + +export type ExperimentalSessionProjectCountsResponses = { + /** + * Project session counts + */ + 200: Array<{ + project_id: string + count: number + worktree: string | null + name: string | null + }> +} + +export type ExperimentalSessionProjectCountsResponse = + ExperimentalSessionProjectCountsResponses[keyof ExperimentalSessionProjectCountsResponses] + +export type ExperimentalSessionBrowseData = { + body?: never + path?: never + query: { + directory: string + workspace?: string + roots?: boolean | "true" | "false" + cursor?: number + limit?: number + } + url: "/experimental/session/browse" +} + +export type ExperimentalSessionBrowseResponses = { + /** + * Sessions in directory + */ + 200: Array +} + +export type ExperimentalSessionBrowseResponse = + ExperimentalSessionBrowseResponses[keyof ExperimentalSessionBrowseResponses] + +export type ExperimentalSessionBrowseProjectData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + project_id: string + cursor?: number + limit?: number + } + url: "/experimental/session/browse-project" +} + +export type ExperimentalSessionBrowseProjectResponses = { + /** + * Sessions in project + */ + 200: Array +} + +export type ExperimentalSessionBrowseProjectResponse = + ExperimentalSessionBrowseProjectResponses[keyof ExperimentalSessionBrowseProjectResponses] + export type ExperimentalResourceListData = { body?: never path?: never diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 2e4d1f53b7bc..c17c02fc514d 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -78,6 +78,7 @@ const icons = { "layout-bottom-full": ``, "dot-grid": ``, "circle-check": ``, + "clock-history": ``, copy: ``, check: ``, photo: ``,