From 96ddad1579a44fb68317983f35d928aecd889600 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Sun, 1 Feb 2026 19:07:52 +0800 Subject: [PATCH 01/42] fix: prevent Windows reserved device names from being added to directory checks and simplify bash description parameter --- packages/opencode/src/tool/bash.ts | 61 +++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 67559b78c085..b6411fe33943 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,3 +1,29 @@ +/** + * 文件用途:Bash工具实现,提供命令执行、权限管理、输出截断等功能 + * 作者:TRAE + * 创建日期:2026-02-01 + * + * 输入输出签名: + * - 输入:command(命令字符串)、timeout(超时毫秒)、workdir(工作目录)、description(命令描述) + * - 输出:{ title, metadata: { output, exit, description }, output } + * + * 依赖列表: + * - zod@latest + * - web-tree-sitter@latest + * - tree-sitter-bash@latest + * - bun@latest + * + * 与其他模块交互方式: + * - 调用Instance.directory获取项目根目录 + * - 调用ctx.ask()请求权限(external_directory、bash) + * - 调用ctx.metadata()更新元数据 + * - 调用ctx.abort监听中止事件 + * - 调用Shell.killTree()终止进程树 + * + * 其他备注: + * - 相关文件:bash.txt(工具描述模板) + * - 支持Windows、Linux、macOS多平台 + */ import z from "zod" import { spawn } from "child_process" import { Tool } from "./tool" @@ -23,6 +49,35 @@ const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 export const log = Log.create({ service: "bash-tool" }) +const WINDOWS_RESERVED_DEVICE_NAMES = new Set([ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]) + +/** + * 实现说明:Windows保留设备名称检查 + * + * Windows系统保留以下设备名称,不能作为文件名使用: + * - CON, PRN, AUX, NUL(标准设备) + * - COM1-9(串行端口) + * - LPT1-9(并行端口) + * + * 检查逻辑: + * 1. 提取路径的基本名称(去除目录部分) + * 2. 转换为大写(Windows文件系统不区分大小写) + * 3. 检查是否在保留名称集合中 + * + * 注意事项: + * - 此检查应在调用realpath之前进行,避免无效命令 + * - 只检查基本名称,不检查路径中间的保留名称 + * - 适用于Windows平台(process.platform === 'win32') + */ +const isWindowsReservedDeviceName = (name: string): boolean => { + const baseName = path.basename(name).toUpperCase() + return WINDOWS_RESERVED_DEVICE_NAMES.has(baseName) +} + const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset @@ -72,7 +127,7 @@ export const BashTool = Tool.define("bash", async () => { description: z .string() .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + "Clear, concise description of what this command does in 5-10 words", ), }), async execute(params, ctx) { @@ -116,6 +171,10 @@ export const BashTool = Tool.define("bash", async () => { if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue + if (process.platform === "win32" && isWindowsReservedDeviceName(arg)) { + log.info("skipping Windows reserved device name", { arg }) + continue + } const resolved = await $`realpath ${arg}` .cwd(cwd) .quiet() From 20cb1f5ca32ab2fbce1cbf679003fe1c784ad570 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 09:12:02 +0800 Subject: [PATCH 02/42] =?UTF-8?q?fix:=20=E4=B8=BAmethod()=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E6=B7=BB=E5=8A=A0=E8=B6=85=E6=97=B6=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8DWindows=20CLI=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=8C=82=E8=B5=B7=E9=97=AE=E9=A2=98=EF=BC=88Issue=20#11657?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用Promise.race()包装check.command()调用 - 添加5秒超时保护,防止包管理器检测命令挂起 - 捕获超时错误并继续尝试下一个检测 - 添加中文注释说明修复内容 --- packages/opencode/src/installation/index.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d18c9e31a13b..bdf93b768b77 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -102,11 +102,22 @@ export namespace Installation { }) for (const check of checks) { - const output = await check.command() - const installedName = - check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" - if (output.includes(installedName)) { - return check.name + try { + // 添加超时保护,防止包管理器检测命令挂起导致CLI启动失败 + const output = await Promise.race([ + check.command(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), 5000) // 5秒超时 + ) + ]) + const installedName = + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" + if (output.includes(installedName)) { + return check.name + } + } catch (e) { + // 忽略超时或其他错误,继续尝试下一个包管理器检测 + continue } } From 4666b81fc87925b82a1b2112c546c259a77bfe76 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 09:17:58 +0800 Subject: [PATCH 03/42] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DWindows=20CLI?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=8C=82=E8=B5=B7=E9=97=AE=E9=A2=98=EF=BC=88?= =?UTF-8?q?Issue=20#11657=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用项目已有的withTimeout工具函数替代手动实现,避免内存泄漏 - 为包管理器检测添加5秒超时保护 - 添加调试日志(debug/info/warn)便于问题排查 - 改进错误信息,包含包管理器名称 - 将超时时间定义为常量TIMEOUT_MS,并添加TODO注释说明未来需要移到配置文件 修复内容: - packages/opencode/src/installation/index.ts: method()函数 测试:TypeCheck通过(12个任务全部成功) --- packages/opencode/src/installation/index.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index bdf93b768b77..9c55046f90ea 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -6,6 +6,7 @@ import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" import { iife } from "@/util/iife" import { Flag } from "../flag/flag" +import { withTimeout } from "@/util/timeout" declare global { const OPENCODE_VERSION: string @@ -15,6 +16,9 @@ declare global { export namespace Installation { const log = Log.create({ service: "installation" }) + // TODO: Move to config file + const TIMEOUT_MS = 5000 + export type Method = Awaited> export const Event = { @@ -103,20 +107,19 @@ export namespace Installation { for (const check of checks) { try { - // 添加超时保护,防止包管理器检测命令挂起导致CLI启动失败 - const output = await Promise.race([ - check.command(), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), 5000) // 5秒超时 - ) - ]) + log.debug("Checking package manager", { name: check.name }) + const output = await withTimeout(check.command(), TIMEOUT_MS) const installedName = check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" if (output.includes(installedName)) { + log.info("Detected installation method", { method: check.name }) return check.name } } catch (e) { - // 忽略超时或其他错误,继续尝试下一个包管理器检测 + log.warn("Package manager check failed", { + name: check.name, + error: e instanceof Error ? e.message : String(e), + }) continue } } From 1e15520512a3fec591b8a5f1e6f2305a6a625aa6 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 11:15:29 +0800 Subject: [PATCH 04/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8DWindows=E4=B8=8A?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=E9=93=BE=E6=8E=A5=E9=87=8D=E5=A4=8D=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E9=A1=B9=E7=9B=AE=E9=97=AE=E9=A2=98=20(Issue=20#11666?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在server.tsx中添加normalizePathForComparison函数,统一处理Windows路径规范化 - 在layout.tsx中添加normalizePathForComparison函数,统一处理Windows路径规范化 - 修改workspaceKey函数使用规范化路径 - 修改parseDeepLink函数返回规范化路径 - 修改项目去重逻辑使用规范化路径比较 - 修改所有路径比较的地方使用规范化路径,包括: - currentProject - closeProject - SortableProject.selected - handleDragOver - handleWorkspaceDragOver - SortableWorkspace.local和active - ProjectDragOverlay - SortableProject.active - label函数 - workspaceIds函数 修复内容: - 统一斜杠为正斜杠 - 统一为小写(Windows) - 移除末尾斜杠 这确保了在Windows上,同一物理路径的不同表示形式(大小写、斜杠类型、末尾斜杠)不会被当作不同项目。 --- packages/app/src/context/server.tsx | 50 ++++++++++-- packages/app/src/pages/layout.tsx | 116 +++++++++++++++++++++++----- 2 files changed, 143 insertions(+), 23 deletions(-) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index c307f6e72abd..aeb091d6ee20 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -7,6 +7,30 @@ import { Persist, persisted } from "@/utils/persist" type StoredProject = { worktree: string; expanded: boolean } +/** + * 规范化路径用于比较,避免在Windows上重复创建项目 + * 处理大小写敏感性、斜杠类型和末尾斜杠 + * + * 问题: Windows路径可能以不同形式表示同一物理路径: + * - C:\Users\Project vs c:\users\project (大小写不同) + * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) + * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) + * + * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 + */ +function normalizePathForComparison(path: string): string { + let normalized = path + // 统一斜杠为正斜杠 + normalized = normalized.replace(/\\/g, '/') + // 移除末尾斜杠 + normalized = normalized.replace(/\/$/, '') + // 在Windows上,统一为小写以进行不区分大小写的比较 + if (process.platform === 'win32') { + normalized = normalized.toLowerCase() + } + return normalized +} + export function normalizeServerUrl(input: string) { const trimmed = input.trim() if (!trimmed) return @@ -164,38 +188,54 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const key = origin() if (!key) return const current = store.projects[key] ?? [] - if (current.find((x) => x.worktree === directory)) return + // 使用规范化路径检查项目是否已存在,避免Windows上的重复项目 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) + const existing = current.find((x) => normalizePathForComparison(x.worktree) === normalizedDirectory) + if (existing) return setStore("projects", key, [{ worktree: directory, expanded: true }, ...current]) }, close(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) setStore( "projects", key, - current.filter((x) => x.worktree !== directory), + current.filter((x) => normalizePathForComparison(x.worktree) !== normalizedDirectory), ) }, expand(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] - const index = current.findIndex((x) => x.worktree === directory) + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) + const index = current.findIndex((x) => normalizePathForComparison(x.worktree) === normalizedDirectory) if (index !== -1) setStore("projects", key, index, "expanded", true) }, collapse(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] - const index = current.findIndex((x) => x.worktree === directory) + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) + const index = current.findIndex((x) => normalizePathForComparison(x.worktree) === normalizedDirectory) if (index !== -1) setStore("projects", key, index, "expanded", false) }, move(directory: string, toIndex: number) { const key = origin() if (!key) return const current = store.projects[key] ?? [] - const fromIndex = current.findIndex((x) => x.worktree === directory) + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) + const fromIndex = current.findIndex((x) => normalizePathForComparison(x.worktree) === normalizedDirectory) if (fromIndex === -1 || fromIndex === toIndex) return const result = [...current] const [item] = result.splice(fromIndex, 1) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 46c9c9154ffd..4ede2474b079 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -75,6 +75,30 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" +/** + * 规范化路径用于比较,避免在Windows上重复创建项目 + * 处理大小写敏感性、斜杠类型和末尾斜杠 + * + * 问题: Windows路径可能以不同形式表示同一物理路径: + * - C:\Users\Project vs c:\users\project (大小写不同) + * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) + * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) + * + * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 + */ +function normalizePathForComparison(path: string): string { + let normalized = path + // 统一斜杠为正斜杠 + normalized = normalized.replace(/\\/g, '/') + // 移除末尾斜杠 + normalized = normalized.replace(/\/$/, '') + // 在Windows上,统一为小写以进行不区分大小写的比较 + if (process.platform === 'win32') { + normalized = normalized.toLowerCase() + } + return normalized +} + export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), @@ -534,11 +558,14 @@ export default function Layout(props: ParentProps) { if (!directory) return const projects = layout.projects.list() + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) if (sandbox) return sandbox - const direct = projects.find((p) => p.worktree === directory) + const direct = projects.find((p) => normalizePathForComparison(p.worktree) === normalizedDirectory) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -549,7 +576,7 @@ export default function Layout(props: ParentProps) { const root = meta?.worktree if (!root) return - return projects.find((p) => p.worktree === root) + return projects.find((p) => normalizePathForComparison(p.worktree) === normalizePathForComparison(root)) }) createEffect( @@ -595,7 +622,12 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + /** + * 生成工作区唯一标识键 + * 使用规范化路径确保在Windows上同一物理路径不会产生重复键 + * 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + */ + const workspaceKey = (directory: string) => normalizePathForComparison(directory) const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) @@ -1263,13 +1295,19 @@ export default function Layout(props: ParentProps) { const deepLinkEvent = "opencode:deep-link" + /** + * 解析深度链接URL + * 返回规范化的项目目录路径,确保在Windows上不会重复创建项目 + * 修复Issue #11666: 使用normalizePathForComparison规范化路径 + */ const parseDeepLink = (input: string) => { if (!input.startsWith("opencode://")) return const url = new URL(input) if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") if (!directory) return - return directory + // 规范化路径以避免Windows上的重复项目 + return normalizePathForComparison(directory) } const handleDeepLinks = (urls: string[]) => { @@ -1332,7 +1370,10 @@ export default function Layout(props: ParentProps) { } function closeProject(directory: string) { - const index = layout.projects.list().findIndex((x) => x.worktree === directory) + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) + const index = layout.projects.list().findIndex((x) => normalizePathForComparison(x.worktree) === normalizedDirectory) const next = layout.projects.list()[index + 1] layout.projects.close(directory) if (next) navigateToProject(next.worktree) @@ -1697,8 +1738,12 @@ export default function Layout(props: ParentProps) { const { draggable, droppable } = event if (draggable && droppable) { const projects = layout.projects.list() - const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) - const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDraggable = normalizePathForComparison(draggable.id.toString()) + const normalizedDroppable = normalizePathForComparison(droppable.id.toString()) + const fromIndex = projects.findIndex((p) => normalizePathForComparison(p.worktree) === normalizedDraggable) + const toIndex = projects.findIndex((p) => normalizePathForComparison(p.worktree) === normalizedDroppable) if (fromIndex !== toIndex && toIndex !== -1) { layout.projects.move(draggable.id.toString(), toIndex) } @@ -1714,7 +1759,11 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined + // 使用规范化路径比较,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedActiveWorktree = active?.worktree ? normalizePathForComparison(active.worktree) : undefined + const normalizedProjectWorktree = normalizePathForComparison(project.worktree) + const directory = normalizedActiveWorktree === normalizedProjectWorktree ? decode64(params.dir) : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1750,8 +1799,12 @@ export default function Layout(props: ParentProps) { if (!project) return const ids = workspaceIds(project) - const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) - const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDraggable = normalizePathForComparison(draggable.id.toString()) + const normalizedDroppable = normalizePathForComparison(droppable.id.toString()) + const fromIndex = ids.findIndex((dir) => normalizePathForComparison(dir) === normalizedDraggable) + const toIndex = ids.findIndex((dir) => normalizePathForComparison(dir) === normalizedDroppable) if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return @@ -2090,7 +2143,12 @@ export default function Layout(props: ParentProps) { } const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) + const project = createMemo(() => { + // 使用规范化路径查找项目,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedActiveProject = store.activeProject ? normalizePathForComparison(store.activeProject) : undefined + return layout.projects.list().find((p) => normalizedActiveProject && normalizePathForComparison(p.worktree) === normalizedActiveProject) + }) return ( {(p) => ( @@ -2152,10 +2210,20 @@ export default function Layout(props: ParentProps) { } return map }) - const local = createMemo(() => props.directory === props.project.worktree) + const local = createMemo(() => { + // 使用规范化路径比较,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(props.directory) + const normalizedWorktree = normalizePathForComparison(props.project.worktree) + return normalizedDirectory === normalizedWorktree + }) const active = createMemo(() => { const current = decode64(params.dir) ?? "" - return current === props.directory + // 使用规范化路径比较,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedCurrent = normalizePathForComparison(current) + const normalizedDirectory = normalizePathForComparison(props.directory) + return normalizedCurrent === normalizedDirectory }) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch @@ -2367,7 +2435,11 @@ export default function Layout(props: ParentProps) { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { const current = decode64(params.dir) ?? "" - return props.project.worktree === current || props.project.sandboxes?.includes(current) + // 使用规范化路径比较,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedCurrent = normalizePathForComparison(current) + const normalizedWorktree = normalizePathForComparison(props.project.worktree) + return normalizedWorktree === normalizedCurrent || props.project.sandboxes?.includes(current) }) const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) @@ -2379,9 +2451,13 @@ export default function Layout(props: ParentProps) { const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo( - () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), - ) + const active = createMemo(() => { + // 使用规范化路径比较,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedHoverProject = state.hoverProject ? normalizePathForComparison(state.hoverProject) : undefined + const normalizedWorktree = normalizePathForComparison(props.project.worktree) + return (preview() ? open() : overlay() && normalizedHoverProject === normalizedWorktree) + }) createEffect(() => { if (preview()) return @@ -2391,8 +2467,12 @@ export default function Layout(props: ParentProps) { const label = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) + // 使用规范化路径比较,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const normalizedDirectory = normalizePathForComparison(directory) + const normalizedWorktree = normalizePathForComparison(props.project.worktree) const kind = - directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + normalizedDirectory === normalizedWorktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") const name = workspaceLabel(directory, data.vcs?.branch, props.project.id) return `${kind} : ${name}` } From f57531c8b6c41a98ca361f56e653762687ebfea4 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 11:30:48 +0800 Subject: [PATCH 05/42] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=A7=84=E8=8C=83=E5=8C=96=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E4=BB=A3=E7=A0=81=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建共享工具模块 packages/app/src/utils/path.ts - 将 normalizePathForComparison 函数移至共享模块 - 添加完整的 JSDoc 注释和输入验证 - 修复 parseDeepLink 返回原始路径而非规范化路径 - 统一 workspaceKey 的使用,仅使用规范化路径作为键 - 消除 server.tsx 和 layout.tsx 中的重复代码 - 修复类型安全问题,添加显式返回类型声明 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 --- packages/app/src/context/server.tsx | 25 +----------- packages/app/src/pages/layout.tsx | 47 +++++++--------------- packages/app/src/utils/path.ts | 61 +++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 57 deletions(-) create mode 100644 packages/app/src/utils/path.ts diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index aeb091d6ee20..99472cefd9c0 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -4,33 +4,10 @@ import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" +import { normalizePathForComparison } from "@/utils/path" type StoredProject = { worktree: string; expanded: boolean } -/** - * 规范化路径用于比较,避免在Windows上重复创建项目 - * 处理大小写敏感性、斜杠类型和末尾斜杠 - * - * 问题: Windows路径可能以不同形式表示同一物理路径: - * - C:\Users\Project vs c:\users\project (大小写不同) - * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) - * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) - * - * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 - */ -function normalizePathForComparison(path: string): string { - let normalized = path - // 统一斜杠为正斜杠 - normalized = normalized.replace(/\\/g, '/') - // 移除末尾斜杠 - normalized = normalized.replace(/\/$/, '') - // 在Windows上,统一为小写以进行不区分大小写的比较 - if (process.platform === 'win32') { - normalized = normalized.toLowerCase() - } - return normalized -} - export function normalizeServerUrl(input: string) { const trimmed = input.trim() if (!trimmed) return diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4ede2474b079..3fba467af2e0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -74,30 +74,7 @@ import { DialogEditProject } from "@/components/dialog-edit-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" - -/** - * 规范化路径用于比较,避免在Windows上重复创建项目 - * 处理大小写敏感性、斜杠类型和末尾斜杠 - * - * 问题: Windows路径可能以不同形式表示同一物理路径: - * - C:\Users\Project vs c:\users\project (大小写不同) - * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) - * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) - * - * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 - */ -function normalizePathForComparison(path: string): string { - let normalized = path - // 统一斜杠为正斜杠 - normalized = normalized.replace(/\\/g, '/') - // 移除末尾斜杠 - normalized = normalized.replace(/\/$/, '') - // 在Windows上,统一为小写以进行不区分大小写的比较 - if (process.platform === 'win32') { - normalized = normalized.toLowerCase() - } - return normalized -} +import { normalizePathForComparison } from "@/utils/path" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -631,7 +608,7 @@ export default function Layout(props: ParentProps) { const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) - const direct = store.workspaceName[key] ?? store.workspaceName[directory] + const direct = store.workspaceName[key] if (direct) return direct if (!projectId) return if (!branch) return @@ -1297,17 +1274,24 @@ export default function Layout(props: ParentProps) { /** * 解析深度链接URL - * 返回规范化的项目目录路径,确保在Windows上不会重复创建项目 - * 修复Issue #11666: 使用normalizePathForComparison规范化路径 + * 返回原始项目目录路径,保留原始格式 + * 在需要比较路径时再进行规范化 + * + * @param input - 深度链接URL字符串 + * @returns 原始目录路径,如果URL无效则返回undefined + * + * @example + * parseDeepLink('opencode://open-project?directory=C:\\Users\\Project') + * // 返回: 'C:\\Users\\Project' */ - const parseDeepLink = (input: string) => { + const parseDeepLink = (input: string): string | undefined => { if (!input.startsWith("opencode://")) return const url = new URL(input) if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") if (!directory) return - // 规范化路径以避免Windows上的重复项目 - return normalizePathForComparison(directory) + // 返回原始路径,保留原始格式 + return directory } const handleDeepLinks = (urls: string[]) => { @@ -2766,9 +2750,6 @@ export default function Layout(props: ParentProps) { setBusy(created.directory, true) WorktreeState.pending(created.directory) setStore("workspaceExpanded", key, true) - if (key !== created.directory) { - setStore("workspaceExpanded", created.directory, true) - } setStore("workspaceOrder", project.worktree, (prev) => { const existing = prev ?? [] const next = existing.filter((item) => { diff --git a/packages/app/src/utils/path.ts b/packages/app/src/utils/path.ts new file mode 100644 index 000000000000..8aa85f0b2dde --- /dev/null +++ b/packages/app/src/utils/path.ts @@ -0,0 +1,61 @@ +/** + * 文件用途: 路径工具模块 + * 作者: TRAE, 创建日期: 2026-02-02 + * + * 输入输出签名: + * - normalizePathForComparison(path: string): string - 规范化路径用于比较 + * + * 依赖列表: 无 + * + * 与其他模块交互方式: + * - 被 packages/app/src/context/server.tsx 导入使用 + * - 被 packages/app/src/pages/layout.tsx 导入使用 + * + * 其他备注: + * - 此模块提供路径规范化功能,用于解决Windows路径大小写不一致问题 + * - 与 packages/opencode/src/util/filesystem.ts 中的 normalizePath 不同,后者使用 realpathSync.native 获取文件系统真实路径 + */ + +/** + * 规范化路径用于比较,避免在Windows上重复创建项目 + * + * @param path - 需要规范化的路径 + * @returns 规范化后的路径,格式为:正斜杠、无末尾斜杠、Windows上为小写 + * @throws {Error} 如果路径为空或无效 + * + * @example + * // Windows + * normalizePathForComparison('C:\\Users\\Project') // 'c:/users/project' + * normalizePathForComparison('C:/Users/Project/') // 'c:/users/project' + * + * @example + * // macOS/Linux + * normalizePathForComparison('/Users/Project') // '/Users/Project' + * normalizePathForComparison('/Users/Project/') // '/Users/Project' + */ +export function normalizePathForComparison(path: string): string { + // 输入验证 + if (!path || typeof path !== 'string') { + throw new Error('Invalid path: path must be a non-empty string') + } + + let normalized = path.trim() + + // 检查空路径 + if (normalized.length === 0) { + throw new Error('Invalid path: path cannot be empty') + } + + // 统一斜杠为正斜杠 + normalized = normalized.replace(/\\/g, '/') + + // 移除末尾斜杠 + normalized = normalized.replace(/\/$/, '') + + // 在Windows上,统一为小写以进行不区分大小写的比较 + if (process.platform === 'win32') { + normalized = normalized.toLowerCase() + } + + return normalized +} From 28f36d79c925af8cc2568588cc0f92f4fcff0a2a Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 17:12:05 +0800 Subject: [PATCH 06/42] fix: remove deprecated copyToClipboardOSC52 call The copyToClipboardOSC52 method was removed from @opentui/core in recent updates. This commit removes the call to this deprecated method and falls back to using the native clipboard methods (clipboardy, osascript, wl-copy, xclip, etc.) --- packages/opencode/src/cli/cmd/tui/util/clipboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 4be6787346df..b98baa831995 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -153,7 +153,6 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - writeOsc52(text) await getCopyMethod()(text) } } From f0847ceb2179776d9f118f5412a45c3198d16ff3 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 11:15:29 +0800 Subject: [PATCH 07/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8DWindows=E4=B8=8A?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=E9=93=BE=E6=8E=A5=E9=87=8D=E5=A4=8D=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E9=A1=B9=E7=9B=AE=E9=97=AE=E9=A2=98=20(Issue=20#11666?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在server.tsx中添加normalizePathForComparison函数,统一处理Windows路径规范化 - 在layout.tsx中添加normalizePathForComparison函数,统一处理Windows路径规范化 - 修改workspaceKey函数使用规范化路径 - 修改parseDeepLink函数返回规范化路径 - 修改项目去重逻辑使用规范化路径比较 - 修改所有路径比较的地方使用规范化路径,包括: - currentProject - closeProject - SortableProject.selected - handleDragOver - handleWorkspaceDragOver - SortableWorkspace.local和active - ProjectDragOverlay - SortableProject.active - label函数 - workspaceIds函数 修复内容: - 统一斜杠为正斜杠 - 统一为小写(Windows) - 移除末尾斜杠 这确保了在Windows上,同一物理路径的不同表示形式(大小写、斜杠类型、末尾斜杠)不会被当作不同项目。 --- packages/app/src/context/layout.tsx | 9 +++++++-- packages/app/src/context/server.tsx | 24 ++++++++++++++++++++++++ packages/app/src/pages/layout.tsx | 28 ++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 28fe628a8038..08f12de72c74 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,6 +8,7 @@ import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" +import { normalizePathForComparison } from "@/utils/path" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] @@ -287,7 +288,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( for (const project of globalSync.data.project) { const sandboxes = project.sandboxes ?? [] for (const sandbox of sandboxes) { - map.set(sandbox, project.worktree) + // 使用规范化路径作为键,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + map.set(normalizePathForComparison(sandbox), project.worktree) } } return map @@ -304,7 +307,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const current = chain[chain.length - 1] if (!current) return directory - const next = map.get(current) + // 使用规范化路径查找,避免Windows上的路径不一致问题 + // 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 + const next = map.get(normalizePathForComparison(current)) if (!next) return current if (visited.has(next)) return directory diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 99472cefd9c0..73d9cbd34a2b 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -8,6 +8,30 @@ import { normalizePathForComparison } from "@/utils/path" type StoredProject = { worktree: string; expanded: boolean } +/** + * 规范化路径用于比较,避免在Windows上重复创建项目 + * 处理大小写敏感性、斜杠类型和末尾斜杠 + * + * 问题: Windows路径可能以不同形式表示同一物理路径: + * - C:\Users\Project vs c:\users\project (大小写不同) + * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) + * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) + * + * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 + */ +function normalizePathForComparison(path: string): string { + let normalized = path + // 统一斜杠为正斜杠 + normalized = normalized.replace(/\\/g, '/') + // 移除末尾斜杠 + normalized = normalized.replace(/\/$/, '') + // 在Windows上,统一为小写以进行不区分大小写的比较 + if (process.platform === 'win32') { + normalized = normalized.toLowerCase() + } + return normalized +} + export function normalizeServerUrl(input: string) { const trimmed = input.trim() if (!trimmed) return diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3fba467af2e0..c0a234d194fc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -76,6 +76,30 @@ import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { normalizePathForComparison } from "@/utils/path" +/** + * 规范化路径用于比较,避免在Windows上重复创建项目 + * 处理大小写敏感性、斜杠类型和末尾斜杠 + * + * 问题: Windows路径可能以不同形式表示同一物理路径: + * - C:\Users\Project vs c:\users\project (大小写不同) + * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) + * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) + * + * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 + */ +function normalizePathForComparison(path: string): string { + let normalized = path + // 统一斜杠为正斜杠 + normalized = normalized.replace(/\\/g, '/') + // 移除末尾斜杠 + normalized = normalized.replace(/\/$/, '') + // 在Windows上,统一为小写以进行不区分大小写的比较 + if (process.platform === 'win32') { + normalized = normalized.toLowerCase() + } + return normalized +} + export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), @@ -1276,10 +1300,10 @@ export default function Layout(props: ParentProps) { * 解析深度链接URL * 返回原始项目目录路径,保留原始格式 * 在需要比较路径时再进行规范化 - * + * * @param input - 深度链接URL字符串 * @returns 原始目录路径,如果URL无效则返回undefined - * + * * @example * parseDeepLink('opencode://open-project?directory=C:\\Users\\Project') * // 返回: 'C:\\Users\\Project' From dcd95349e4e7dfcef8a8481d2c366194d1f3280d Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Mon, 2 Feb 2026 11:30:48 +0800 Subject: [PATCH 08/42] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=A7=84=E8=8C=83=E5=8C=96=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=E4=BB=A3=E7=A0=81=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建共享工具模块 packages/app/src/utils/path.ts - 将 normalizePathForComparison 函数移至共享模块 - 添加完整的 JSDoc 注释和输入验证 - 修复 parseDeepLink 返回原始路径而非规范化路径 - 统一 workspaceKey 的使用,仅使用规范化路径作为键 - 消除 server.tsx 和 layout.tsx 中的重复代码 - 修复类型安全问题,添加显式返回类型声明 修复Issue #11666: Windows路径规范化不一致导致重复创建项目 --- packages/app/src/context/server.tsx | 24 ---------------------- packages/app/src/pages/layout.tsx | 31 +++++++---------------------- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 73d9cbd34a2b..99472cefd9c0 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -8,30 +8,6 @@ import { normalizePathForComparison } from "@/utils/path" type StoredProject = { worktree: string; expanded: boolean } -/** - * 规范化路径用于比较,避免在Windows上重复创建项目 - * 处理大小写敏感性、斜杠类型和末尾斜杠 - * - * 问题: Windows路径可能以不同形式表示同一物理路径: - * - C:\Users\Project vs c:\users\project (大小写不同) - * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) - * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) - * - * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 - */ -function normalizePathForComparison(path: string): string { - let normalized = path - // 统一斜杠为正斜杠 - normalized = normalized.replace(/\\/g, '/') - // 移除末尾斜杠 - normalized = normalized.replace(/\/$/, '') - // 在Windows上,统一为小写以进行不区分大小写的比较 - if (process.platform === 'win32') { - normalized = normalized.toLowerCase() - } - return normalized -} - export function normalizeServerUrl(input: string) { const trimmed = input.trim() if (!trimmed) return diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c0a234d194fc..a706e5745495 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -76,30 +76,6 @@ import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { normalizePathForComparison } from "@/utils/path" -/** - * 规范化路径用于比较,避免在Windows上重复创建项目 - * 处理大小写敏感性、斜杠类型和末尾斜杠 - * - * 问题: Windows路径可能以不同形式表示同一物理路径: - * - C:\Users\Project vs c:\users\project (大小写不同) - * - C:/Users/Project vs C:\Users\Project (斜杠类型不同) - * - C:\Users\Project\ vs C:\Users\Project (末尾斜杠不同) - * - * 解决方案: 统一斜杠为正斜杠,统一为小写(Windows),移除末尾斜杠 - */ -function normalizePathForComparison(path: string): string { - let normalized = path - // 统一斜杠为正斜杠 - normalized = normalized.replace(/\\/g, '/') - // 移除末尾斜杠 - normalized = normalized.replace(/\/$/, '') - // 在Windows上,统一为小写以进行不区分大小写的比较 - if (process.platform === 'win32') { - normalized = normalized.toLowerCase() - } - return normalized -} - export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), @@ -1300,10 +1276,17 @@ export default function Layout(props: ParentProps) { * 解析深度链接URL * 返回原始项目目录路径,保留原始格式 * 在需要比较路径时再进行规范化 +<<<<<<< HEAD * * @param input - 深度链接URL字符串 * @returns 原始目录路径,如果URL无效则返回undefined * +======= + * + * @param input - 深度链接URL字符串 + * @returns 原始目录路径,如果URL无效则返回undefined + * +>>>>>>> 065844dbe (优化: 重构路径规范化逻辑,消除代码重复) * @example * parseDeepLink('opencode://open-project?directory=C:\\Users\\Project') * // 返回: 'C:\\Users\\Project' From db803aac10b4b621fbebcf441c13ee5f39aa36a1 Mon Sep 17 00:00:00 2001 From: 01luyicheng Date: Wed, 4 Feb 2026 14:11:17 +0800 Subject: [PATCH 09/42] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4app.tsx=E4=B8=AD?= =?UTF-8?q?=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84autoFocus=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7442037604bd..713def2e5af4 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -169,7 +169,6 @@ export function tui(input: { gatherStats: false, exitOnCtrlC: false, useKittyKeyboard: {}, - autoFocus: false, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { From f64644d2d5b98f7e157145c82299b47b12e41f71 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Wed, 4 Feb 2026 13:34:18 +0200 Subject: [PATCH 10/42] fix(desktop): removed compression from rpm bundle to save 15m in CI (#12097) --- packages/desktop/src-tauri/tauri.conf.json | 7 +++++++ packages/desktop/src-tauri/tauri.prod.conf.json | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 5f76d510bcef..53c28d9c1cdc 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -42,6 +42,13 @@ "active": true, "targets": ["deb", "rpm", "dmg", "nsis", "app"], "externalBin": ["sidecars/opencode-cli"], + "linux": { + "rpm": { + "compression": { + "type": "none" + } + } + }, "macOS": { "entitlements": "./entitlements.plist" }, diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 7ce4c78420e8..0416c59cbb9f 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -21,6 +21,11 @@ "files": { "/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml" } + }, + "rpm": { + "compression": { + "type": "none" + } } } }, From f870d8aa2b436d684758585653932c8e194dd71f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 4 Feb 2026 11:35:05 +0000 Subject: [PATCH 11/42] chore: generate --- packages/desktop/src-tauri/tauri.conf.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 53c28d9c1cdc..b631e2876ec3 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -44,9 +44,9 @@ "externalBin": ["sidecars/opencode-cli"], "linux": { "rpm": { - "compression": { - "type": "none" - } + "compression": { + "type": "none" + } } }, "macOS": { From a63681a325b55e0b9b64a51486a1dbac994da89e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 06:22:55 -0600 Subject: [PATCH 12/42] fix(app): opened tabs follow created session --- packages/app/src/components/prompt-input.tsx | 5 ++- packages/app/src/context/layout.tsx | 19 ++++++++++ packages/app/src/pages/session.tsx | 38 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6b568e9160b9..b897e394aa18 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1220,7 +1220,10 @@ export const PromptInput: Component = (props) => { }) return undefined }) - if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + if (session) { + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } } if (!session) return diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 08f12de72c74..9e3b50d6cf03 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -36,6 +36,12 @@ type SessionView = { reviewOpen?: string[] } +type TabHandoff = { + dir: string + id: string + at: number +} + export type LocalProject = Partial & { worktree: string; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" @@ -116,6 +122,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, sessionTabs: {} as Record, sessionView: {} as Record, + handoff: { + tabs: undefined as TabHandoff | undefined, + }, }), ) @@ -416,6 +425,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return { ready, + handoff: { + tabs: createMemo(() => store.handoff?.tabs), + setTabs(dir: string, id: string) { + setStore("handoff", "tabs", { dir, id, at: Date.now() }) + }, + clearTabs() { + if (!store.handoff?.tabs) return + setStore("handoff", "tabs", undefined) + }, + }, projects: { list, open(directory: string) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e8c61ee98935..e31ab18b98ec 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -280,9 +280,47 @@ export default function Page() { .finally(() => setUi("responding", false)) } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const workspaceKey = createMemo(() => params.dir ?? "") + const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) + createEffect( + on( + () => params.id, + (id, prev) => { + if (!id) return + if (prev) return + + const pending = layout.handoff.tabs() + if (!pending) return + if (Date.now() - pending.at > 60_000) { + layout.handoff.clearTabs() + return + } + + if (pending.id !== id) return + layout.handoff.clearTabs() + if (pending.dir !== (params.dir ?? "")) return + + const from = workspaceTabs().tabs() + if (from.all.length === 0 && !from.active) return + + const current = tabs().tabs() + if (current.all.length > 0 || current.active) return + + const all = normalizeTabs(from.all) + const active = from.active ? normalizeTab(from.active) : undefined + tabs().setAll(all) + tabs().setActive(active && all.includes(active) ? active : all[0]) + + workspaceTabs().setAll([]) + workspaceTabs().setActive(undefined) + }, + { defer: true }, + ), + ) + if (import.meta.env.DEV) { createEffect( on( From a0850a148a5336e2fef074d2962c0fa0a82dc81f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 06:37:59 -0600 Subject: [PATCH 13/42] wip(app): session options --- packages/app/src/pages/session.tsx | 288 +++++++++++++++++++++++++++-- 1 file changed, 273 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e31ab18b98ec..644fa66b3b01 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -436,6 +439,218 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + async function archiveSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + + if (params.id !== sessionID) return + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return + } + navigate(`/${params.dir}/session`) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + async function deleteSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + + const byParent = new Map() + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + if (params.id !== sessionID) return true + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return true + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return true + } + navigate(`/${params.dir}/session`) + return true + } + + function DialogRenameSession(props: { sessionID: string }) { + const [data, setData] = createStore({ + title: sync.session.get(props.sessionID)?.title ?? "", + saving: false, + }) + + const submit = (event: Event) => { + event.preventDefault() + if (data.saving) return + + const title = data.title.trim() + if (!title) { + dialog.close() + return + } + + const current = sync.session.get(props.sessionID)?.title ?? "" + if (title === current) { + dialog.close() + return + } + + setData("saving", true) + void sdk.client.session + .update({ sessionID: props.sessionID, title }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === props.sessionID) + if (index !== -1) draft.session[index].title = title + }), + ) + dialog.close() + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + .finally(() => { + setData("saving", false) + }) + } + + return ( + +
+ setData("title", value)} + /> +
+ + +
+ +
+ ) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: title() })} + +
+
+ + +
+
+
+ ) + } + const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -1992,20 +2207,63 @@ export default function Page() { centered(), }} > -
- - { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - aria-label={language.t("common.goBack")} - /> - - -

{info()?.title}

+
+
+ + { + navigate(`/${params.dir}/session/${info()?.parentID}`) + }} + aria-label={language.t("common.goBack")} + /> + + +

{info()?.title}

+
+
+ + {(id) => ( +
+ + + + + + + dialog.show(() => )} + > + + {language.t("common.rename")} + + + void archiveSession(id())}> + + {language.t("common.archive")} + + + + dialog.show(() => )} + > + + {language.t("common.delete")} + + + + + +
+ )}
From 3349cf341bb616e8482a032158e48f2593043160 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:12:12 -0600 Subject: [PATCH 14/42] fix(app): move session options to the session page --- packages/app/src/pages/layout.tsx | 170 +++----------------------- packages/app/src/pages/session.tsx | 190 +++++++++++++++++------------ 2 files changed, 134 insertions(+), 226 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a706e5745495..47e9d9d2b637 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1009,69 +1009,6 @@ export default function Layout(props: ParentProps) { } } - async function deleteSession(session: Session) { - const [store, setStore] = globalSync.child(session.directory) - const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === session.id) - const nextSession = sessions[index + 1] ?? sessions[index - 1] - - const result = await globalSDK.client.session - .delete({ directory: session.directory, sessionID: session.id }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return - - setStore( - produce((draft) => { - const removed = new Set([session.id]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [session.id] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - if (session.id === params.id) { - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - } else { - navigate(`/${params.dir}/session`) - } - } - } - command.register(() => { const commands: CommandOption[] = [ { @@ -1345,15 +1282,6 @@ export default function Layout(props: ParentProps) { globalSync.project.meta(project.worktree, { name }) } - async function renameSession(session: Session, next: string) { - if (next === session.title) return - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - title: next, - }) - } - const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) if (current === next) return @@ -1507,33 +1435,6 @@ export default function Layout(props: ParentProps) { }) } - function DialogDeleteSession(props: { session: Session }) { - const handleDelete = async () => { - await deleteSession(props.session) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: props.session.title })} - -
-
- - -
-
-
- ) - } - function DialogDeleteWorkspace(props: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ @@ -1899,10 +1800,6 @@ export default function Layout(props: ParentProps) { const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const [menu, setMenu] = createStore({ - open: false, - pendingRename: false, - }) const hoverPrefetch = { current: undefined as ReturnType | undefined } const cancelHoverPrefetch = () => { @@ -1929,7 +1826,7 @@ export default function Layout(props: ParentProps) { const item = ( - props.session.title} - onSave={(next) => renameSession(props.session, next)} - class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation - /> + + {props.session.title} + {(summary) => (
@@ -2033,49 +1925,25 @@ export default function Layout(props: ParentProps) {
- setMenu("open", open)}> - - - - - { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - openEditor(`session:${props.session.id}`, props.session.title) - }} - > - { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - {language.t("common.rename")} - - archiveSession(props.session)}> - {language.t("common.archive")} - - - dialog.show(() => )}> - {language.t("common.delete")} - - - - + + { + event.preventDefault() + event.stopPropagation() + void archiveSession(props.session) + }} + /> +
) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 644fa66b3b01..2143cd34b60c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -25,7 +25,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { TextField } from "@opencode-ai/ui/text-field" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -440,6 +440,15 @@ export default function Page() { return sync.session.history.loading(id) }) + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data @@ -449,6 +458,60 @@ export default function Page() { return language.t("common.requestFailed") } + createEffect( + on( + () => params.id, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!params.id) return + setTitle({ editing: true, draft: info()?.title ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const sessionID = params.id + if (!sessionID) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (info()?.title ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + async function archiveSession(sessionID: string) { const session = sync.session.get(sessionID) if (!session) return @@ -555,74 +618,6 @@ export default function Page() { return true } - function DialogRenameSession(props: { sessionID: string }) { - const [data, setData] = createStore({ - title: sync.session.get(props.sessionID)?.title ?? "", - saving: false, - }) - - const submit = (event: Event) => { - event.preventDefault() - if (data.saving) return - - const title = data.title.trim() - if (!title) { - dialog.close() - return - } - - const current = sync.session.get(props.sessionID)?.title ?? "" - if (title === current) { - dialog.close() - return - } - - setData("saving", true) - void sdk.client.session - .update({ sessionID: props.sessionID, title }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === props.sessionID) - if (index !== -1) draft.session[index].title = title - }), - ) - dialog.close() - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - .finally(() => { - setData("saving", false) - }) - } - - return ( - -
- setData("title", value)} - /> -
- - -
- -
- ) - } - function DialogDeleteSession(props: { sessionID: string }) { const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) const handleDelete = async () => { @@ -2208,7 +2203,7 @@ export default function Page() { }} >
-
+
- -

{info()?.title}

+ + + {info()?.title} + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-16-medium text-text-strong grow-1 min-w-0" + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={() => closeTitleEditor()} + /> +
{(id) => (
- + setTitle("menuOpen", open)} + > - + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > dialog.show(() => )} + onSelect={() => { + setTitle({ pendingRename: true, menuOpen: false }) + }} > {language.t("common.rename")} From 5f69c581aa7838b257d5b1dfbc0cba2e9f479602 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:59:42 -0600 Subject: [PATCH 15/42] fix(app): file tree not staying in sync across projects/sessions --- packages/app/src/context/layout.tsx | 46 ++++++++ packages/app/src/pages/layout.tsx | 5 +- packages/app/src/pages/session.tsx | 159 ++++++++++++++++++---------- 3 files changed, 154 insertions(+), 56 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 9e3b50d6cf03..95c68d50fa9f 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -34,6 +34,8 @@ type SessionTabs = { type SessionView = { scroll: Record reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number } type TabHandoff = { @@ -129,6 +131,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() @@ -560,6 +563,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("mobileSidebar", "opened", (x) => !x) }, }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(meta.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, view(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 47e9d9d2b637..0d8f6cb78c6c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1908,7 +1908,10 @@ export default function Layout(props: ParentProps) { getLabel={messageLabel} onMessageSelect={(message) => { if (!isActive()) { - sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) navigate(`${props.slug}/session/${props.session.id}`) return } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2143cd34b60c..7ff4bebb4d96 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -76,10 +76,31 @@ import { same } from "@/utils/same" type DiffStyle = "unified" | "split" +type HandoffSession = { + prompt: string + files: Record +} + +const HANDOFF_MAX = 40 + const handoff = { - prompt: "", - terminals: [] as string[], - files: {} as Record, + session: new Map(), + terminal: new Map(), +} + +const touch = (map: Map, key: K, value: V) => { + map.delete(key) + map.set(key, value) + while (map.size > HANDOFF_MAX) { + const first = map.keys().next().value + if (first === undefined) return + map.delete(first) + } +} + +const setSessionHandoff = (key: string, patch: Partial) => { + const prev = handoff.session.get(key) ?? { prompt: "", files: {} } + touch(handoff.session, key, { ...prev, ...patch }) } interface SessionReviewTabProps { @@ -793,8 +814,10 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) + sdk.directory + const id = params.id + if (!id) return + sync.session.sync(id) }) createEffect(() => { @@ -862,10 +885,22 @@ export default function Page() { createEffect( on( - () => params.id, + sessionKey, () => { setStore("messageId", undefined) setStore("expanded", {}) + setUi("autoCreated", false) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => params.dir, + (dir) => { + if (!dir) return + setStore("newSessionWorktree", "main") }, { defer: true }, ), @@ -1373,12 +1408,15 @@ export default function Page() { activeDiff: undefined as string | undefined, }) - const reviewScroll = () => tree.reviewScroll - const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) - const pendingDiff = () => tree.pendingDiff - const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) - const activeDiff = () => tree.activeDiff - const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) + createEffect( + on( + sessionKey, + () => { + setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) + }, + { defer: true }, + ), + ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return @@ -1399,8 +1437,8 @@ export default function Page() { view={view} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} - onScrollRef={setReviewScroll} - focusedFile={activeDiff()} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -1450,7 +1488,7 @@ export default function Page() { } const reviewDiffTop = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return const id = reviewDiffId(path) @@ -1466,7 +1504,7 @@ export default function Page() { } const scrollToReviewDiff = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return false const top = reviewDiffTop(path) @@ -1480,24 +1518,23 @@ export default function Page() { const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) - setActiveDiff(path) - setPendingDiff(path) + setTree({ activeDiff: path, pendingDiff: path }) } createEffect(() => { - const pending = pendingDiff() + const pending = tree.pendingDiff if (!pending) return - if (!reviewScroll()) return + if (!tree.reviewScroll) return if (!diffsReady()) return const attempt = (count: number) => { - if (pendingDiff() !== pending) return + if (tree.pendingDiff !== pending) return if (count > 60) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } - const root = reviewScroll() + const root = tree.reviewScroll if (!root) { requestAnimationFrame(() => attempt(count + 1)) return @@ -1515,7 +1552,7 @@ export default function Page() { } if (Math.abs(root.scrollTop - top) <= 1) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } @@ -1558,13 +1595,17 @@ export default function Page() { void sync.session.diff(id) }) + let treeDir: string | undefined createEffect(() => { + const dir = sdk.directory if (!isDesktop()) return if (!layout.fileTree.opened()) return if (sync.status === "loading") return fileTreeTab() - void file.tree.list("") + const refresh = treeDir !== dir + treeDir = dir + void (refresh ? file.tree.refresh("") : file.tree.list("")) }) const autoScroll = createAutoScroll({ @@ -1599,6 +1640,18 @@ export default function Page() { let scrollSpyFrame: number | undefined let scrollSpyTarget: HTMLDivElement | undefined + createEffect( + on( + sessionKey, + () => { + if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + scrollSpyFrame = undefined + scrollSpyTarget = undefined + }, + { defer: true }, + ), + ) + const anchor = (id: string) => `message-${id}` const setScrollRef = (el: HTMLDivElement | undefined) => { @@ -1713,20 +1766,14 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - const raw = sessionStorage.getItem("opencode.pendingMessage") - if (!raw) return - const parts = raw.split("|") - const pendingSessionID = parts[0] - const messageID = parts[1] - if (!pendingSessionID || !messageID) return - if (pendingSessionID !== sessionID) return - - sessionStorage.removeItem("opencode.pendingMessage") - setUi("pendingMessage", messageID) - }) + createEffect( + on(sessionKey, (key) => { + if (!params.id) return + const messageID = layout.pendingMessage.consume(key) + if (!messageID) return + setUi("pendingMessage", messageID) + }), + ) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const root = scroller @@ -1940,7 +1987,7 @@ export default function Page() { createEffect(() => { if (!prompt.ready()) return - handoff.prompt = previewPrompt() + setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) createEffect(() => { @@ -1960,20 +2007,22 @@ export default function Page() { return language.t("terminal.title") } - handoff.terminals = terminal.all().map(label) + touch(handoff.terminal, params.dir!, terminal.all().map(label)) }) createEffect(() => { if (!file.ready()) return - handoff.files = Object.fromEntries( - tabs() - .all() - .flatMap((tab) => { - const path = file.pathFromTab(tab) - if (!path) return [] - return [[path, file.selectedLines(path) ?? null] as const] - }), - ) + setSessionHandoff(sessionKey(), { + files: Object.fromEntries( + tabs() + .all() + .flatMap((tab) => { + const path = file.pathFromTab(tab) + if (!path) return [] + return [[path, file.selectedLines(path) ?? null] as const] + }), + ), + }) }) onCleanup(() => { @@ -2049,7 +2098,7 @@ export default function Page() { diffs={diffs} view={view} diffStyle="unified" - focusedFile={activeDiff()} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -2483,7 +2532,7 @@ export default function Page() { when={prompt.ready()} fallback={
- {handoff.prompt || language.t("prompt.loading")} + {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
} > @@ -2734,7 +2783,7 @@ export default function Page() { const p = path() if (!p) return null if (file.ready()) return file.selectedLines(p) ?? null - return handoff.files[p] ?? null + return handoff.session.get(sessionKey())?.files[p] ?? null }) let wrap: HTMLDivElement | undefined @@ -3228,7 +3277,7 @@ export default function Page() { allowed={diffFiles()} kinds={kinds()} draggable={false} - active={activeDiff()} + active={tree.activeDiff} onFileClick={(node) => focusReviewDiff(node.path)} /> @@ -3288,7 +3337,7 @@ export default function Page() { fallback={
- + {(title) => (
{title} From 4b32b59c7e67f6bc134f8cbea3aa219a44d35f00 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 4 Feb 2026 10:31:21 -0500 Subject: [PATCH 16/42] ci: remove source-based AUR package from publish script Simplifies the release process by publishing only the binary package to AUR, eliminating the need to maintain separate source and binary build configurations. --- packages/opencode/script/publish.ts | 68 +---------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 3113a85003ca..fbc1c83ba6dc 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -95,73 +95,7 @@ if (!Script.preview) { "", ].join("\n") - // Source-based PKGBUILD for opencode - const sourcePkgbuild = [ - "# Maintainer: dax", - "# Maintainer: adam", - "", - "pkgname='opencode'", - `pkgver=${pkgver}`, - `_subver=${_subver}`, - "options=('!debug' '!strip')", - "pkgrel=1", - "pkgdesc='The AI coding agent built for the terminal.'", - "url='https://github.com/anomalyco/opencode'", - "arch=('aarch64' 'x86_64')", - "license=('MIT')", - "provides=('opencode')", - "conflicts=('opencode-bin')", - "depends=('ripgrep')", - "makedepends=('git' 'bun' 'go')", - "", - `source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`, - `sha256sums=('SKIP')`, - "", - "build() {", - ` cd "opencode-\${pkgver}"`, - ` bun install`, - " cd ./packages/opencode", - ` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`, - "}", - "", - "package() {", - ` cd "opencode-\${pkgver}/packages/opencode"`, - ' mkdir -p "${pkgdir}/usr/bin"', - ' target_arch="x64"', - ' case "$CARCH" in', - ' x86_64) target_arch="x64" ;;', - ' aarch64) target_arch="arm64" ;;', - ' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;', - " esac", - ' libc=""', - " if command -v ldd >/dev/null 2>&1; then", - " if ldd --version 2>&1 | grep -qi musl; then", - ' libc="-musl"', - " fi", - " fi", - ' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then', - ' libc="-musl"', - " fi", - ' base=""', - ' if [ "$target_arch" = "x64" ]; then', - " if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then", - ' base="-baseline"', - " fi", - " fi", - ' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"', - ' if [ ! -f "$bin" ]; then', - ' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2', - " return 1", - " fi", - ' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"', - "}", - "", - ].join("\n") - - for (const [pkg, pkgbuild] of [ - ["opencode-bin", binaryPkgbuild], - ["opencode", sourcePkgbuild], - ]) { + for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { for (let i = 0; i < 30; i++) { try { await $`rm -rf ./dist/aur-${pkg}` From 19510ca71c2d8fd8f4689cb959714ff3a13edacc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:00:55 -0600 Subject: [PATCH 17/42] fix(app): don't show scroll-to-bottom unecessarily --- packages/app/src/pages/session.tsx | 67 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7ff4bebb4d96..f74eadc87bec 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -279,6 +279,10 @@ export default function Page() { pendingMessage: undefined as string | undefined, scrollGesture: 0, autoCreated: false, + scroll: { + overflow: false, + bottom: true, + }, }) createEffect( @@ -795,6 +799,7 @@ export default function Page() { let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined + let content: HTMLDivElement | undefined const scrollGestureWindowMs = 250 @@ -1618,10 +1623,40 @@ export default function Page() { window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) } + let scrollStateFrame: number | undefined + let scrollStateTarget: HTMLDivElement | undefined + + const updateScrollState = (el: HTMLDivElement) => { + const max = el.scrollHeight - el.clientHeight + const overflow = max > 1 + const bottom = !overflow || el.scrollTop >= max - 2 + + if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return + setUi("scroll", { overflow, bottom }) + } + + const scheduleScrollState = (el: HTMLDivElement) => { + scrollStateTarget = el + if (scrollStateFrame !== undefined) return + + scrollStateFrame = requestAnimationFrame(() => { + scrollStateFrame = undefined + + const target = scrollStateTarget + scrollStateTarget = undefined + if (!target) return + + updateScrollState(target) + }) + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.forceScrollToBottom() clearMessageHash() + + const el = scroller + if (el) scheduleScrollState(el) } // When the user returns to the bottom, treat the active message as "latest". @@ -1657,8 +1692,17 @@ export default function Page() { const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) + if (el) scheduleScrollState(el) } + createResizeObserver( + () => content, + () => { + const el = scroller + if (el) scheduleScrollState(el) + }, + ) + const turnInit = 20 const turnBatch = 20 let turnHandle: number | undefined @@ -1759,6 +1803,8 @@ export default function Page() { el.scrollTo({ top: el.scrollHeight, behavior: "auto" }) }) } + + if (el) scheduleScrollState(el) }, ) @@ -1839,6 +1885,9 @@ export default function Page() { const hash = window.location.hash.slice(1) if (!hash) { autoScroll.forceScrollToBottom() + + const el = scroller + if (el) scheduleScrollState(el) return } @@ -1864,6 +1913,9 @@ export default function Page() { } autoScroll.forceScrollToBottom() + + const el = scroller + if (el) scheduleScrollState(el) } const closestMessage = (node: Element | null): HTMLElement | null => { @@ -2029,6 +2081,7 @@ export default function Page() { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) }) return ( @@ -2133,8 +2186,9 @@ export default function Page() {