From d53497658a525b445cbb0803ccf6e9bddb51926e Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 8 Jun 2026 17:27:44 -0400 Subject: [PATCH 1/3] feat(tui): show project copy in session list --- packages/core/src/project.ts | 11 +++++-- packages/core/test/project.test.ts | 12 ++++---- packages/sdk/js/src/v2/gen/types.gen.ts | 5 +++- packages/sdk/openapi.json | 13 +++++++- .../tui/src/component/dialog-move-session.tsx | 2 +- .../tui/src/component/dialog-session-list.tsx | 30 ++++++------------- packages/tui/src/context/project.tsx | 9 ++++-- 7 files changed, 48 insertions(+), 34 deletions(-) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index aa1c3a616157..4b3977310a64 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -36,7 +36,12 @@ export const DirectoriesInput = Schema.Struct({ }).annotate({ identifier: "Project.DirectoriesInput" }) export type DirectoriesInput = typeof DirectoriesInput.Type -export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" }) +export const Directories = Schema.Array( + Schema.Struct({ + directory: AbsolutePath, + type: Schema.Literals(["main", "root", "git_worktree"]), + }), +).annotate({ identifier: "Project.Directories" }) export type Directories = typeof Directories.Type export interface Interface { @@ -73,13 +78,13 @@ export const layer = Layer.effect( const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { const rows = yield* db - .select({ directory: ProjectDirectoryTable.directory }) + .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type }) .from(ProjectDirectoryTable) .where(eq(ProjectDirectoryTable.project_id, input.projectID)) .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory)) .all() .pipe(Effect.orDie) - return rows.map((row) => AbsolutePath.make(row.directory)) + return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type })) }) const cached = Effect.fnUntraced(function* (dir: string) { diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 645558ffb73a..3608939d7b25 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -59,9 +59,11 @@ describe("Project directories schemas", () => { projectID: ProjectV2.ID.make("project"), }, ) - expect(Schema.decodeUnknownSync(ProjectV2.Directories)([AbsolutePath.make("/tmp/project")])).toEqual([ - AbsolutePath.make("/tmp/project"), - ]) + expect( + Schema.decodeUnknownSync(ProjectV2.Directories)([ + { directory: AbsolutePath.make("/tmp/project"), type: "main" }, + ]), + ).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }]) }), ) @@ -90,8 +92,8 @@ describe("Project directories schemas", () => { .pipe(Effect.orDie) expect(yield* project.directories({ projectID })).toEqual([ - AbsolutePath.make("/repo/z"), - AbsolutePath.make("/repo/a"), + { directory: AbsolutePath.make("/repo/z"), type: "root" }, + { directory: AbsolutePath.make("/repo/a"), type: "main" }, ]) }), ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9c57ccd15d29..537e3ca28cb6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3768,7 +3768,10 @@ export type ConfigV2ExperimentalPolicy = { resource: string } -export type ProjectDirectories = Array +export type ProjectDirectories = Array<{ + directory: string + type: "main" | "root" | "git_worktree" +}> export type ProjectCopyCopy = { directory: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2895c474369c..b77e0300f921 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -23962,7 +23962,18 @@ "ProjectDirectories": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["main", "root", "git_worktree"] + } + }, + "required": ["directory", "type"], + "additionalProperties": false } }, "ProjectCopyCopy": { diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 73f04959417e..f06b0b2d14ab 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -63,7 +63,7 @@ export function DialogMoveSession(props: { try { await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true }) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) - return directories.data ?? [] + return directories.data?.map((item) => item.directory) ?? [] } finally { setWorking(false) } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 9666d9903ce2..a7f630ee33cc 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -2,13 +2,13 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" import { useSync } from "../context/sync" -import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" +import { createMemo, createResource, createSignal, onMount } from "solid-js" +import path from "path" import { Locale } from "../util/locale" import { useProject } from "../context/project" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { useLocal } from "../context/local" -import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" @@ -16,7 +16,6 @@ import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } fr import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" -import { WorkspaceLabel } from "./workspace-label" import { useCommandShortcut } from "../keymap" export function DialogSessionList() { @@ -170,24 +169,13 @@ export function DialogSessionList() { function buildOption(id: string, category: string) { const x = sessionMap.get(id) if (!x) return undefined - const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - - let footer: JSX.Element | string = "" - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - if (x.workspaceID) { - footer = workspace ? ( - - ) : ( - - ) - } - } else { - footer = Locale.time(x.time.updated) - } + const directory = x.path + ? x.directory.endsWith(x.path) + ? x.directory.slice(0, -x.path.length).replace(/\/$/, "") + : undefined + : x.directory + const footer = + directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 30) : "" const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index ee25127053c7..0969a389a588 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -23,6 +23,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex project: { id: undefined as string | undefined, worktree: undefined as string | undefined, + mainDir: undefined as string | undefined, }, instance: { path: defaultPath, @@ -36,15 +37,19 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex async function sync() { const workspace = store.workspace.current - const [path, project] = await Promise.all([ + const [instancePath, project] = await Promise.all([ sdk.client.path.get({ workspace }), sdk.client.project.current({ workspace }), ]) + const directories = project.data?.id + ? await sdk.client.project.directories({ projectID: project.data.id, workspace }) + : undefined batch(() => { - setStore("instance", "path", reconcile(path.data || defaultPath)) + setStore("instance", "path", reconcile(instancePath.data || defaultPath)) setStore("project", "id", project.data?.id) setStore("project", "worktree", project.data?.worktree) + setStore("project", "mainDir", directories?.data?.find((item) => item.type === "main")?.directory) }) } From 43f197a1a0b3af1629ef6c8ff0f4d4cca3c34a93 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 8 Jun 2026 17:34:20 -0400 Subject: [PATCH 2/3] truncate more --- packages/tui/src/component/dialog-session-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index a7f630ee33cc..2965b3692e94 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -175,7 +175,7 @@ export function DialogSessionList() { : undefined : x.directory const footer = - directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 30) : "" + directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] From 48d84d4581d1c09a229681b6b6d093a68ca8677b Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 8 Jun 2026 18:13:18 -0400 Subject: [PATCH 3/3] test(core): update project directory endpoint assertions --- packages/opencode/test/server/project-copy.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index a2681d6f431d..61d9b4c0e86d 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -34,6 +34,8 @@ function json(response: HttpClientResponse.HttpClientResponse) { } describe("project directories and copies endpoints", () => { + type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" } + it.instance( "lists directories and manages git worktree copies", () => @@ -51,7 +53,7 @@ describe("project directories and copies endpoints", () => { const initial = yield* request(test.directory, `${base}/directories`) expect(initial.status).toBe(200) - expect(yield* json(initial)).toEqual([test.directory]) + expect(yield* json(initial)).toEqual([{ directory: test.directory, type: "main" }]) const create = yield* request(test.directory, copies, { method: "POST", @@ -63,7 +65,10 @@ describe("project directories and copies endpoints", () => { expect(created.directory).toBe(createdDirectory) const listed = yield* request(test.directory, `${base}/directories`) - expect(yield* json(listed)).toContain(created.directory) + expect(yield* json(listed)).toContainEqual({ + directory: created.directory, + type: "git_worktree", + }) yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty")) @@ -94,7 +99,10 @@ describe("project directories and copies endpoints", () => { }) expect(refresh.status).toBe(204) const refreshed = yield* request(test.directory, `${base}/directories`) - expect((yield* json(refreshed)).length).toBe(2) + expect(yield* json(refreshed)).toEqual([ + { directory: externalDirectory, type: "git_worktree" }, + { directory: test.directory, type: "main" }, + ]) }), { git: true }, )