From 70c794e9139af1c1a2c43979d8f5e499a1587189 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:12:17 -0600 Subject: [PATCH] fix(app): regressions --- packages/app/src/components/file-tree.tsx | 11 +-- .../prompt-input/build-request-parts.test.ts | 4 +- .../prompt-input/build-request-parts.ts | 23 ++--- packages/app/src/context/file/path.test.ts | 16 +-- packages/app/src/context/file/path.ts | 7 +- packages/app/src/pages/home.tsx | 3 +- packages/app/src/pages/layout.tsx | 2 +- packages/app/src/pages/layout/helpers.ts | 2 +- .../src/pages/layout/sidebar-workspace.tsx | 4 +- packages/app/src/pages/session.tsx | 2 +- packages/app/src/pages/session/scroll-spy.ts | 1 + .../app/src/pages/session/terminal-panel.tsx | 2 +- .../pages/session/use-session-commands.tsx | 97 +++++++++++++------ 13 files changed, 101 insertions(+), 73 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 4a3e27672..d7b729973 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,4 +1,5 @@ import { useFile } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" @@ -20,11 +21,7 @@ import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" function pathToFileUrl(filepath: string): string { - const encodedPath = filepath - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/") - return `file://${encodedPath}` + return `file://${encodeFilePath(filepath)}` } type Kind = "add" | "del" | "mix" @@ -223,12 +220,14 @@ export default function FileTree(props: { seen.add(item) } - return out.toSorted((a, b) => { + out.sort((a, b) => { if (a.type !== b.type) { return a.type === "directory" ? -1 : 1 } return a.name.localeCompare(b.name) }) + + return out }) const Node = ( diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index b0fd3a050..72bdecc01 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -112,7 +112,7 @@ describe("buildRequestParts", () => { // Special chars should be encoded expect(filePart.url).toContain("file%23name.txt") // Should have Windows drive letter properly encoded - expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/) + expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/) } }) @@ -210,7 +210,7 @@ describe("buildRequestParts", () => { if (filePart?.type === "file") { // Should handle absolute path that differs from sessionDirectory expect(() => new URL(filePart.url)).not.toThrow() - expect(filePart.url).toContain("/D%3A/other/project/file.ts") + expect(filePart.url).toContain("/D:/other/project/file.ts") } }) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 11aec9631..0cc54dc2b 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,6 +1,7 @@ import { getFilename } from "@opencode-ai/util/path" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" import type { FileSelection } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import { Identifier } from "@/utils/id" @@ -27,23 +28,11 @@ type BuildRequestPartsInput = { sessionDirectory: string } -const absolute = (directory: string, path: string) => - path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") - -const encodeFilePath = (filepath: string): string => { - // Normalize Windows paths: convert backslashes to forward slashes - let normalized = filepath.replace(/\\/g, "/") - - // Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs) - if (/^[A-Za-z]:/.test(normalized)) { - normalized = "/" + normalized - } - - // Encode each path segment (preserving forward slashes as path separators) - return normalized - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/") +const absolute = (directory: string, path: string) => { + if (path.startsWith("/")) return path + if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path + if (path.startsWith("\\\\") || path.startsWith("//")) return path + return `${directory.replace(/[\\/]+$/, "")}/${path}` } const fileQuery = (selection: FileSelection | undefined) => diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 95247c08b..f2a3c44b6 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -108,7 +108,7 @@ describe("encodeFilePath", () => { const url = new URL(fileUrl) expect(url.protocol).toBe("file:") expect(url.pathname).toContain("README.bs.md") - expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md") + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") }) test("should handle mixed separator path (Windows + Unix)", () => { @@ -118,7 +118,7 @@ describe("encodeFilePath", () => { const fileUrl = `file://${result}` expect(() => new URL(fileUrl)).not.toThrow() - expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md") + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") }) test("should handle Windows path with spaces", () => { @@ -146,7 +146,7 @@ describe("encodeFilePath", () => { const fileUrl = `file://${result}` expect(() => new URL(fileUrl)).not.toThrow() - expect(result).toBe("/C%3A/") + expect(result).toBe("/C:/") }) test("should handle Windows relative path with backslashes", () => { @@ -177,7 +177,7 @@ describe("encodeFilePath", () => { const fileUrl = `file://${result}` expect(() => new URL(fileUrl)).not.toThrow() - expect(result).toBe("/c%3A/users/test/file.txt") + expect(result).toBe("/c:/users/test/file.txt") }) }) @@ -193,7 +193,7 @@ describe("encodeFilePath", () => { const result = encodeFilePath(windowsPath) // Should convert to forward slashes and add leading / expect(result).not.toContain("\\") - expect(result).toMatch(/^\/[A-Za-z]%3A\//) + expect(result).toMatch(/^\/[A-Za-z]:\//) }) test("should handle relative paths the same on all platforms", () => { @@ -237,7 +237,7 @@ describe("encodeFilePath", () => { const result = encodeFilePath(alreadyNormalized) // Should not add another leading slash - expect(result).toBe("/D%3A/path/file.txt") + expect(result).toBe("/D:/path/file.txt") expect(result).not.toContain("//D") }) @@ -246,7 +246,7 @@ describe("encodeFilePath", () => { const result = encodeFilePath(justDrive) const fileUrl = `file://${result}` - expect(result).toBe("/D%3A") + expect(result).toBe("/D:") expect(() => new URL(fileUrl)).not.toThrow() }) @@ -256,7 +256,7 @@ describe("encodeFilePath", () => { const fileUrl = `file://${result}` expect(() => new URL(fileUrl)).not.toThrow() - expect(result).toBe("/C%3A/Users/test/") + expect(result).toBe("/C:/Users/test/") }) test("should handle very long paths", () => { diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index e1d47c644..859fdc040 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -90,9 +90,14 @@ export function encodeFilePath(filepath: string): string { } // Encode each path segment (preserving forward slashes as path separators) + // Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers + // can reliably detect drives. return normalized .split("/") - .map((segment) => encodeURIComponent(segment)) + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment + return encodeURIComponent(segment) + }) .join("/") } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 10f7dac53..6b61ed300 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -25,7 +25,8 @@ export default function Home() { const homedir = createMemo(() => sync.data.path.home) const recent = createMemo(() => { return sync.data.project - .toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice() + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .slice(0, 5) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 59adef469..06a9e6fe1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1938,7 +1938,7 @@ export default function Layout(props: ParentProps) { direction="horizontal" size={layout.sidebar.width()} min={244} - max={window.innerWidth * 0.3 + 64} + max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} collapseThreshold={244} onResize={layout.sidebar.resize} onCollapse={layout.sidebar.close} diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 6ecccb95c..6a1e7c012 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -26,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) export const childMapByParent = (sessions: Session[]) => { const map = new Map() diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index a7a33f25e..13c1e55ef 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -118,7 +118,7 @@ export const SortableWorkspace = (props: { const touch = createMediaQuery("(hover: none)") const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) const loadMore = async () => { - setWorkspaceStore("limit", (limit) => limit + 5) + setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.directory) } @@ -368,7 +368,7 @@ export const LocalWorkspace = (props: { const loading = createMemo(() => !booted() && sessions().length === 0) const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) const loadMore = async () => { - workspace().setStore("limit", (limit) => limit + 5) + workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 24d46f828..73f67c740 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1683,7 +1683,7 @@ export default function Page() { direction="horizontal" size={layout.session.width()} min={450} - max={window.innerWidth * 0.45} + max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45} onResize={layout.session.resize} /> diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts index 8c52d77dc..6ef4c844c 100644 --- a/packages/app/src/pages/session/scroll-spy.ts +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => { node.delete(key) visible.delete(key) dirty = true + schedule() } const markDirty = () => { diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 09095d689..2e65fde0e 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -41,7 +41,7 @@ export function TerminalPanel(props: { direction="vertical" size={props.height} min={100} - max={window.innerHeight * 0.6} + max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6} collapseThreshold={50} onResize={props.resize} onCollapse={props.close} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 09c0fd17c..d52022d73 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -365,48 +365,81 @@ export const useSessionCommands = (input: { return [ { id: "session.share", - title: input.info()?.share?.url ? "Copy share link" : input.language.t("command.session.share"), + title: input.info()?.share?.url + ? input.language.t("session.share.copy.copyLink") + : input.language.t("command.session.share"), description: input.info()?.share?.url - ? "Copy share URL to clipboard" + ? input.language.t("toast.session.share.success.description") : input.language.t("command.session.share.description"), category: input.language.t("command.category.session"), slash: "share", disabled: !input.params.id, onSelect: async () => { if (!input.params.id) return - const copy = (url: string, existing: boolean) => - navigator.clipboard - .writeText(url) - .then(() => - showToast({ - title: existing - ? input.language.t("session.share.copy.copied") - : input.language.t("toast.session.share.success.title"), - description: input.language.t("toast.session.share.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: input.language.t("toast.session.share.copyFailed.title"), - variant: "error", - }), - ) - const url = input.info()?.share?.url - if (url) { - await copy(url, true) + + const write = (value: string) => { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return Promise.resolve(true) + } + + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } + + const copy = async (url: string, existing: boolean) => { + const ok = await write(url) + if (!ok) { + showToast({ + title: input.language.t("toast.session.share.copyFailed.title"), + variant: "error", + }) + return + } + + showToast({ + title: existing + ? input.language.t("session.share.copy.copied") + : input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), + variant: "success", + }) + } + + const existing = input.info()?.share?.url + if (existing) { + await copy(existing, true) return } - await input.sdk.client.session + + const url = await input.sdk.client.session .share({ sessionID: input.params.id }) - .then((res) => copy(res.data!.share!.url, false)) - .catch(() => - showToast({ - title: input.language.t("toast.session.share.failed.title"), - description: input.language.t("toast.session.share.failed.description"), - variant: "error", - }), - ) + .then((res) => res.data?.share?.url) + .catch(() => undefined) + if (!url) { + showToast({ + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), + variant: "error", + }) + return + } + + await copy(url, false) }, }, {