From 488445a6794f02e2bc94035744998a81ab0abe56 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 21:58:53 -0400 Subject: [PATCH] fix(tui): correct project-aware session lists --- .../app/src/context/global-sync/bootstrap.ts | 2 +- packages/app/src/context/global-sync/utils.ts | 1 + packages/app/src/utils/server-compat.ts | 13 +- packages/cli/test/session-target.test.ts | 2 +- .../client/src/promise/generated/types.ts | 82 +++++----- packages/core/src/location.ts | 2 +- packages/core/src/project.ts | 55 ++++++- packages/core/src/session.ts | 33 ++-- .../test/effect/layer-node/node-build.test.ts | 4 +- packages/core/test/fixture/location.ts | 3 +- packages/core/test/location.test.ts | 2 + packages/core/test/move-session.test.ts | 21 --- packages/core/test/plugin/host.ts | 18 ++- packages/core/test/project.test.ts | 41 ++++- packages/core/test/session-compact.test.ts | 2 +- packages/core/test/session-create.test.ts | 24 ++- packages/core/test/session-generate.test.ts | 6 - .../core/test/session-instructions.test.ts | 2 +- packages/core/test/session-log.test.ts | 2 +- packages/core/test/session-remove.test.ts | 2 +- packages/core/test/session-skill.test.ts | 2 +- packages/core/test/session-wait.test.ts | 2 +- packages/plugin/src/tui/context.ts | 5 + packages/schema/src/location.ts | 1 + packages/schema/src/project.ts | 3 +- packages/server/src/handlers/project.ts | 6 +- .../tui/src/component/dialog-integration.tsx | 4 +- .../tui/src/component/dialog-move-session.tsx | 8 +- .../tui/src/component/dialog-session-list.tsx | 153 ++++++++++++------ packages/tui/src/component/dialog-skill.tsx | 8 +- packages/tui/src/context/data.tsx | 64 +++++--- packages/tui/src/routes/session/index.tsx | 2 +- packages/tui/src/ui/dialog-select.tsx | 4 +- packages/tui/test/cli/tui/data.test.tsx | 92 +++++++++-- .../tui/test/cli/tui/dialog-select.test.tsx | 13 ++ packages/tui/test/fixture/tui-client.ts | 20 +-- packages/tui/test/mini/runtime.test.ts | 16 +- .../tui/test/mini/stream-v2.transport.test.ts | 6 +- 38 files changed, 506 insertions(+), 220 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 39221d551fa..844ce7cfc28 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => api.list().then((projects) => { return projects .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .map(normalizeProjectInfo) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() .sort((a, b) => cmp(a.id, b.id)) }), diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 333bf094f0f..0fd40ff2dbe 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -168,6 +168,7 @@ export function sanitizeProject(project: Project) { export function normalizeProjectInfo(project: Project | CurrentProject): Project { return { ...project, + worktree: "canonical" in project ? project.canonical : project.worktree, vcs: project.vcs === "git" ? "git" : undefined, } } diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index b37bf6db48d..8a2c42f198b 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { const located = (data: T, value?: { directory?: string }) => ({ location: { directory: directory(value) ?? "", - project: { id: "", directory: directory(value) ?? "" }, + project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" }, }, data, }) @@ -298,12 +298,19 @@ function createV1Api(input: CompatibleInput): CompatibleApi { project: { ...input.current.project, async list() { - return ((await legacy().project.list()).data ?? []) as Project[] + return ((await legacy().project.list()).data ?? []).map((project) => ({ + ...project, + canonical: project.worktree, + })) }, async current(value?: Parameters[0]) { const result = await legacy(value?.location).project.current() if (!result.data) throw new Error("Project not found") - return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + return { + id: result.data.id, + directory: result.data.worktree, + canonical: result.data.worktree, + } satisfies ProjectCurrent }, // async update(value: Parameters[0]) { // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) diff --git a/packages/cli/test/session-target.test.ts b/packages/cli/test/session-target.test.ts index ea34aada7f4..0a5bf8c55d9 100644 --- a/packages/cli/test/session-target.test.ts +++ b/packages/cli/test/session-target.test.ts @@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target" function location(directory: string, workspaceID?: string): LocationGetOutput { - return { directory, workspaceID, project: { id: "project", directory } } + return { directory, workspaceID, project: { id: "project", directory, canonical: directory } } } function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 98680994382..8c8b6295a6f 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string } export type ProjectTime = { created: number; updated: number; initialized?: number } -export type ProjectCurrent = { id: string; directory: string } +export type ProjectCurrent = { id: string; directory: string; canonical: string } export type ProjectDirectory = { directory: string; strategy?: string } @@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array; templates: Arr export type Project = { id: string - worktree: string + canonical: string vcs?: ProjectVcs name?: string icon?: ProjectIcon @@ -2515,7 +2515,11 @@ export type LocationGetInput = { }["location"] } -export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } } +export type LocationGetOutput = { + directory: string + workspaceID?: string + project: { id: string; directory: string; canonical: string } +} export type AgentListInput = { readonly location?: { @@ -2524,7 +2528,7 @@ export type AgentListInput = { } export type AgentListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -2536,7 +2540,7 @@ export type AgentGetInput = { } export type AgentGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: AgentInfo } @@ -2547,7 +2551,7 @@ export type PluginListInput = { } export type PluginListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3231,7 +3235,7 @@ export type ModelListInput = { } export type ModelListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3242,7 +3246,7 @@ export type ModelDefaultInput = { } export type ModelDefaultOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ModelInfo | null } @@ -3269,7 +3273,7 @@ export type ProviderListInput = { } export type ProviderListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3281,7 +3285,7 @@ export type ProviderGetInput = { } export type ProviderGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ProviderInfo } @@ -3292,7 +3296,7 @@ export type IntegrationListInput = { } export type IntegrationListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3304,7 +3308,7 @@ export type IntegrationGetInput = { } export type IntegrationGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationInfo | null } @@ -3351,7 +3355,7 @@ export type IntegrationOauthConnectInput = { } export type IntegrationOauthConnectOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: { attemptID: string url: string @@ -3370,7 +3374,7 @@ export type IntegrationOauthStatusInput = { } export type IntegrationOauthStatusOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationAttemptStatus } @@ -3405,7 +3409,7 @@ export type IntegrationCommandConnectInput = { } export type IntegrationCommandConnectOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationCommandAttempt } @@ -3418,7 +3422,7 @@ export type IntegrationCommandStatusInput = { } export type IntegrationCommandStatusOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: IntegrationCommandAttemptStatus } @@ -3439,7 +3443,7 @@ export type McpListInput = { } export type McpListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -3528,7 +3532,7 @@ export type McpResourceCatalogInput = { } export type McpResourceCatalogOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: McpResourceCatalog } @@ -3577,7 +3581,7 @@ export type FormRequestListInput = { } export type FormRequestListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4433,7 +4437,7 @@ export type PermissionRequestListInput = { } export type PermissionRequestListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4555,7 +4559,7 @@ export type FileListInput = { } export type FileListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4587,7 +4591,7 @@ export type FileFindInput = { } export type FileFindOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4598,7 +4602,7 @@ export type CommandListInput = { } export type CommandListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4609,7 +4613,7 @@ export type SkillListInput = { } export type SkillListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4622,7 +4626,7 @@ export type PtyListInput = { } export type PtyListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4668,7 +4672,7 @@ export type PtyCreateInput = { } export type PtyCreateOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Pty } @@ -4680,7 +4684,7 @@ export type PtyGetInput = { } export type PtyGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Pty } @@ -4697,7 +4701,7 @@ export type PtyUpdateInput = { } export type PtyUpdateOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Pty } @@ -4717,7 +4721,7 @@ export type ShellListInput = { } export type ShellListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4752,7 +4756,7 @@ export type ShellCreateInput = { } export type ShellCreateOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ShellInfo1 } @@ -4764,7 +4768,7 @@ export type ShellGetInput = { } export type ShellGetOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ShellInfo1 } @@ -4777,7 +4781,7 @@ export type ShellTimeoutInput = { } export type ShellTimeoutOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: ShellInfo1 } @@ -4801,7 +4805,7 @@ export type ShellOutputInput = { } export type ShellOutputOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: { output: string; cursor: number; size: number; truncated: boolean } } @@ -4821,7 +4825,7 @@ export type QuestionRequestListInput = { } export type QuestionRequestListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4851,7 +4855,7 @@ export type ReferenceListInput = { } export type ReferenceListOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4894,7 +4898,7 @@ export type VcsStatusInput = { } export type VcsStatusOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4917,7 +4921,7 @@ export type VcsDiffInput = { } export type VcsDiffOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4938,7 +4942,7 @@ export type WebsearchProvidersInput = { } export type WebsearchProvidersOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: Array } @@ -4951,6 +4955,6 @@ export type WebsearchQueryInput = { } export type WebsearchQueryOutput = { - location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } data: { providerID: string; results: Array } } diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index f5ccabf8c55..ac27cb6ca9e 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -25,7 +25,7 @@ const layer = (ref: Ref) => return Service.of({ directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: resolved.id, directory: resolved.directory }, + project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical }, vcs: resolved.vcs, }) }), diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 2fb0aabc18d..ba907367adb 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,7 +2,7 @@ export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" -import { asc, desc } from "drizzle-orm" +import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm" import path from "path" import { AbsolutePath } from "./schema" import { Database } from "./database/database" @@ -40,6 +40,7 @@ export interface Resolved { readonly previous?: ID readonly id: ID readonly directory: AbsolutePath + readonly canonical: AbsolutePath readonly vcs?: Vcs } @@ -83,7 +84,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info { : undefined return { id: row.id, - worktree: row.worktree, + canonical: row.worktree, vcs: row.vcs ?? undefined, name: row.name ?? undefined, icon, @@ -106,6 +107,40 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const projectDirectories = yield* ProjectDirectories.Service + const persist = Effect.fnUntraced(function* (project: Resolved) { + yield* db + .transaction((tx) => + Effect.gen(function* () { + const vcs = project.vcs?.type + yield* tx + .insert(ProjectTable) + .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { worktree: project.canonical, vcs: vcs ?? null }, + setWhere: or( + ne(ProjectTable.worktree, project.canonical), + vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs), + ), + }) + .run() + if (!project.vcs) return + yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx) + if (project.directory === project.canonical) return + yield* projectDirectories.create( + { + projectID: project.id, + directory: project.directory, + strategy: project.vcs.type === "git" ? "git_worktree" : undefined, + }, + tx, + ) + }), + ) + .pipe(Effect.orDie) + return project + }) + const list = Effect.fn("Project.list")(function* () { const rows = yield* db .select() @@ -211,17 +246,25 @@ const layer = Layer.effect( if (repo) { const previous = yield* cached(repo.commonDirectory) const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo)) - return { + const canonical = yield* git.worktree + .list(repo) + .pipe( + Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree), + Effect.catch(() => Effect.succeed(repo.worktree)), + ) + return yield* persist({ previous, id: id ?? ID.global, directory: repo.worktree, + canonical, vcs: { type: "git" as const, store: repo.commonDirectory }, - } + }) } const hg = yield* hgDiscover(input) - if (hg) return hg - return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined } + if (hg) return yield* persist({ ...hg, canonical: hg.directory }) + const directory = AbsolutePath.make(path.parse(input).root) + return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined }) }) const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0ac1b5cc621..bf1b38eaafe 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -3,7 +3,7 @@ export * from "./session/schema" import { Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" -import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" +import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm" import { Project } from "./project" import { Workspace } from "./workspace" import { Model } from "./model" @@ -325,6 +325,22 @@ const layer = Layer.effect( const shellLocks = KeyedMutex.makeUnsafe() const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) + const persistProject = (project: Project.Resolved) => { + const vcs = project.vcs?.type + return db + .insert(ProjectTable) + .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] }) + .onConflictDoUpdate({ + target: ProjectTable.id, + set: { worktree: project.canonical, vcs: vcs ?? null }, + setWhere: or( + ne(ProjectTable.worktree, project.canonical), + vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs), + ), + }) + .run() + .pipe(Effect.orDie) + } const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( @@ -347,12 +363,7 @@ const layer = Layer.effect( if (location === undefined) return yield* Effect.die(new Error("Session.create requires either location or an existing parentID")) const project = yield* projects.resolve(location.directory) - yield* db - .insert(ProjectTable) - .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) + yield* persistProject(project) const now = Date.now() const info = SessionV1.SessionInfo.make({ id: sessionID, @@ -451,6 +462,7 @@ const layer = Layer.effect( if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) + if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.parentID !== undefined) conditions.push( @@ -732,12 +744,7 @@ const layer = Layer.effect( ) return const project = yield* projects.resolve(directory) - yield* db - .insert(ProjectTable) - .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) + yield* persistProject(project) if ((yield* execution.active).has(input.sessionID)) { yield* execution.interrupt(input.sessionID) yield* execution.awaitIdle(input.sessionID) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index a4a2bf28605..0c7cc489f40 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -56,7 +56,7 @@ describe("node build", () => { Location.Service.of({ directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: service.directory }, + project: { id: Project.ID.global, directory: service.directory, canonical: service.directory }, }), ), { idleTimeToLive: "1 minute" }, @@ -79,7 +79,7 @@ describe("node build", () => { return Project.Service.of({ list: () => Effect.succeed([]), directories: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), commit: () => Effect.void, }) }), diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 40d8ed9dc36..d82d439943a 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -5,10 +5,11 @@ import { Effect, Layer } from "effect" import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { + const directory = input.projectDirectory ?? ref.directory return { directory: ref.directory, workspaceID: ref.workspaceID, - project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory }, + project: { id: Project.ID.global, directory, canonical: directory }, vcs: input.vcs, } satisfies Location.Interface } diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index b51c3c5bd83..a37234feca1 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -18,6 +18,7 @@ const projectLayer = Layer.succeed( Effect.succeed({ id: Project.ID.make("project"), directory: AbsolutePath.make("/repo"), + canonical: AbsolutePath.make("/main/repo"), vcs: { type: "git", store: AbsolutePath.make("/repo/.git") }, }), commit: () => Effect.void, @@ -34,6 +35,7 @@ describe("Location", () => { expect(location.workspaceID).toBe(workspaceID) expect(location.project.id).toBe(Project.ID.make("project")) expect(location.project.directory).toBe(AbsolutePath.make("/repo")) + expect(location.project.canonical).toBe(AbsolutePath.make("/main/repo")) expect(location.vcs).toEqual({ type: "git", store: AbsolutePath.make("/repo/.git"), diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 9a37f143b16..f97b0cfb867 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -11,7 +11,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { Job } from "@opencode-ai/core/job" import { Project } from "@opencode-ai/core/project" -import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" @@ -88,11 +87,6 @@ describe("MoveSession", () => { const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id const sessionID = Session.ID.make("ses_move") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ @@ -144,11 +138,6 @@ describe("MoveSession", () => { const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id const sessionID = Session.ID.make("ses_move_nested") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ @@ -204,11 +193,6 @@ describe("MoveSession", () => { const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id const sessionID = Session.ID.make("ses_move_project") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ @@ -268,11 +252,6 @@ describe("MoveSession", () => { const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id const sessionID = Session.ID.make("ses_move_nested_checkout") const { db } = yield* Database.Service - yield* db - .insert(ProjectTable) - .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 956c5353d24..975845f4500 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -121,7 +121,11 @@ export function agentHost(agent: Agent.Interface): Plugin.Context["agent"] { ? Effect.succeed({ location: new Location.Info({ directory: AbsolutePath.make("/"), - project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") }, + project: { + id: Project.ID.make("test"), + directory: AbsolutePath.make("/"), + canonical: AbsolutePath.make("/"), + }, }), data: agentInfo(value), }) @@ -163,7 +167,11 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog Effect.map((data) => ({ location: new Location.Info({ directory: AbsolutePath.make("/"), - project: { id: Project.ID.make("test"), directory: AbsolutePath.make("/") }, + project: { + id: Project.ID.make("test"), + directory: AbsolutePath.make("/"), + canonical: AbsolutePath.make("/"), + }, }), data: data.map(modelInfo), })), @@ -357,7 +365,11 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["websearch"] { const location = Location.Info.make({ directory: AbsolutePath.make("/tmp/websearch-test"), - project: { id: Project.ID.make("websearch-test"), directory: AbsolutePath.make("/tmp/websearch-test") }, + project: { + id: Project.ID.make("websearch-test"), + directory: AbsolutePath.make("/tmp/websearch-test"), + canonical: AbsolutePath.make("/tmp/websearch-test"), + }, }) return { providers: () => websearch.providers().pipe(Effect.map((data) => ({ location, data }))), diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 44d36fd85f8..7be0a5ad77c 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -47,13 +47,13 @@ describe("Project.list", () => { expect(yield* project.list()).toEqual([ { id: Project.ID.make("newer"), - worktree: abs("/newer"), + canonical: abs("/newer"), time: { created: 2, updated: 2, initialized: 3 }, sandboxes: [], }, { id: Project.ID.make("older"), - worktree: abs("/older"), + canonical: abs("/older"), vcs: "git", name: "Older", icon: { color: "#000000" }, @@ -105,6 +105,7 @@ describe("Project.resolve", () => { expect(result.id).toBe(Project.ID.make("global")) expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root) + expect(result.canonical).toBe(result.directory) expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), @@ -123,6 +124,7 @@ describe("Project.resolve", () => { expect(result.id).toBe(Project.ID.make("global")) expect(result.directory).toBe(yield* real(tmp.path)) + expect(result.canonical).toBe(result.directory) expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") }), @@ -327,13 +329,46 @@ describe("Project.resolve", () => { yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id")) yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet()) const project = yield* Project.Service + const db = (yield* Database.Service).db + const id = remoteID("github.com/owner/repo") + yield* db + .insert(ProjectTable) + .values({ + id, + worktree: abs("/stale-worktree"), + vcs: "hg", + name: "Preserved name", + icon_color: "#123456", + commands: { start: "bun dev" }, + sandboxes: [abs("/preserved-sandbox")], + time_created: 1, + time_updated: 1, + time_initialized: 2, + }) + .run() const result = yield* project.resolve(abs(worktree)) expect(result.directory).toBe(yield* real(worktree)) + expect(result.canonical).toBe(yield* real(tmp.path)) expect(result.previous).toBe(Project.ID.make("old-id")) - expect(result.id).toBe(remoteID("github.com/owner/repo")) + expect(result.id).toBe(id) expect(result.vcs?.type).toBe("git") + expect((yield* project.list()).find((item) => item.id === id)).toMatchObject({ + canonical: yield* real(tmp.path), + vcs: "git", + name: "Preserved name", + icon: { color: "#123456" }, + commands: { start: "bun dev" }, + sandboxes: [abs("/preserved-sandbox")], + time: { created: 1, initialized: 2 }, + }) + expect( + (yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)), + ).toEqual([ + { directory: yield* real(tmp.path) }, + { directory: yield* real(worktree), strategy: "git_worktree" }, + ]) }), ) }) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index a64f2367d52..61bb24a622b 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -34,7 +34,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 1bda9a54881..ceffdcf91d0 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -14,7 +14,7 @@ import { Model } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { Provider } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -32,7 +32,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), @@ -184,6 +184,26 @@ describe("Session.create", () => { }), ) + it.effect("filters project sessions by subpath", () => + Effect.gen(function* () { + const session = yield* Session.Service + const { db } = yield* Database.Service + const root = yield* session.create({ location, title: "root" }) + const nested = yield* session.create({ location, title: "nested" }) + + yield* db.update(SessionTable).set({ path: "packages/tui" }).where(eq(SessionTable.id, nested.id)).run() + + const page = yield* session.list({ + project: Project.ID.global, + subpath: RelativePath.make("packages/tui"), + parentID: null, + }) + + expect(page.data.map((item) => item.id)).toEqual([nested.id]) + expect(page.data.map((item) => item.id)).not.toContain(root.id) + }), + ) + it.effect("forks a session by replaying a durable fork event into copied projected rows", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 7ca2a9b18f1..9a0d1efa3f5 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -15,7 +15,6 @@ import { Location } from "@opencode-ai/core/location" import { McpInstructions } from "@opencode-ai/core/mcp/instructions" import { ID } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" -import { ProjectTable } from "@opencode-ai/core/project/sql" import { Provider } from "@opencode-ai/core/provider" import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -191,11 +190,6 @@ const setup = Effect.gen(function* () { agent.mode = "primary" }), ) - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .run() - .pipe(Effect.orDie) yield* db .insert(SessionTable) .values({ diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 1ccfec20683..fd4e00249c0 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -54,7 +54,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index f223542cafb..fa3961f2127 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -21,7 +21,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-remove.test.ts b/packages/core/test/session-remove.test.ts index 65728a7b7ce..cf3ce126cc6 100644 --- a/packages/core/test/session-remove.test.ts +++ b/packages/core/test/session-remove.test.ts @@ -17,7 +17,7 @@ const projects = Layer.succeed( Project.Service, Project.Service.of({ list: () => Effect.succeed([]), - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 2ab4a655519..7461f9f2c10 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const projects = Layer.mock(Project.Service, { - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }) const skills = Layer.mock(Skill.Service, { list: () => diff --git a/packages/core/test/session-wait.test.ts b/packages/core/test/session-wait.test.ts index b3861a29c3e..9fa8d1c682f 100644 --- a/packages/core/test/session-wait.test.ts +++ b/packages/core/test/session-wait.test.ts @@ -17,7 +17,7 @@ import { testEffect } from "./lib/effect" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const awaited: Session.ID[] = [] const projects = Layer.mock(Project.Service, { - resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }), }) const execution = Layer.mock(SessionExecution.Service, { awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)), diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 2bc5d393ca3..374e56aabe2 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -11,6 +11,7 @@ import type { OpenCodeEvent, PermissionSavedInfo, PermissionRequest, + Project, ProviderInfo, ReferenceInfo, SessionInfo, @@ -77,6 +78,10 @@ export interface Data { } } readonly project: { + list(): Project[] + get(projectID: string): Project | undefined + sync(): Promise + invalidate(): void readonly permission: { list(projectID: string): PermissionSavedInfo[] | undefined sync(projectID: string): Promise diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index 30a3e340216..d9104435c62 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -17,6 +17,7 @@ export class Info extends Schema.Class("Location.Info")({ project: Schema.Struct({ id: ProjectID, directory: AbsolutePath, + canonical: AbsolutePath, }), }) {} diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index e8524300a97..e47e45678c3 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -12,6 +12,7 @@ export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Projec export const Current = Schema.Struct({ id: ID, directory: AbsolutePath, + canonical: AbsolutePath, }).annotate({ identifier: "Project.Current" }) export interface Current extends Schema.Schema.Type {} export const Directory = Schema.Struct({ @@ -46,7 +47,7 @@ export interface Time extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, - worktree: Schema.String, + canonical: AbsolutePath, vcs: optional(Vcs), name: optional(Schema.String), icon: optional(Icon), diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts index 79206e6326a..e02aa814aa2 100644 --- a/packages/server/src/handlers/project.ts +++ b/packages/server/src/handlers/project.ts @@ -9,7 +9,11 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl .handle("project.list", () => Project.Service.use((project) => project.list())) .handle("project.current", () => Location.Service.use((location) => - Effect.succeed({ id: location.project.id, directory: location.project.directory }), + Effect.succeed({ + id: location.project.id, + directory: location.project.directory, + canonical: location.project.canonical, + }), ), ) .handle("project.directories", (ctx) => diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index f07d6f27cb5..6511a14036b 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -102,12 +102,12 @@ export function DialogIntegration( title="Connect a service" options={options()} emptyView={ - + No integrations available } noMatchView={ - + No integrations found } diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 0ca6a1168cb..af9d8209f58 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { options={options()} emptyView={ showError() ? ( - + Could not load project directories @@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { Close and reopen Move session to try again. ) : directories.loading || loadedProject.loading ? ( - + Loading project directories… ) : ( - + No project directories available ) } noMatchView={ - + No project directories found } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 3b489607b1b..ebe93e34fd2 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,6 +1,7 @@ -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" import path from "path" import type { SessionInfo } from "@opencode-ai/client" +import { TextAttributes } from "@opentui/core" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" @@ -32,52 +33,69 @@ export function DialogSessionList() { const shortcuts = Keymap.useShortcuts() const [search, setSearch] = createDebouncedSignal("", 150) const [toDelete, setToDelete] = createSignal() + const [allProjects, setAllProjects] = createSignal(false) - const [searchResults] = createResource(search, async (query) => { - if (!query) return - try { - if (!data.location.info()) await data.location.sync() - const current = data.location.info() - if (!current) throw new Error("Location unavailable") - const response = await client.api.session.list({ - project: current.project.id, - search: query, - limit: 50, - order: "desc", - parentID: null, - }) - return { query, sessions: response.data, error: undefined } - } catch (error) { - // A transient transport failure must degrade search, not crash the TUI - // through the root ErrorBoundary when the errored resource is read. - return { query, sessions: [] as SessionInfo[], error } - } - }) + const [searchResults, { mutate: setSearchResults }] = createResource( + () => ({ query: search().trim(), allProjects: allProjects() }), + async ({ query, allProjects }) => { + try { + if (!data.location.info()) await data.location.sync() + const current = data.location.info() + if (!current) throw new Error("Location unavailable") + const response = await client.api.session.list({ + ...(allProjects + ? {} + : { + project: current.project.id, + subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"), + }), + ...(query ? { search: query } : {}), + limit: 50, + order: "desc", + parentID: null, + }) + return { query, allProjects, sessions: response.data, error: undefined } + } catch (error) { + // A transient transport failure must degrade search, not crash the TUI + // through the root ErrorBoundary when the errored resource is read. + return { query, allProjects, sessions: [] as SessionInfo[], error } + } + }, + ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const localSessions = createMemo(() => { const query = filter().trim().toLowerCase() - const sessions = data.session.list() + const current = data.location.info() + const sessions = data.session + .list() + .filter( + (session) => + allProjects() || + (session.projectID === current?.project.id && session.location.directory === current.directory), + ) if (!query) return sessions return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query)) }) const sessions = createMemo(() => { - const query = filter() + const query = filter().trim() const local = localSessions() - if (!query) return local - if (query !== search() || searchResults.loading) return local + if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local const result = searchResults() - if (result?.query !== query || result.error) return local + if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local return result.sessions }) const searchState = createMemo(() => { - const query = filter() - if (!query) return { message: "No sessions available", error: false } - if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false } + const query = filter().trim() + if (query !== search().trim() || searchResults.loading) + return { message: query ? "Searching sessions…" : "Loading sessions…", error: false } const result = searchResults() if (result?.query === query && result.error) - return { message: "Could not search sessions. Change the search to try again.", error: true } - return { message: "No sessions found", error: false } + return { + message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.", + error: true, + } + return { message: query ? "No sessions found" : "No sessions available", error: false } }) const quickSwitchHint = createMemo(() => { @@ -91,6 +109,13 @@ export function DialogSessionList() { const hint = quickSwitchHint() return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : [] }) + const currentProjectName = createMemo(() => { + const current = data.location.info() + if (!current) return "" + const project = data.project.get(current.project.id) + if (!project) return "" + return project.name || path.basename(project.canonical) + }) const options = createMemo(() => { const today = new Date().toDateString() @@ -105,8 +130,12 @@ export function DialogSessionList() { const option = (session: SessionInfo, category: string) => { const directory = session.location.directory - const footer = - directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : "" + const project = data.project.get(session.projectID) + const footer = allProjects() + ? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20) + : directory !== data.location.info()?.project.directory + ? Locale.truncate(path.basename(directory), 20) + : "" const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id) const deleting = toDelete() === session.id return { @@ -139,6 +168,16 @@ export function DialogSessionList() { return ( + + Sessions + + + for {currentProjectName()} + + + } options={options()} skipFilter={true} current={currentSessionID()} @@ -146,13 +185,25 @@ export function DialogSessionList() { setFilter(query) setSearch(query) }} + bindings={[ + { + bind: "ctrl+a", + title: allProjects() ? "Show current directory sessions" : "Show all project sessions", + group: "Dialog", + run: () => { + setAllProjects((value) => !value) + }, + }, + ]} emptyView={ - - No sessions available + + + {searchState().message} + } noMatchView={ - + {searchState().message} @@ -178,24 +229,34 @@ export function DialogSessionList() { setToDelete(option.value) return } - void client.api.session.remove({ sessionID: option.value }).catch((error) => { - setToDelete(undefined) - toast.show({ - message: `Failed to delete session: ${errorMessage(error)}`, - variant: "error", - duration: 5000, + void client.api.session + .remove({ sessionID: option.value }) + .then(() => { + setSearchResults((result) => + result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result, + ) + }) + .catch((error) => { + setToDelete(undefined) + toast.show({ + message: `Failed to delete session: ${errorMessage(error)}`, + variant: "error", + duration: 5000, + }) }) - }) }, }, { command: "session.rename", title: "rename", - onTrigger: (option: { value: string }) => - DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title), + onTrigger: (option: { value: string; title: string }) => + DialogSessionRename.show(dialog, option.value, option.title), }, ]} - footerHints={quickSwitchFooterHints()} + footerHints={[ + ...quickSwitchFooterHints(), + { title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" }, + ]} /> ) } diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 3d2e31260ce..bbcd62cf41e 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) { emptyView={ + No skills available } > - + Could not load skills @@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) { - + Loading skills… } noMatchView={ - + No skills found } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 58802757902..384fd3499c9 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -15,6 +15,7 @@ import type { ModelInfo, PermissionSavedInfo, PermissionRequest, + Project, ProviderInfo, ReferenceInfo, SessionMessageInfo, @@ -80,6 +81,7 @@ type Store = { form: Record } project: { + info: Record permission: Record } location: Record @@ -139,6 +141,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ form: {}, }, project: { + info: {}, permission: {}, }, location: {}, @@ -954,10 +957,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ sync.invalidate(`session.pending:${sessionID}`) }, }, - sync(sessionID: string) { - return sync.run(`session:${sessionID}`, async () => { - setStore("session", "info", sessionID, await client.api.session.get({ sessionID })) - registerSession(sessionID) + sync(sessionID: string, options?: { children?: boolean }) { + return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { + const [info, children] = await Promise.all([ + client.api.session.get({ sessionID }), + options?.children + ? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data) + : [], + ]) + const sessions = [info, ...children] + setStore( + "session", + "info", + produce((draft) => { + for (const session of sessions) draft[session.id] = session + }), + ) + for (const session of sessions) { + sync.complete(`session:${session.id}`) + registerSession(session.id) + } }) }, invalidate(sessionID: string) { @@ -1037,6 +1056,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, project: { + list() { + return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated) + }, + get(projectID: string) { + return store.project.info[projectID] + }, + sync() { + return sync.run("project", async () => { + const projects = await client.api.project.list() + setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project])))) + }) + }, + invalidate() { + sync.invalidate("project") + }, permission: { list(projectID: string) { return store.project.permission[projectID] @@ -1318,27 +1352,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ .then((location) => { const key = locationKey(location) setStore("location", key, { ...store.location[key], info: location }) - return client.api.session.list({ - project: location.project.id, - limit: 50, - order: "desc", - parentID: null, - }) }) - .then((response) => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of response.data) draft[session.id] = session - }), - ) - for (const session of response.data) { - sync.complete(`session:${session.id}`) - registerSession(session.id) - } - }) - .catch((error) => console.error("Failed to preload sessions", error)) + .catch((error) => console.error("Failed to preload location", error)) + void result.project.sync().catch((error) => console.error("Failed to preload projects", error)) return } handleEvent(details) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0f3ade05415..eb09929ad36 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -256,7 +256,7 @@ export function Session() { const sessionID = route.sessionID void (async () => { await Promise.all([ - data.session.sync(sessionID), + data.session.sync(sessionID, { children: true }), data.session.permission.sync(sessionID), data.session.form.sync(sessionID), ]) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index c10e44ad2fe..6764b074540 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -615,14 +615,14 @@ export function DialogSelect(props: DialogSelectProps) { when={props.renderFilter !== false && store.filter.length > 0} fallback={ props.emptyView ?? ( - + No items available ) } > {props.noMatchView ?? ( - + No results found )} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 6f69efdb2e6..c814f576790 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" -import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" +import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -71,11 +71,13 @@ function durable(sessionID: string, seq = 0, version = 1) { return { aggregateID: sessionID, seq, version } } -test("preloads root sessions before applying the session limit", async () => { +test("does not preload session summaries into the data context", async () => { const events = createEventStream() - let request: URL | undefined + let location = false + let sessions = false const calls = createFetch((url) => { - if (url.pathname === "/api/session") request = url + if (url.pathname === "/api/location") location = true + if (url.pathname === "/api/session") sessions = true return undefined }, events) @@ -92,10 +94,58 @@ test("preloads root sessions before applying the session limit", async () => { )) try { - await wait(() => request !== undefined) - expect(request?.searchParams.get("project")).toBe("proj_test") - expect(request?.searchParams.get("limit")).toBe("50") - expect(request?.searchParams.get("parentID")).toBe("null") + await wait(() => location) + await Bun.sleep(20) + expect(sessions).toBe(false) + } finally { + app.renderer.destroy() + } +}) + +test("proactively syncs project metadata", async () => { + const events = createEventStream() + const calls = createFetch((url) => { + if (url.pathname !== "/api/project") return + return json([ + { + id: "proj_test", + canonical: worktree, + name: "OpenCode", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.project.get("proj_test") !== undefined) + expect(data.project.list()).toEqual([ + { + id: "proj_test", + canonical: worktree, + name: "OpenCode", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) } finally { app.renderer.destroy() } @@ -2619,8 +2669,18 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) { // the family-index tests below. async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { + if (url.pathname === "/api/session") { + const parentID = url.searchParams.get("parentID") + return json({ + data: Object.entries(parents) + .filter(([, parent]) => parent === parentID) + .map(([id, parent]) => sessionInfo(id, parent, costs[id])), + cursor: {}, + }) + } const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) + if (match && match[1] !== "active") + return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2647,6 +2707,20 @@ async function mountData(parents: Record, costs: Record { + const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" }) + try { + await data.session.sync("root", { children: true }) + expect(data.session.get("root")?.id).toBe("root") + expect(data.session.get("child")?.parentID).toBe("root") + expect(data.session.get("sibling")?.parentID).toBe("root") + expect(data.session.get("grandchild")).toBeUndefined() + expect(data.session.family("root")).toEqual(["root", "child", "sibling"]) + } finally { + app.renderer.destroy() + } +}) + test("groups an orphan child under its missing parent until the root arrives", async () => { const { data, app } = await mountData({ child: "root" }) try { diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 78984ee5bc9..ebf7720bef6 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -185,6 +185,19 @@ test("dialog actions run without options while row actions still require a selec } }) +test("renders one gap before an empty state", async () => { + await using tmp = await tmpdir() + const app = await renderSelect(tmp.path, [], () => {}, () => {}) + + try { + await app.waitForFrame((frame) => frame.includes("No items available")) + const lines = app.captureCharFrame().split("\n").map((line) => line.trim()) + expect(lines.indexOf("No items available") - lines.indexOf("Search")).toBe(2) + } finally { + app.renderer.destroy() + } +}) + test("footer actions run when filtering leaves no selected row", async () => { await using tmp = await tmpdir() let global = 0 diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 5cf10b8ca6f..eae2d142da4 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -93,24 +93,26 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { directory: "/tmp", target: async () => ({ sessionID: "ses_root", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: undefined, variant: undefined, @@ -130,7 +130,7 @@ describe("run interactive runtime", () => { await refreshCatalog?.() expect(defaultModel).toHaveBeenCalledTimes(1) selected.resolve({ - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, data: model, }) while (defaultModel.mock.calls.length < 2) await Bun.sleep(0) @@ -165,7 +165,7 @@ describe("run interactive runtime", () => { directory: "/tmp", target: async () => ({ sessionID: "ses_root", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: { providerID: "test", modelID: "model" }, variant: undefined, @@ -261,7 +261,7 @@ describe("run interactive runtime", () => { return { sessionID: "ses-deferred", sessionTitle: "Deferred", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: { providerID: "openai", modelID: "gpt-5" }, variant: undefined, @@ -349,7 +349,7 @@ describe("run interactive runtime", () => { target: async () => ({ sessionID: "ses-resume", sessionTitle: "Resume", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "review", model: { providerID: "openai", modelID: "gpt-5" }, variant: "high", @@ -432,7 +432,7 @@ describe("run interactive runtime", () => { target: async () => ({ sessionID: "ses-resume-abort", sessionTitle: "Cached title", - location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } }, + location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } }, agent: "build", model: undefined, variant: undefined, @@ -490,7 +490,7 @@ describe("run interactive runtime", () => { location: { directory: "/session", workspaceID: "work-1", - project: { id: "pro-1", directory: "/session" }, + project: { id: "pro-1", directory: "/session", canonical: "/session" }, }, data: [{ path: "src/index.ts", type: "file" }], } as never) @@ -507,7 +507,7 @@ describe("run interactive runtime", () => { location: { directory: "/session", workspaceID: "work-1", - project: { id: "location-project", directory: "/session" }, + project: { id: "location-project", directory: "/session", canonical: "/session" }, }, agent: "review", model: { providerID: "openai", modelID: "gpt-5" }, diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 2a1f828e28c..604384c9256 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -167,7 +167,11 @@ function sdk(input: { location: { directory: input.globalLocation?.directory ?? "/tmp", workspaceID: input.globalLocation?.workspaceID, - project: { id: "proj_1", directory: input.globalLocation?.directory ?? "/tmp" }, + project: { + id: "proj_1", + directory: input.globalLocation?.directory ?? "/tmp", + canonical: input.globalLocation?.directory ?? "/tmp", + }, }, data: input.globals ?? [], }),