diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index 20bffc41a3dc..de01ac6408a4 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -1,10 +1,98 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import type { JSX } from "solid-js" +import type { FileNode } from "@opencode-ai/sdk/v2" +let FileTree: typeof import("./file-tree").default let shouldListRoot: typeof import("./file-tree").shouldListRoot let shouldListExpanded: typeof import("./file-tree").shouldListExpanded let dirsToExpand: typeof import("./file-tree").dirsToExpand +let parentDirectoryPath: typeof import("./file-tree").parentDirectoryPath +let fileTreeOpenLocationPath: typeof import("./file-tree").fileTreeOpenLocationPath +let openFileTreeLocation: typeof import("./file-tree").openFileTreeLocation + +const openPath = mock((_path: string) => Promise.resolve()) +const contextMenuItems: { onSelect?: () => void }[] = [] +const contextMenuLabels: string[] = [] +let contextMenuRoots = 0 +let contextMenuTriggers = 0 +let fileTreeNodes: FileNode[] = [] +let platformOpenPath: ((path: string) => Promise) | undefined = openPath +let platformName = "desktop" +let localServer = true + +type ElementType = string | ((props: Record) => unknown) + +const createElement = (type: ElementType, props: Record | null, ...children: unknown[]) => { + const propsWithChildren: Record = { ...(props ?? {}) } + if (children.length === 1) propsWithChildren.children = children[0] + if (children.length > 1) propsWithChildren.children = children + if (typeof type === "function") return type(propsWithChildren) + return propsWithChildren.children +} + +const clearContextMenuCaptures = () => { + contextMenuItems.length = 0 + contextMenuLabels.length = 0 + contextMenuRoots = 0 + contextMenuTriggers = 0 +} + +const resetEnvironment = () => { + openPath.mockClear() + clearContextMenuCaptures() + fileTreeNodes = [] + platformOpenPath = openPath + platformName = "desktop" + localServer = true + document.body.innerHTML = "" +} + +const fileNode = (absolute?: string) => + ({ + name: "index.ts", + path: "src/index.ts", + absolute, + type: "file", + ignored: false, + }) as FileNode + +const folderNode = (absolute?: string) => + ({ + name: "components", + path: "src/components", + absolute, + type: "directory", + ignored: false, + }) as FileNode + +const renderTree = (nodes: FileNode[]) => { + fileTreeNodes = nodes + FileTree({ path: "root", draggable: false }) + return () => undefined +} + +const expectNoContextMenuAction = (node: FileNode, configure?: () => void) => { + resetEnvironment() + configure?.() + const dispose = renderTree([node]) + + expect(contextMenuRoots).toBe(0) + expect(contextMenuTriggers).toBe(0) + expect(contextMenuItems).toHaveLength(0) + expect(contextMenuLabels).toEqual([]) + expect(openPath).not.toHaveBeenCalled() + + dispose() +} beforeAll(async () => { + Reflect.set(globalThis, "React", { createElement }) + mock.module("solid-js/web", () => ({ + Dynamic: (props: { component?: ElementType; children?: JSX.Element }) => { + if (typeof props.component === "function") return props.component(props) + return props.children + }, + })) mock.module("@solidjs/router", () => ({ useNavigate: () => () => undefined, useParams: () => ({}), @@ -13,28 +101,81 @@ beforeAll(async () => { })) mock.module("@/context/file", () => ({ useFile: () => ({ + normalize: (path: string) => path, tree: { - state: () => undefined, + state: () => ({ loaded: true, expanded: false }), list: () => Promise.resolve(), - children: () => [], + children: (path: string) => (path === "root" ? fileTreeNodes : []), expand: () => {}, collapse: () => {}, }, }), })) + mock.module("@/context/language", () => ({ + useLanguage: () => ({ + t: (key: string) => key, + }), + })) + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ + platform: platformName, + openPath: platformOpenPath, + }), + })) + mock.module("@/context/server", () => ({ + useServer: () => ({ + isLocal: () => localServer, + }), + })) mock.module("@opencode-ai/ui/collapsible", () => ({ - Collapsible: { + Collapsible: Object.assign((props: { children?: unknown }) => props.children, { Trigger: (props: { children?: unknown }) => props.children, Content: (props: { children?: unknown }) => props.children, - }, + }), })) + mock.module("@opencode-ai/ui/context-menu", () => { + const passthrough = (props: { children?: JSX.Element }) => props.children + return { + ContextMenu: Object.assign( + (props: { children?: JSX.Element }) => { + contextMenuRoots++ + return props.children + }, + { + Trigger: (props: { children?: JSX.Element }) => { + contextMenuTriggers++ + return props.children + }, + Portal: passthrough, + Content: passthrough, + Item: (props: { children?: JSX.Element; onSelect?: () => void }) => { + contextMenuItems.push({ onSelect: props.onSelect }) + return props.children + }, + ItemLabel: (props: { children?: JSX.Element }) => { + contextMenuLabels.push(String(props.children)) + return props.children + }, + }, + ), + } + }) mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null })) mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null })) + mock.module("@opencode-ai/ui/toast", () => ({ showToast: () => undefined })) mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) const mod = await import("./file-tree") + FileTree = mod.default shouldListRoot = mod.shouldListRoot shouldListExpanded = mod.shouldListExpanded dirsToExpand = mod.dirsToExpand + parentDirectoryPath = mod.parentDirectoryPath + fileTreeOpenLocationPath = mod.fileTreeOpenLocationPath + openFileTreeLocation = mod.openFileTreeLocation +}) + +beforeEach(() => { + resetEnvironment() }) describe("file tree fetch discipline", () => { @@ -78,3 +219,98 @@ describe("file tree fetch discipline", () => { expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([]) }) }) + +describe("file tree open location path", () => { + test("resolves a file path to its parent directory", () => { + expect(parentDirectoryPath("E:\\works\\opencode\\src\\index.ts")).toBe("E:\\works\\opencode\\src") + }) + + test("resolves a folder path to its parent directory", () => { + expect(parentDirectoryPath("E:\\works\\opencode\\src\\components")).toBe("E:\\works\\opencode\\src") + }) + + test("does not resolve missing, empty, or root-only paths", () => { + expect(parentDirectoryPath()).toBeUndefined() + expect(parentDirectoryPath("")).toBeUndefined() + expect(parentDirectoryPath("/")).toBeUndefined() + expect(parentDirectoryPath("E:\\")).toBeUndefined() + expect(fileTreeOpenLocationPath({} as Pick)).toBeUndefined() + }) +}) + +describe("file tree open location action", () => { + test("opens the resolved parent directory for file and folder rows", () => { + openFileTreeLocation({ + path: fileTreeOpenLocationPath({ absolute: "E:\\works\\opencode\\src\\index.ts" }), + openPath, + onError: () => undefined, + }) + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + + openPath.mockClear() + openFileTreeLocation({ + path: fileTreeOpenLocationPath({ absolute: "E:\\works\\opencode\\src\\components" }), + openPath, + onError: () => undefined, + }) + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + }) + + test("skips openPath when the node has no parent target", () => { + openFileTreeLocation({ + path: fileTreeOpenLocationPath({} as Pick), + openPath, + onError: () => undefined, + }) + + expect(openPath).not.toHaveBeenCalled() + }) +}) + +describe("file tree row context menu", () => { + test("renders an i18n open-folder action for file rows and opens the parent directory", () => { + const dispose = renderTree([fileNode("E:\\works\\opencode\\src\\index.ts")]) + + expect(contextMenuRoots).toBeGreaterThan(0) + expect(contextMenuTriggers).toBeGreaterThan(0) + expect(contextMenuLabels).toContain("session.files.openFolder") + + contextMenuItems[0]?.onSelect?.() + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + + dispose() + }) + + test("renders an i18n open-folder action for folder rows and opens the parent directory", () => { + const dispose = renderTree([folderNode("E:\\works\\opencode\\src\\components")]) + + expect(contextMenuRoots).toBeGreaterThan(0) + expect(contextMenuTriggers).toBeGreaterThan(0) + expect(contextMenuLabels).toContain("session.files.openFolder") + + contextMenuItems[0]?.onSelect?.() + + expect(openPath).toHaveBeenLastCalledWith("E:\\works\\opencode\\src") + expect(openPath).not.toHaveBeenCalledWith("E:\\works\\opencode\\src\\components") + + dispose() + }) + + test("hides the custom action when the row path or opener gate is unavailable", () => { + expectNoContextMenuAction(fileNode()) + expectNoContextMenuAction(fileNode("")) + expectNoContextMenuAction(folderNode("E:\\")) + expectNoContextMenuAction(fileNode("E:\\works\\opencode\\src\\index.ts"), () => { + platformOpenPath = undefined + }) + expectNoContextMenuAction(folderNode("E:\\works\\opencode\\src\\components"), () => { + localServer = false + }) + expectNoContextMenuAction(fileNode("E:\\works\\opencode\\src\\index.ts"), () => { + platformName = "web" + }) + }) +}) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 211ce05ef065..29e7c21b41e9 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,8 +1,13 @@ import { useFile } from "@/context/file" import { encodeFilePath } from "@/context/file/path" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { Collapsible } from "@opencode-ai/ui/collapsible" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" import { createEffect, createMemo, @@ -14,6 +19,7 @@ import { Switch, untrack, type ComponentProps, + type JSX, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -85,6 +91,39 @@ const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: return kind } +export const parentDirectoryPath = (path?: string) => { + if (!path) return + + const trimmed = path.replace(/[\\/]+$/, "") + if (!trimmed) return + if (/^[A-Za-z]:$/.test(trimmed)) return + + const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + if (index === -1) return + if (index === 0) return trimmed.slice(0, 1) + if (index === 2 && /^[A-Za-z]:/.test(trimmed)) return trimmed.slice(0, 3) + return trimmed.slice(0, index) +} + +export const fileTreeOpenLocationPath = (node: Pick) => parentDirectoryPath(node.absolute) + +export const openFileTreeLocation = (input: { + path?: string + openPath?: (path: string) => Promise + onError: (err: unknown) => void +}) => { + if (!input.path || !input.openPath) return + input.openPath(input.path).catch(input.onError) +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + const buildDragImage = (target: HTMLElement) => { const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") const text = target.querySelector("span") @@ -209,8 +248,12 @@ export default function FileTree(props: { _chain?: readonly string[] }) { const file = useFile() + const language = useLanguage() + const platform = usePlatform() + const server = useServer() const level = props.level ?? 0 const draggable = () => props.draggable ?? true + const canOpenLocation = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const key = (p: string) => file @@ -302,6 +345,33 @@ export default function FileTree(props: { return out }) + const openLocation = (path: string) => { + openFileTreeLocation({ + path, + openPath: canOpenLocation() ? platform.openPath : undefined, + onError: (err) => showRequestError(language, err), + }) + } + + const withNodeContextMenu = (node: FileNode, trigger: JSX.Element) => ( + + {(path) => ( + + + {trigger} + + + + openLocation(path())}> + {language.t("session.files.openFolder")} + + + + + )} + + ) + createEffect(() => { const current = filter() const dirs = dirsToExpand({ @@ -403,21 +473,24 @@ export default function FileTree(props: { open={expanded()} onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} > - - -
- -
-
-
+ {withNodeContextMenu( + node, + + +
+ +
+
+
, + )}
- props.onFileClick?.(node)} - > -
- - - - - - - - - + {withNodeContextMenu( + node, + props.onFileClick?.(node)} + > +
+ + + + - - - - + + + + + + + + + , + )} ) diff --git a/packages/app/src/components/session/session-header.test.tsx b/packages/app/src/components/session/session-header.test.tsx new file mode 100644 index 000000000000..e28635a54246 --- /dev/null +++ b/packages/app/src/components/session/session-header.test.tsx @@ -0,0 +1,118 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type OpenSessionHeaderDirectory = typeof import("./session-header").openSessionHeaderDirectory +type SessionHeaderOpenVisible = typeof import("./session-header").sessionHeaderOpenVisible + +let openSessionHeaderDirectory: OpenSessionHeaderDirectory +let sessionHeaderOpenVisible: SessionHeaderOpenVisible + +const projectDirectory = "E:\\works\\opencode" +const openPath = mock((_path: string, _app?: string) => Promise.resolve()) +const onError = mock(() => undefined) +const opening: Array = [] + +beforeAll(async () => { + const noop = () => null + mock.module("@/context/command", () => ({ useCommand: noop })) + mock.module("@/context/language", () => ({ useLanguage: noop })) + mock.module("@/context/layout", () => ({ useLayout: noop })) + mock.module("@/context/platform", () => ({ usePlatform: noop })) + mock.module("@/context/server", () => ({ useServer: noop })) + mock.module("@/context/settings", () => ({ useSettings: noop })) + mock.module("@/context/sync", () => ({ useSync: noop })) + mock.module("@/context/terminal", () => ({ useTerminal: noop })) + mock.module("@/pages/session/helpers", () => ({ focusTerminalById: noop })) + mock.module("@/pages/session/session-layout", () => ({ useSessionLayout: noop })) + mock.module("@opencode-ai/ui/app-icon", () => ({ AppIcon: noop })) + mock.module("@opencode-ai/ui/button", () => ({ Button: noop })) + mock.module("@opencode-ai/ui/dropdown-menu", () => ({ DropdownMenu: Object.assign(noop, {}) })) + mock.module("@opencode-ai/ui/icon", () => ({ Icon: noop })) + mock.module("@opencode-ai/ui/icon-button", () => ({ IconButton: noop })) + mock.module("@opencode-ai/ui/keybind", () => ({ Keybind: noop })) + mock.module("@opencode-ai/ui/spinner", () => ({ Spinner: noop })) + mock.module("@opencode-ai/ui/toast", () => ({ Toast: Object.assign(noop, { Region: noop }), showToast: noop })) + mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: noop, TooltipKeybind: noop })) + mock.module("@opencode-ai/ui/v2/components/icon-button-v2.jsx", () => ({ IconButtonV2: noop })) + mock.module("@opencode-ai/ui/v2/components/icon.jsx", () => ({ Icon: noop })) + mock.module("../status-popover", () => ({ StatusPopover: noop, StatusPopoverV2: noop })) + + const mod = await import("./session-header") + openSessionHeaderDirectory = mod.openSessionHeaderDirectory + sessionHeaderOpenVisible = mod.sessionHeaderOpenVisible +}) + +beforeEach(() => { + openPath.mockReset() + openPath.mockImplementation(() => Promise.resolve()) + onError.mockClear() + opening.length = 0 +}) + +describe("SessionHeader V2 open folder action", () => { + test("uses the same local desktop visibility gate as the classic header", () => { + expect(sessionHeaderOpenVisible({ canOpen: true, directory: projectDirectory })).toBe(true) + expect(sessionHeaderOpenVisible({ canOpen: false, directory: projectDirectory })).toBe(false) + expect(sessionHeaderOpenVisible({ canOpen: true, directory: "" })).toBe(false) + }) + + test("opens the project directory with the selected open-with target", async () => { + openSessionHeaderDirectory({ + opening: false, + canOpen: true, + openPath, + directory: projectDirectory, + app: "vscode", + options: [ + { id: "finder" }, + { id: "vscode", openWith: "code" }, + ], + setOpening: (app) => opening.push(app), + onError, + }) + + expect(openPath.mock.calls).toEqual([[projectDirectory, "code"]]) + await settle() + expect(opening).toEqual(["vscode", undefined]) + }) + + test("skips unavailable openPath instead of producing a dead action", () => { + openSessionHeaderDirectory({ + opening: false, + canOpen: true, + openPath: undefined, + directory: projectDirectory, + app: "finder", + options: [{ id: "finder" }], + setOpening: (app) => opening.push(app), + onError, + }) + + expect(openPath).not.toHaveBeenCalled() + expect(opening).toEqual([]) + }) + + test("reports rejected openPath through the header error callback", async () => { + const error = new Error("open failed") + openPath.mockImplementation(() => Promise.reject(error)) + + openSessionHeaderDirectory({ + opening: false, + canOpen: true, + openPath, + directory: projectDirectory, + app: "finder", + options: [{ id: "finder" }], + setOpening: (app) => opening.push(app), + onError, + }) + + await settle() + expect(onError).toHaveBeenCalledWith(error) + expect(opening).toEqual(["finder", undefined]) + }) +}) + +async function settle() { + await Promise.resolve() + await Promise.resolve() +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index db1990dfaa75..8245f2e8cedb 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,4 @@ -import { AppIcon } from "@opencode-ai/ui/app-icon" +import { AppIcon, type AppIconProps } from "@opencode-ai/ui/app-icon" import { Button } from "@opencode-ai/ui/button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" @@ -48,6 +48,34 @@ const OPEN_APPS = [ type OpenApp = (typeof OPEN_APPS)[number] type OS = "macos" | "windows" | "linux" | "unknown" +type SessionHeaderOpenOption = { id: OpenApp; openWith?: string } + +export function sessionHeaderOpenVisible(input: { canOpen: boolean; directory: string }) { + return input.canOpen && !!input.directory +} + +export function openSessionHeaderDirectory(input: { + opening: boolean + canOpen: boolean + openPath?: (path: string, app?: string) => Promise + directory: string + app: OpenApp + options: readonly SessionHeaderOpenOption[] + setOpening: (app: OpenApp | undefined) => void + onError: (err: unknown) => void +}) { + if (input.opening || !input.canOpen || !input.openPath) return + if (!input.directory) return + + input.setOpening(input.app) + input + .openPath(input.directory, input.options.find((item) => item.id === input.app)?.openWith) + .catch(input.onError) + .finally(() => { + input.setOpening(undefined) + }) +} + const MAC_APPS = [ { id: "vscode", @@ -220,7 +248,7 @@ export function SessionHeader() { app: undefined as OpenApp | undefined, }) - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && !!server.isLocal()) const current = createMemo( () => options().find((o) => o.id === prefs.app) ?? @@ -232,6 +260,12 @@ export function SessionHeader() { messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent), ) const v2ActionsState = createMemo(() => ({ + openVisible: sessionHeaderOpenVisible({ canOpen: canOpen(), directory: projectDirectory() }), + openLabel: language.t("session.header.open.ariaLabel", { app: current().label }), + openIcon: current().icon, + opening: opening(), + openTint: tint(), + onOpen: () => openDir(current().id), statusVisible: status(), statusLabel: language.t("status.popover.trigger"), reviewLabel: language.t("command.review.toggle"), @@ -246,19 +280,16 @@ export function SessionHeader() { } const openDir = (app: OpenApp) => { - if (opening() || !canOpen() || !platform.openPath) return - const directory = projectDirectory() - if (!directory) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - setOpenRequest("app", app) - platform - .openPath(directory, openWith) - .catch((err: unknown) => showRequestError(language, err)) - .finally(() => { - setOpenRequest("app", undefined) - }) + openSessionHeaderDirectory({ + opening: opening(), + canOpen: canOpen(), + openPath: platform.openPath, + directory: projectDirectory(), + app, + options: options(), + setOpening: (value) => setOpenRequest("app", value), + onError: (err) => showRequestError(language, err), + }) } const copyPath = () => { @@ -514,6 +545,12 @@ export function SessionHeader() { } type SessionHeaderV2ActionsState = { + openVisible: boolean + openLabel: string + openIcon: AppIconProps["id"] + opening: boolean + openTint?: string + onOpen: () => void statusVisible: boolean statusLabel: string reviewLabel: string @@ -525,6 +562,27 @@ type SessionHeaderV2ActionsState = { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { return (
+ + + + }> + + + + } + /> + + diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 9ca8f8bdad18..888c5960ebbc 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -117,7 +117,11 @@ export function applyDirectoryEvent(input: { } const next = input.store.session.slice() next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit, permission: input.store.permission }) + const trimmed = trimSessions(next, { + limit, + permission: input.store.permission, + question: input.store.question, + }) input.setStore("session", reconcile(trimmed, { key: "id" })) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) @@ -147,7 +151,11 @@ export function applyDirectoryEvent(input: { } const next = input.store.session.slice() next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit, permission: input.store.permission }) + const trimmed = trimSessions(next, { + limit, + permission: input.store.permission, + question: input.store.question, + }) input.setStore("session", reconcile(trimmed, { key: "id" })) cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) break diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts index be12c074b5dc..357fa1fe4a2e 100644 --- a/packages/app/src/context/global-sync/session-trim.test.ts +++ b/packages/app/src/context/global-sync/session-trim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { trimSessions } from "./session-trim" const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => @@ -24,7 +24,7 @@ describe("trimSessions", () => { session({ id: "e", created: now - 60_000, archived: now - 10 }), ] - const result = trimSessions(list, { limit: 2, permission: {}, now }) + const result = trimSessions(list, { limit: 2, permission: {}, question: {}, now }) expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"]) }) @@ -45,6 +45,7 @@ describe("trimSessions", () => { permission: { "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], }, + question: {}, now, }) @@ -56,4 +57,26 @@ describe("trimSessions", () => { "root-2", ]) }) + + test("keeps children with pending questions when their root is trimmed", () => { + const now = 1_000_000 + const list = [ + session({ id: "root-kept-1", created: now - 1000 }), + session({ id: "root-kept-2", created: now - 2000 }), + session({ id: "root-trimmed", created: now - 30_000_000 }), + session({ id: "child-kept-by-question", parentID: "root-trimmed", created: now - 20_000_000 }), + session({ id: "child-trimmed", parentID: "root-trimmed", created: now - 20_000_000 }), + ] + + const result = trimSessions(list, { + limit: 2, + permission: {}, + question: { + "child-kept-by-question": [{ id: "question-1" } as QuestionRequest], + }, + now, + }) + + expect(result.map((x) => x.id)).toEqual(["child-kept-by-question", "root-kept-1", "root-kept-2"]) + }) }) diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts index ba13cb5ec0ec..c3f93f1d0980 100644 --- a/packages/app/src/context/global-sync/session-trim.ts +++ b/packages/app/src/context/global-sync/session-trim.ts @@ -1,4 +1,4 @@ -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { cmp } from "./utils" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" @@ -32,7 +32,12 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n export function trimSessions( input: Session[], - options: { limit: number; permission: Record; now?: number }, + options: { + limit: number + permission: Record + question: Record + now?: number + }, ) { const limit = Math.max(0, options.limit) const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW @@ -51,6 +56,8 @@ export function trimSessions( if (s.parentID && keepRootIds.has(s.parentID)) return true const perms = options.permission[s.id] ?? [] if (perms.length > 0) return true + const questions = options.question[s.id] ?? [] + if (questions.length > 0) return true return sessionUpdatedAt(s) > cutoff }) return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index fc79fdaa8478..78d3b86ee3b8 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -263,6 +263,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const next = trimSessions(store.session, { limit: retainedLimit, permission: store.permission, + question: store.question, }) if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) @@ -292,6 +293,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission, + question: store.question, }) batch(() => { setStore( diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1ba0c3d230a0..cd88a7ab5d15 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -621,6 +621,7 @@ export const dict = { "session.files.all": "All files", "session.files.empty": "No files", "session.files.binaryContent": "Binary file (content cannot be displayed)", + "session.files.openFolder": "Open Folder", "session.messages.renderEarlier": "Render earlier messages", "session.messages.loadingEarlier": "Loading earlier messages...", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts index 8ad1beb56b0c..daac3a61f9ef 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -30,4 +30,10 @@ describe("i18n parity", () => { } } }) + + test("open folder label is present in English and Chinese locales", () => { + expect(en["session.files.openFolder"]).toBe("Open Folder") + expect(zh["session.files.openFolder"]).toBe("打开文件夹") + expect(zht["session.files.openFolder"]).toBe("開啟資料夾") + }) }) diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 541bce13886d..5d0972c66393 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -562,6 +562,7 @@ export const dict = { "session.header.open.ariaLabel": "在 {{app}} 中打开", "session.header.open.menu": "打开选项", "session.header.open.copyPath": "复制路径", + "session.files.openFolder": "打开文件夹", "status.popover.trigger": "状态", "status.popover.ariaLabel": "服务器配置", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 75fb452bc0a1..288246265a0c 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -550,6 +550,7 @@ export const dict = { "session.header.open.ariaLabel": "在 {{app}} 中開啟", "session.header.open.menu": "開啟選項", "session.header.open.copyPath": "複製路徑", + "session.files.openFolder": "開啟資料夾", "status.popover.trigger": "狀態", "status.popover.ariaLabel": "伺服器設定", diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index c27454f7e18a..8d680cdf43bf 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, mock, test } from "bun:test" import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" -import { todoState } from "./session-composer-state" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" +mock.module("@solidjs/router", () => ({ useParams: () => ({ id: "root" }) })) + +const { isStalePermissionResponseFailure, removePermissionRequest, todoState } = await import("./session-composer-state") + const session = (input: { id: string; parentID?: string }) => ({ id: input.id, @@ -126,3 +129,113 @@ describe("todoState", () => { expect(todoState({ count: 2, done: true, live: false })).toBe("clear") }) }) + +describe("permission response stale cleanup", () => { + test("treats matching PermissionNotFoundError as stale", () => { + expect( + isStalePermissionResponseFailure( + { _tag: "PermissionNotFoundError", requestID: "perm-1", message: "Permission request not found" }, + permission("perm-1", "session-1"), + ), + ).toBe(true) + }) + + test("ignores PermissionNotFoundError for another pending permission", () => { + expect( + isStalePermissionResponseFailure( + { _tag: "PermissionNotFoundError", requestID: "perm-2", message: "Permission request not found" }, + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("treats request-not-found messages as stale", () => { + expect(isStalePermissionResponseFailure(new Error("Permission request not found"), permission("perm-1", "session-1"))).toBe( + true, + ) + }) + + test("treats name-discriminated PermissionNotFoundError with data message as stale", () => { + expect( + isStalePermissionResponseFailure( + { name: "PermissionNotFoundError", data: { message: "Permission request not found" }, requestID: "perm-1" }, + permission("perm-1", "session-1"), + ), + ).toBe(true) + }) + + test("ignores name-discriminated PermissionNotFoundError for another pending permission", () => { + expect( + isStalePermissionResponseFailure( + { name: "PermissionNotFoundError", data: { message: "Permission request not found" }, requestID: "perm-2" }, + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("ignores request-not-found messages for another pending permission", () => { + expect( + isStalePermissionResponseFailure( + { requestID: "perm-2", message: "Permission request not found" }, + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("keeps typed session-not-found responses user-visible for direct permission replies", () => { + expect( + isStalePermissionResponseFailure( + { _tag: "NotFoundError", message: "Session not found" }, + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("keeps body-wrapped session-not-found responses user-visible for direct permission replies", () => { + expect( + isStalePermissionResponseFailure( + new Error("Request failed", { cause: { body: { name: "NotFoundError", message: "Session not found" } } }), + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("keeps name-discriminated session-not-found responses user-visible for direct permission replies", () => { + expect( + isStalePermissionResponseFailure( + { name: "NotFoundError", data: { message: "Session not found" }, requestID: "perm-1" }, + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("keeps body-message session-not-found responses user-visible for direct permission replies", () => { + expect( + isStalePermissionResponseFailure( + { name: "NotFoundError", message: "Session not found", requestID: "perm-1" }, + permission("perm-1", "session-1"), + ), + ).toBe(false) + }) + + test("keeps bare session-not-found errors user-visible", () => { + expect(isStalePermissionResponseFailure(new Error("Session not found"), permission("perm-1", "session-1"))).toBe( + false, + ) + }) + + test("keeps normal failures user-visible", () => { + expect(isStalePermissionResponseFailure(new Error("Internal server error"), permission("perm-1", "session-1"))).toBe( + false, + ) + }) + + test("removes only the stale permission request", () => { + expect( + removePermissionRequest( + [permission("perm-1", "session-1"), permission("perm-2", "session-1")], + permission("perm-1", "session-1"), + ).map((item) => item.id), + ).toEqual(["perm-2"]) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index edcc37ebd493..8c4448618e61 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -21,6 +21,50 @@ export const todoState = (input: { return "close" } +export function isStalePermissionResponseFailure(error: unknown, permission: PermissionRequest) { + const body = unwrapErrorBody(error) + const tag = discriminator(body) + const requestID = typeof body?.requestID === "string" ? body.requestID : undefined + const message = errorMessage(error, body) + if (tag === "PermissionNotFoundError") return isCurrentPermissionRequest(requestID, permission) + if (message.includes("Permission request not found")) return isCurrentPermissionRequest(requestID, permission) + return false +} + +function isCurrentPermissionRequest(requestID: string | undefined, permission: PermissionRequest) { + return !requestID || requestID === permission.id +} + +function discriminator(body: Record | undefined) { + if (typeof body?._tag === "string") return body._tag + if (typeof body?.name === "string") return body.name +} + +export function removePermissionRequest(list: PermissionRequest[] | undefined, permission: PermissionRequest) { + return (list ?? []).filter((item) => item.id !== permission.id) +} + +function unwrapErrorBody(error: unknown): Record | undefined { + if (!(error instanceof Error)) return objectValue(error) + if (!error.cause || typeof error.cause !== "object" || !("body" in error.cause)) return objectValue(error) + return objectValue((error.cause as Record).body) +} + +function objectValue(value: unknown) { + if (typeof value !== "object" || value === null) return + return value as Record +} + +function errorMessage(error: unknown, body = unwrapErrorBody(error)) { + const bodyMessage = typeof body?.message === "string" ? body.message : undefined + if (bodyMessage) return bodyMessage + const data = objectValue(body?.data) + const dataMessage = typeof data?.message === "string" ? data.message : undefined + if (dataMessage) return dataMessage + if (error instanceof Error) return error.message + return String(error) +} + const idle = { type: "idle" as const } export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { @@ -81,8 +125,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => sdk() .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { - const description = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description }) + if (isStalePermissionResponseFailure(err, perm)) { + sync.set("permission", perm.sessionID, (list) => removePermissionRequest(list, perm)) + return + } + + showToast({ title: language.t("common.requestFailed"), description: errorMessage(err) }) }) .finally(() => { setStore("responding", (id) => (id === perm.id ? undefined : id)) diff --git a/packages/app/src/pages/session/composer/session-question-dock.test.ts b/packages/app/src/pages/session/composer/session-question-dock.test.ts new file mode 100644 index 000000000000..042387baa1db --- /dev/null +++ b/packages/app/src/pages/session/composer/session-question-dock.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { isStaleQuestionResponseFailure, removeQuestionRequest } from "./session-question-dock" + +const question = (id: string, sessionID: string) => + ({ + id, + sessionID, + questions: [], + }) as QuestionRequest + +describe("question response stale cleanup", () => { + test("treats matching QuestionNotFoundError as stale", () => { + expect( + isStaleQuestionResponseFailure( + { _tag: "QuestionNotFoundError", requestID: "question-1", message: "Question request not found" }, + question("question-1", "session-1"), + ), + ).toBe(true) + }) + + test("ignores QuestionNotFoundError for another pending question", () => { + expect( + isStaleQuestionResponseFailure( + { _tag: "QuestionNotFoundError", requestID: "question-2", message: "Question request not found" }, + question("question-1", "session-1"), + ), + ).toBe(false) + }) + + test("treats request-not-found messages as stale", () => { + expect(isStaleQuestionResponseFailure(new Error("Question request not found"), question("question-1", "session-1"))).toBe( + true, + ) + }) + + test("treats name-discriminated QuestionNotFoundError with data message as stale", () => { + expect( + isStaleQuestionResponseFailure( + { name: "QuestionNotFoundError", data: { message: "Question request not found" }, requestID: "question-1" }, + question("question-1", "session-1"), + ), + ).toBe(true) + }) + + test("ignores request-not-found messages for another pending question", () => { + expect( + isStaleQuestionResponseFailure( + { requestID: "question-2", message: "Question request not found" }, + question("question-1", "session-1"), + ), + ).toBe(false) + }) + + test("keeps normal failures user-visible", () => { + expect(isStaleQuestionResponseFailure(new Error("Internal server error"), question("question-1", "session-1"))).toBe( + false, + ) + }) + + test("removes only the stale question request", () => { + expect( + removeQuestionRequest( + [question("question-1", "session-1"), question("question-2", "session-1")], + question("question-1", "session-1"), + ).map((item) => item.id), + ).toEqual(["question-2"]) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 5fa0c97fb3d7..5e6f27657e67 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -8,6 +8,7 @@ import { showToast } from "@/utils/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" @@ -15,6 +16,46 @@ import { ScopedKey } from "@/utils/server-scope" const cache = new Map() +export function isStaleQuestionResponseFailure(error: unknown, request: QuestionRequest) { + const body = unwrapErrorBody(error) + const tag = discriminator(body) + const requestID = typeof body?.requestID === "string" ? body.requestID : undefined + const message = errorMessage(error, body) + if (tag === "QuestionNotFoundError") return !requestID || requestID === request.id + if (message.includes("Question request not found")) return !requestID || requestID === request.id + return false +} + +function discriminator(body: Record | undefined) { + if (typeof body?._tag === "string") return body._tag + if (typeof body?.name === "string") return body.name +} + +export function removeQuestionRequest(list: QuestionRequest[] | undefined, request: QuestionRequest) { + return (list ?? []).filter((item) => item.id !== request.id) +} + +function unwrapErrorBody(error: unknown): Record | undefined { + if (!(error instanceof Error)) return objectValue(error) + if (!error.cause || typeof error.cause !== "object" || !("body" in error.cause)) return objectValue(error) + return objectValue((error.cause as Record).body) +} + +function objectValue(value: unknown) { + if (typeof value !== "object" || value === null) return + return value as Record +} + +function errorMessage(error: unknown, body = unwrapErrorBody(error)) { + const bodyMessage = typeof body?.message === "string" ? body.message : undefined + if (bodyMessage) return bodyMessage + const data = objectValue(body?.data) + const dataMessage = typeof data?.message === "string" ? data.message : undefined + if (dataMessage) return dataMessage + if (error instanceof Error) return error.message + return String(error) +} + function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) { return (