@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
- onClick={() =>
- props.onFileClick?.({
- ...row().node,
- path: row().node.originalPath,
- absolute: row().node.originalPath,
- })
- }
+ onClick={() => selectFile(row().node, props.onFileClick)}
+ onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
- aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
- onClick={() =>
- file.tree.state(row().node.path)?.expanded === false
- ? file.tree.expand(row().node.path, { list: false })
- : file.tree.collapse(row().node.path)
- }
+ aria-expanded={expanded(row().node.path)}
+ onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
>
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index bbb71e8722..7db0583c51 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import {
ContentPart,
DEFAULT_PROMPT,
+ isCommentItem,
isPromptEqual,
Prompt,
usePrompt,
@@ -1626,7 +1627,7 @@ export const PromptInput: Component
= (props) => {
)}
/>
!isCommentItem(item))}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1636,6 +1637,7 @@ export const PromptInput: Component = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
+ newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters[0])}
/>
= (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
+ newLayoutDesigns={props.controls.newLayoutDesigns}
+ comments={contextItems().filter(isCommentItem)}
+ commentActive={(item) => {
+ const active = comments.active()
+ return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+ }}
+ onOpenComment={openComment}
+ onRemoveComment={(item) => {
+ if (item.commentID) comments.remove(item.path, item.commentID)
+ prompt.context.remove(item.key)
+ }}
/>
= (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
+ newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters
[0])}
/>
= (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
+ newLayoutDesigns={props.controls.newLayoutDesigns}
/>
)}
- {props.state.modelName}
+ {props.state.modelName}
diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx
index 6b154ee1e5..1f03f199ab 100644
--- a/packages/app/src/components/prompt-input/context-items.tsx
+++ b/packages/app/src/components/prompt-input/context-items.tsx
@@ -1,6 +1,8 @@
import { Component, For, Show } from "solid-js"
+import { Dynamic } from "solid-js/web"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void
+ newLayoutDesigns: boolean
t: (key: string) => string
}
@@ -27,10 +30,17 @@ export const PromptContextItems: Component = (props) => {
const selected = props.active(item)
return (
-
-
+
{directory}
{filename}
@@ -78,7 +88,7 @@ export const PromptContextItems: Component = (props) => {
{(comment) => {comment()}
}
-
+
)
}}
diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css
new file mode 100644
index 0000000000..2aaf494ebf
--- /dev/null
+++ b/packages/app/src/components/prompt-input/image-attachments.css
@@ -0,0 +1,43 @@
+@keyframes prompt-attachments-fade-left {
+ from {
+ visibility: hidden;
+ }
+ to {
+ visibility: visible;
+ }
+}
+
+@keyframes prompt-attachments-fade-right {
+ from {
+ visibility: visible;
+ }
+ to {
+ visibility: hidden;
+ }
+}
+
+[data-slot="prompt-attachments"] {
+ timeline-scope: --prompt-attachments-scroll;
+
+ [data-slot^="prompt-attachments-fade-"] {
+ visibility: hidden;
+ }
+}
+
+@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
+ [data-slot="prompt-attachments-scroll"] {
+ scroll-timeline: --prompt-attachments-scroll x;
+ }
+
+ [data-slot="prompt-attachments-fade-left"] {
+ animation: prompt-attachments-fade-left linear both;
+ animation-timeline: --prompt-attachments-scroll;
+ animation-range: 0 0.1px;
+ }
+
+ [data-slot="prompt-attachments-fade-right"] {
+ animation: prompt-attachments-fade-right linear both;
+ animation-timeline: --prompt-attachments-scroll;
+ animation-range: calc(100% - 1.1px) calc(100% - 1px);
+ }
+}
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
index dd8138e5a4..3a0cc3e4dd 100644
--- a/packages/app/src/components/prompt-input/image-attachments.tsx
+++ b/packages/app/src/components/prompt-input/image-attachments.tsx
@@ -1,60 +1,167 @@
import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import type { ImageAttachmentPart } from "@/context/prompt"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
+import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
+import { typeLabel } from "@opencode-ai/session-ui/message-file"
+import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
+import "./image-attachments.css"
+
+type PromptCommentItem = ContextItem & { key: string }
type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
+ newLayoutDesigns: boolean
+ comments?: PromptCommentItem[]
+ commentActive?: (item: PromptCommentItem) => boolean
+ onOpenComment?: (item: PromptCommentItem) => void
+ onRemoveComment?: (item: PromptCommentItem) => void
}
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
+// inset box-shadows do not paint over
content, so the hairline is a separate overlay
+const imageHairlineClassV2 =
+ "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+const removeClassV2 =
+ "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component = (props) => {
return (
- 0}>
-
-
- {(attachment) => (
-
-
+
0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
+
+
+
+
+ {(item) => (
+
+
+ props.onOpenComment?.(item)}
+ />
+
+
+
+ )}
+
+
+
+ {(attachment) => {
+ const image = attachment.mime.startsWith("image/")
+ const media = () => (
-
-
+
+
+
+ }
+ >
+
+ {typeLabel(attachment.filename, attachment.mime)}
+
+
}
>

props.onOpen(attachment)}
/>
-
+ )
+ const name = () => (
{attachment.filename}
-
-
- )}
-
+ )
+ const remove = () => (
+
+ )
+ // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
+ return (
+
+
+ {media()}
+ {name()}
+ {remove()}
+
+
+ }
+ >
+
+
+ {media()}
+
+
+
+
+ {remove()}
+
+
+ )
+ }}
+
+
+
+
+
+
)
diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx
index 27d8c97ca2..35ac4f9c6e 100644
--- a/packages/app/src/components/prompt-workspace-selector.tsx
+++ b/packages/app/src/components/prompt-workspace-selector.tsx
@@ -1,5 +1,6 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
{(branch) => (
<>
/
-
-
- {branch()}
-
+
+
+
+ {branch()}
+
+
>
)}
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
index 8e424f0f36..9e44cea17b 100644
--- a/packages/app/src/components/session/index.ts
+++ b/packages/app/src/components/session/index.ts
@@ -1,6 +1,7 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
+export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx
new file mode 100644
index 0000000000..e26ff2e0ea
--- /dev/null
+++ b/packages/app/src/components/session/open-in-app-v2.tsx
@@ -0,0 +1,98 @@
+import { For, Show } from "solid-js"
+import { AppIcon } from "@opencode-ai/ui/app-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useLanguage } from "@/context/language"
+import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
+
+export function OpenInAppV2(props: { directory: () => string }) {
+ const language = useLanguage()
+ const state = useOpenInApp(props)
+
+ return (
+
+ event.stopPropagation()}>
+
+ event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation()
+ if (state.opening()) return
+ state.openDir(state.current().id)
+ }}
+ disabled={state.opening()}
+ aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
+ >
+ }>
+
+
+
+
+
state.setMenu("open", open)}
+ >
+ event.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx
new file mode 100644
index 0000000000..cd363d00c4
--- /dev/null
+++ b/packages/app/src/components/session/open-in-app.tsx
@@ -0,0 +1,229 @@
+import { createEffect, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useServer } from "@/context/server"
+import { Persist, persisted } from "@/utils/persist"
+import { showToast } from "@/utils/toast"
+
+export const OPEN_APPS = [
+ "vscode",
+ "cursor",
+ "zed",
+ "textmate",
+ "antigravity",
+ "finder",
+ "terminal",
+ "iterm2",
+ "ghostty",
+ "warp",
+ "xcode",
+ "android-studio",
+ "powershell",
+ "sublime-text",
+] as const
+
+export type OpenApp = (typeof OPEN_APPS)[number]
+export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
+
+export const MAC_OPEN_APPS = [
+ {
+ id: "vscode",
+ label: "session.header.open.app.vscode",
+ icon: "vscode",
+ openWith: "Visual Studio Code",
+ },
+ { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
+ { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
+ { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
+ {
+ id: "antigravity",
+ label: "session.header.open.app.antigravity",
+ icon: "antigravity",
+ openWith: "Antigravity",
+ },
+ { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
+ { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
+ { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
+ { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
+ { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
+ {
+ id: "android-studio",
+ label: "session.header.open.app.androidStudio",
+ icon: "android-studio",
+ openWith: "Android Studio",
+ },
+ {
+ id: "sublime-text",
+ label: "session.header.open.app.sublimeText",
+ icon: "sublime-text",
+ openWith: "Sublime Text",
+ },
+] as const
+
+export const WINDOWS_OPEN_APPS = [
+ { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
+ {
+ id: "powershell",
+ label: "session.header.open.app.powershell",
+ icon: "powershell",
+ openWith: "powershell",
+ },
+ {
+ id: "sublime-text",
+ label: "session.header.open.app.sublimeText",
+ icon: "sublime-text",
+ openWith: "Sublime Text",
+ },
+] as const
+
+export const LINUX_OPEN_APPS = [
+ { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
+ {
+ id: "sublime-text",
+ label: "session.header.open.app.sublimeText",
+ icon: "sublime-text",
+ openWith: "Sublime Text",
+ },
+] as const
+
+export function detectOpenAppOS(platform: ReturnType
): OpenAppOS {
+ if (platform.platform === "desktop" && platform.os) return platform.os
+ if (typeof navigator !== "object") return "unknown"
+ const value = navigator.platform || navigator.userAgent
+ if (/Mac/i.test(value)) return "macos"
+ if (/Win/i.test(value)) return "windows"
+ if (/Linux/i.test(value)) return "linux"
+ return "unknown"
+}
+
+export function openAppFileManager(os: OpenAppOS) {
+ if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
+ if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
+ return { label: "session.header.open.fileManager", icon: "finder" as const }
+}
+
+export function openAppsForOS(os: OpenAppOS) {
+ if (os === "macos") return MAC_OPEN_APPS
+ if (os === "windows") return WINDOWS_OPEN_APPS
+ return LINUX_OPEN_APPS
+}
+
+const showRequestError = (language: ReturnType, err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+}
+
+export function useOpenInApp(input: { directory: () => string }) {
+ const platform = usePlatform()
+ const server = useServer()
+ const language = useLanguage()
+
+ const os = createMemo(() => detectOpenAppOS(platform))
+ const apps = createMemo(() => openAppsForOS(os()))
+ const fileManager = createMemo(() => openAppFileManager(os()))
+
+ const [exists, setExists] = createStore>>({
+ finder: true,
+ })
+
+ createEffect(() => {
+ if (platform.platform !== "desktop") return
+ if (!platform.checkAppExists) return
+
+ const list = apps()
+
+ setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>)
+
+ void Promise.all(
+ list.map((app) =>
+ Promise.resolve(platform.checkAppExists?.(app.openWith))
+ .then((value) => Boolean(value))
+ .catch(() => false)
+ .then((ok) => [app.id, ok] as const),
+ ),
+ ).then((entries) => {
+ setExists(Object.fromEntries(entries) as Partial>)
+ })
+ })
+
+ const options = createMemo(() => {
+ return [
+ { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
+ ...apps()
+ .filter((app) => exists[app.id])
+ .map((app) => ({ ...app, label: language.t(app.label) })),
+ ] as const
+ })
+
+ const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
+ const [menu, setMenu] = createStore({ open: false })
+ const [openRequest, setOpenRequest] = createStore({
+ app: undefined as OpenApp | undefined,
+ })
+
+ const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
+ const current = createMemo(
+ () =>
+ options().find((o) => o.id === prefs.app) ??
+ options()[0] ??
+ ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
+ )
+ const opening = createMemo(() => openRequest.app !== undefined)
+
+ const selectApp = (app: OpenApp | "finder") => {
+ if (!options().some((item) => item.id === app)) return
+ setPrefs("app", app)
+ }
+
+ const openDir = (app: OpenApp | "finder") => {
+ if (opening() || !canOpen() || !platform.openPath) return
+ const directory = input.directory()
+ if (!directory) return
+
+ const item = options().find((o) => o.id === app)
+ const openWith = item && "openWith" in item ? item.openWith : undefined
+ setOpenRequest("app", app)
+ platform
+ .openPath(directory, openWith)
+ .catch((err: unknown) => showRequestError(language, err))
+ .finally(() => {
+ setOpenRequest("app", undefined)
+ })
+ }
+
+ const copyPath = () => {
+ const directory = input.directory()
+ if (!directory) return
+ navigator.clipboard
+ .writeText(directory)
+ .then(() => {
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("session.share.copy.copied"),
+ description: directory,
+ })
+ })
+ .catch((err: unknown) => showRequestError(language, err))
+ }
+
+ return {
+ canOpen,
+ opening,
+ current,
+ options,
+ menu,
+ setMenu,
+ openDir,
+ selectApp,
+ copyPath,
+ }
+}
diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx
new file mode 100644
index 0000000000..158eba9f0b
--- /dev/null
+++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx
@@ -0,0 +1,66 @@
+import { createMemo, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { useSortable } from "@dnd-kit/solid/sortable"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useFile } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
+import { FileVisual } from "./session-sortable-tab"
+
+export function SortableTabV2(props: {
+ tab: string
+ index: () => number
+ temporary?: boolean
+ onTabClose: (tab: string) => void
+ onTabDoubleClick?: (tab: string) => void
+}): JSX.Element {
+ const file = useFile()
+ const language = useLanguage()
+ const command = useCommand()
+ const sortable = useSortable({
+ get id() {
+ return props.tab
+ },
+ get index() {
+ return props.index()
+ },
+ })
+ const path = createMemo(() => file.pathFromTab(props.tab))
+ const content = createMemo(() => {
+ const value = path()
+ if (!value) return
+ return
+ })
+ return (
+
+
+
+ props.onTabClose(props.tab)}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => props.onTabClose(props.tab)}
+ onDblClick={() => props.onTabDoubleClick?.(props.tab)}
+ >
+ {(value) => value()}
+
+
+
+ )
+}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 8a26d91c46..8e09539fbe 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
}
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
- if (key === "orange") return "orange"
- if (key === "pink") return "pink"
- if (key === "cyan") return "cyan"
- if (key === "purple") return "purple"
if (key === "mint") return "cyan"
if (key === "lime") return "green"
+ if (
+ key === "orange" ||
+ key === "yellow" ||
+ key === "cyan" ||
+ key === "green" ||
+ key === "red" ||
+ key === "pink" ||
+ key === "blue" ||
+ key === "purple" ||
+ key === "gray"
+ )
+ return key
return "gray"
}
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index e59bec9aaf..1a061a6675 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise
+ /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
+ revealPath?(path: string): Promise
+
/** Restart the app */
restart(): Promise
diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts
index b6a68be4e7..462cfa638b 100644
--- a/packages/app/src/context/prompt-state.ts
+++ b/packages/app/src/context/prompt-state.ts
@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
-function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 0ad8e70f3c..14147b6616 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -24,6 +24,7 @@ export {
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
+ isCommentItem,
isPromptEqual,
} from "./prompt-state"
export type {
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 343923af76..6b54e69822 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -638,7 +638,7 @@ export const dict = {
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
- "session.review.filesChanged": "{{count}} Files Changed",
+ "session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts
index a71d58ff07..74b9426654 100644
--- a/packages/app/src/pages/home-session-open.test.ts
+++ b/packages/app/src/pages/home-session-open.test.ts
@@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => {
+ test("opens middle clicks in the background", () => {
+ expect(
+ shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
+ ).toBe(true)
+ expect(
+ shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
+ ).toBe(false)
+ })
+
test("requires only the platform primary modifier", () => {
- expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
- expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
- expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
- expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
- expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
+ expect(
+ shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
+ ).toBe(true)
+ expect(
+ shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
+ ).toBe(true)
+ expect(
+ shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
+ ).toBe(false)
+ expect(
+ shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
+ ).toBe(false)
+ expect(
+ shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
+ ).toBe(false)
})
})
diff --git a/packages/app/src/pages/home-session-open.ts b/packages/app/src/pages/home-session-open.ts
index 8e32efb559..9f117935b7 100644
--- a/packages/app/src/pages/home-session-open.ts
+++ b/packages/app/src/pages/home-session-open.ts
@@ -1,10 +1,13 @@
export function shouldOpenSessionInBackground(input: {
+ button: number
mac: boolean
meta: boolean
ctrl: boolean
shift: boolean
alt: boolean
}) {
+ if (input.button === 1) return true
+ if (input.button !== 0) return false
if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index c8a89672df..d697e53ae7 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -240,10 +240,11 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
-// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
-// background without navigating, matching browser conventions.
+// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
+// tab in the background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
+ button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
@@ -465,8 +466,8 @@ export function NewHome() {
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
- void import("@/components/dialog-edit-project").then((x) => {
- dialog.show(() => )
+ void import("@/components/dialog-edit-project-v2").then((x) => {
+ void dialog.show(() => )
})
}
@@ -1386,7 +1387,15 @@ function HomeSessionSearchResultRow(props: {
group: !!showProjectName(),
}}
onMouseEnter={() => props.onHighlight()}
+ onMouseDown={(event) => {
+ if (event.button === 1) event.preventDefault()
+ }}
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
+ onAuxClick={(event) => {
+ if (!isBackgroundOpen(event)) return
+ event.preventDefault()
+ props.onSelect(props.record.session, { background: true })
+ }}
>
{
+ if (event.button === 1) event.preventDefault()
+ }}
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
+ onAuxClick={(event) => {
+ if (!isBackgroundOpen(event)) return
+ event.preventDefault()
+ props.openSession(props.record.session, { background: true })
+ }}
>
({ id: item.id, text: line(item.id) }))
})
- const actions = { revert }
+ // attachment bytes are embedded as a data URL, so downloading always works;
+ // revealing requires the on-disk path captured by the client that attached the file
+ const openAttachment = (file: FilePart) => {
+ const download = () => {
+ const anchor = document.createElement("a")
+ anchor.href = file.url
+ anchor.download = getFilename(file.filename) || "attachment"
+ anchor.click()
+ }
+ const path = file.filename ?? ""
+ const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
+ if (platform.revealPath && absolute) {
+ void platform.revealPath(path).then(
+ (revealed) => {
+ if (!revealed) download()
+ },
+ () => download(),
+ )
+ return
+ }
+ download()
+ }
+
+ const actions = { revert, openAttachment }
createEffect(() => {
const sessionID = params.id
@@ -2232,6 +2257,13 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
+ reviewSidebarToggle={(disabled) => (
+
+ )}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index dd51dd0585..b54fbc4e4c 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
+import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
+import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast"
@@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
+import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -53,6 +57,30 @@ function FileCommentMenu(props: {
)
}
+function FileCommentMenuV2(props: {
+ moreLabel: string
+ editLabel: string
+ deleteLabel: string
+ onEdit: VoidFunction
+ onDelete: VoidFunction
+}) {
+ return (
+ event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+
+
+
+
+
+
+ {props.editLabel}
+ {props.deleteLabel}
+
+
+
+
+ )
+}
+
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType["view"] }) {
@@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
}
export function SessionFileView(props: { tab: string }) {
+ const settings = useSettings()
+ return (
+ }>
+
+
+ )
+}
+
+function SessionFileViewV1(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
@@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
return content()
}
+
+function SessionFileViewV2(props: { tab: string }) {
+ const file = useFile()
+ const comments = useComments()
+ const language = useLanguage()
+ const prompt = usePrompt()
+ const fileComponent = useFileComponent()
+ const { sessionKey, tabs, view } = useSessionLayout()
+ const activeFileTab = createSessionTabs({
+ tabs,
+ pathFromTab: file.pathFromTab,
+ normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+ }).activeFileTab
+
+ let find: FileSearchHandle | null = null
+
+ const search = {
+ register: (handle: FileSearchHandle | null) => {
+ find = handle
+ },
+ }
+
+ const path = createMemo(() => file.pathFromTab(props.tab))
+ const state = createMemo(() => {
+ const p = path()
+ if (!p) return
+ return file.get(p)
+ })
+ const contents = createMemo(() => state()?.content?.content ?? "")
+ const cacheKey = createMemo(() => sampledChecksum(contents()))
+ const selectedLines = createMemo(() => {
+ const p = path()
+ if (!p) return null
+ if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
+ return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
+ })
+ const scrollSync = createScrollSync({
+ tab: () => props.tab,
+ view,
+ })
+
+ const selectionPreview = (source: string, selection: FileSelection) => {
+ return previewSelectedLines(source, {
+ start: selection.startLine,
+ end: selection.endLine,
+ })
+ }
+
+ const buildPreview = (filePath: string, selection: FileSelection) => {
+ const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
+ if (!source) return undefined
+ return selectionPreview(source, selection)
+ }
+
+ const addCommentToContext = (input: {
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+ }) => {
+ const selection = selectionFromLines(input.selection)
+ const preview = input.preview ?? buildPreview(input.file, selection)
+
+ const saved = comments.add({
+ file: input.file,
+ selection: input.selection,
+ comment: input.comment,
+ })
+ prompt.context.add({
+ type: "file",
+ path: input.file,
+ selection,
+ comment: input.comment,
+ commentID: saved.id,
+ commentOrigin: input.origin,
+ preview,
+ })
+ }
+
+ const updateCommentInContext = (input: {
+ id: string
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ }) => {
+ comments.update(input.file, input.id, input.comment)
+ const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
+ prompt.context.updateComment(input.file, input.id, {
+ comment: input.comment,
+ ...(preview ? { preview } : {}),
+ })
+ }
+
+ const removeCommentFromContext = (input: { id: string; file: string }) => {
+ comments.remove(input.file, input.id)
+ prompt.context.removeComment(input.file, input.id)
+ }
+
+ const fileComments = createMemo(() => {
+ const p = path()
+ if (!p) return []
+ return comments.list(p)
+ })
+
+ const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
+
+ const [note, setNote] = createStore({
+ openedComment: null as string | null,
+ commenting: null as SelectedLineRange | null,
+ selected: null as SelectedLineRange | null,
+ })
+
+ const syncSelected = (range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
+ }
+
+ const activeSelection = () => note.selected ?? selectedLines()
+
+ const commentsUi = createLineCommentControllerV2({
+ comments: fileComments,
+ label: language.t("ui.lineComment.submit"),
+ draftKey: () => path() ?? props.tab,
+ mention: {
+ items: file.searchFilesAndDirectories,
+ },
+ getSide: (range) => range.endSide ?? range.side ?? "additions",
+ state: {
+ opened: () => note.openedComment,
+ setOpened: (id) => setNote("openedComment", id),
+ selected: () => note.selected,
+ setSelected: (range) => setNote("selected", range),
+ commenting: () => note.commenting,
+ setCommenting: (range) => setNote("commenting", range),
+ syncSelected,
+ hoverSelected: syncSelected,
+ },
+ onSubmit: ({ comment, selection }) => {
+ const p = path()
+ if (!p) return
+ addCommentToContext({ file: p, selection, comment, origin: "file" })
+ },
+ onUpdate: ({ id, comment, selection }) => {
+ const p = path()
+ if (!p) return
+ updateCommentInContext({ id, file: p, selection, comment })
+ },
+ onDelete: (comment) => {
+ const p = path()
+ if (!p) return
+ removeCommentFromContext({ id: comment.id, file: p })
+ },
+ editSubmitLabel: language.t("common.save"),
+ renderCommentActions: (_, controls) => (
+
+ ),
+ })
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (activeFileTab() !== props.tab) return
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
+ if (event.key.toLowerCase() !== "f") return
+
+ event.preventDefault()
+ event.stopPropagation()
+ find?.focus()
+ }
+
+ makeEventListener(window, "keydown", onKeyDown, { capture: true })
+ })
+
+ createEffect(
+ on(
+ path,
+ () => {
+ commentsUi.note.reset()
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ const focus = comments.focus()
+ const p = path()
+ if (!focus || !p) return
+ if (focus.file !== p) return
+ if (activeFileTab() !== props.tab) return
+
+ const target = fileComments().find((comment) => comment.id === focus.id)
+ if (!target) return
+
+ commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
+ requestAnimationFrame(() => comments.clearFocus())
+ })
+
+ let prev = {
+ loaded: false,
+ ready: false,
+ active: false,
+ }
+
+ createEffect(() => {
+ const loaded = !!state()?.loaded
+ const ready = file.ready()
+ const active = activeFileTab() === props.tab
+ const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
+ prev = { loaded, ready, active }
+ if (!restore) return
+ scrollSync.queueRestore()
+ })
+
+ const renderFile = (source: string) => (
+
+ {
+ scrollSync.queueRestore()
+ }}
+ annotations={commentsUi.annotations()}
+ renderAnnotation={commentsUi.renderAnnotation}
+ renderGutterUtility={commentsUi.renderGutterUtility}
+ onLineSelected={(range: SelectedLineRange | null) => {
+ commentsUi.onLineSelected(range)
+ }}
+ onLineSelectionEnd={(range: SelectedLineRange | null) => {
+ if (!range) {
+ commentsUi.note.select(null)
+ commentsUi.note.cancelDraft()
+ return
+ }
+ commentsUi.onLineSelectionEnd(range)
+ }}
+ onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
+ commentsUi.onLineNumberSelectionEnd(range)
+ }}
+ search={search}
+ class="select-text"
+ media={{
+ mode: "auto",
+ path: path(),
+ current: state()?.content,
+ onLoad: scrollSync.queueRestore,
+ onError: (args: { kind: "image" | "audio" | "svg" }) => {
+ if (args.kind !== "svg") return
+ showToast({
+ variant: "error",
+ title: language.t("toast.file.loadFailed.title"),
+ })
+ },
+ }}
+ />
+
+ )
+
+ const content = () => (
+
+
+
+ {renderFile(contents())}
+
+ {language.t("common.loading")}...
+
+ {(err) => {err()}
}
+
+
+
+ )
+
+ return content()
+}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index b85e7ab13d..7c84ca7078 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -1,28 +1,46 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
+import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable } from "@dnd-kit/solid/sortable"
+import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ type DragEvent,
+} from "@thisbeyond/solid-dnd"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
-import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
-import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
+import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
-import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
+const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
+import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
+import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
@@ -47,6 +65,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
+ reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -60,7 +79,9 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
+ const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout()
+ const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
@@ -92,11 +113,9 @@ export function SessionSidePanel(props: {
return "mix" as const
}
- const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
-
const out = new Map()
for (const diff of diffs()) {
- const file = normalize(diff.file)
+ const file = normalizeFileTreeV2Path(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
@@ -153,6 +172,7 @@ export function SessionSidePanel(props: {
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
+ const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
@@ -170,10 +190,8 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab("all")
}
- const [store, setStore] = createStore({
- activeDraggable: undefined as string | undefined,
- })
let fileFilter: HTMLInputElement | undefined
+ let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
@@ -196,10 +214,26 @@ export function SessionSidePanel(props: {
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
- if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
+ const active = activeTab()
+ if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
+ if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
- const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
+ // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
+ // selects Review while the tab For replaces a preview trigger, which would
+ // otherwise dispose the sidebar and reset scroll.
+ const fileBrowserMounted = createMemo(() => {
+ if (!props.fileBrowserState) return false
+ return openedTabs().length > 0 || openFileOpen() || !!browserTab()
+ })
+ const fileBrowserVisible = createMemo(() => {
+ const active = activeTab()
+ return active !== "review" && active !== "context" && active !== "empty"
+ })
+ const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
+ const [store, setStore] = createStore({
+ activeDraggable: undefined as string | undefined,
+ })
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
@@ -285,72 +319,283 @@ export function SessionSidePanel(props: {
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
-
-
-
-
-
-
{
- const stop = createFileTabListSync({ el, contextOpen })
- onCleanup(stop)
- }}
- >
-
-
+
+
+
+
+
{
+ const stop = createFileTabListSync({ el, contextOpen })
+ onCleanup(stop)
+ }}
>
-
-
{language.t("session.tab.review")}
-
- {props.reviewCount()}
-
-
-
-
-
-
+
+
+
{language.t("session.tab.review")}
+
+ {props.reviewCount()}
+
+
+
+
+
+
+ tabs().close("context")}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close("context")}
+ >
+
+
+
{language.t("session.tab.context")}
+
+
+
+
+
+ {(tab) => (
+
+ }
+ >
+
+ tabs().close(SESSION_OPEN_FILE_TAB)}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
+ >
+
+
+ {language.t("command.file.open")}
+
+
+
+ )}
+
+
+
tabs().close("context")}
- aria-label={language.t("common.closeTab")}
+ iconSize="large"
+ class="!rounded-md"
+ onClick={() => {
+ void import("@/components/dialog-select-file").then((x) => {
+ dialog.show(() => )
+ })
+ }}
+ aria-label={language.t("command.file.open")}
/>
- }
- hideCloseButton
- onMiddleClick={() => tabs().close("context")}
- >
-
-
-
{language.t("session.tab.context")}
-
+
+
+
+
+
+ {props.reviewPanel()}
+
-
+
+
+
+
+
+
+
+ {language.t("session.files.selectToOpen")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(tab) => }
+
+
+
+
+ {(tab) => {
+ const path = file.pathFromTab(tab)
+ return (
+
+
+ {(p) => }
+
+
+ )
+ }}
+
+
+
+ }
+ >
+
+ event.target instanceof Element &&
+ (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
+ !!event.target.closest(".session-review-v2-open-in-app-slot")),
+ }),
+ ]}
+ modifiers={[
+ RestrictToHorizontalAxis,
+ RestrictToElement.configure({ element: () => tabList ?? null }),
+ ]}
+ plugins={(defaults) => [
+ ...defaults.filter((plugin) => plugin !== Accessibility),
+ AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
+ Feedback.configure({ dropAnimation: null }),
+ ]}
+ onDragEnd={(event) => {
+ const source = event.operation.source
+ if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
+ tabs().move(source.id.toString(), source.index)
+ }}
+ >
+
+
+
{
+ tabList = el
+ const stop = createFileTabListSync({ el, contextOpen })
+ onCleanup(stop)
+ }}
+ >
+
+ {(toggle) => (
+
+ {toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
+
+ )}
+
+
+
+ {props.hasReview()
+ ? language.t("session.review.filesChanged", { count: props.reviewCount() })
+ : language.t("session.tab.review")}
+
+
+
+
+ tabs().close("context")}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => tabs().close("context")}
+ >
+
+
+
{language.t("session.tab.context")}
+
+
+
{(tab) => (
tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@@ -386,106 +631,104 @@ export function SessionSidePanel(props: {
)}
-
-
-
- {
- if (props.fileBrowserState) {
- openFileBrowser()
- return
- }
- void import("@/components/dialog-select-file").then((x) => {
- dialog.show(() => )
- })
- }}
- aria-label={language.t("command.file.open")}
- />
-
+
+ {language.t("command.file.open")}
+ 0}>
+
+
+ >
+ }
+ placement="bottom"
+ class="flex items-center"
+ >
+ }
+ variant="ghost-muted"
+ size="large"
+ onClick={() => openFileBrowser()}
+ aria-label={language.t("command.file.open")}
+ />
+
+
+
+
event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ >
+
-
-
-
-
-
- {props.reviewPanel()}
-
-
-
-
-
-
-
- {language.t("session.files.selectToOpen")}
+
+
+ {props.reviewPanel()}
+
+
+
+
+
+
+
+
+
+ {language.t("session.files.selectToOpen")}
+
-
-
-
+
+
-
-
-
-
-
-
-
-
-
- previewTab(file.tab(path))}
- onSelectPermanent={(path) => openTab(file.tab(path))}
- filterRef={(element) => (fileFilter = element)}
- />
-
-
-
- {(tab) => }
-
-
-
-
- {(tab) => {
- const path = file.pathFromTab(tab)
- return (
-
-
- {(p) => }
-
+
+
+
+
- )
- }}
-
-
-
+
+
+
+
+
+ previewTab(file.tab(path))}
+ onSelectPermanent={(path) => openTab(file.tab(path))}
+ filterRef={(element) => (fileFilter = element)}
+ />
+
+
+
+
+
@@ -513,10 +756,19 @@ export function SessionSidePanel(props: {
>
- {props.reviewCount()}{" "}
- {language.t(
- props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
- )}
+
+ {props.reviewCount()}{" "}
+ {language.t(
+ props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
+ )}
+ >
+ }
+ >
+ {language.t("session.review.filesChanged", { count: props.reviewCount() })}
+
{language.t("session.files.all")}
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 168bdf2f69..7438d6febd 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -327,6 +327,7 @@ export function MessageTimeline(props: {
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
+ inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
+ const messageComments = createMemo(() => {
+ if (!settings.general.newLayoutDesigns()) return []
+ return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
+ })
return (
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
+ comments={messageComments()}
/>
diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts
index ec2a3190ba..ea8ea4f132 100644
--- a/packages/app/src/pages/session/timeline/projection.ts
+++ b/packages/app/src/pages/session/timeline/projection.ts
@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
parts: (messageID: string) => Part[]
status: Accessor
showReasoningSummaries: Accessor
+ inlineComments: Accessor
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
+ input.inlineComments(),
),
),
),
diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts
index 6a9cb669d0..72cd9a180c 100644
--- a/packages/app/src/pages/session/timeline/rows.ts
+++ b/packages/app/src/pages/session/timeline/rows.ts
@@ -38,6 +38,8 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
+ // v2 renders comments inside the user message attachments row instead of a strip row
+ inlineComments: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
@@ -74,7 +76,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
- if (comments.length > 0)
+ if (comments.length > 0 && !inlineComments)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
@@ -84,7 +86,7 @@ export namespace Timeline {
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
- anchor: comments.length === 0,
+ anchor: inlineComments || comments.length === 0,
}),
)
diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx
index cf9367d063..3f24b702c7 100644
--- a/packages/app/src/pages/session/v2/review-panel-v2.tsx
+++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx
@@ -5,7 +5,6 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
- SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -65,6 +64,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
+ // Changes-only trees omit "M" — every row is already a change; A/D stay visible.
+ const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
@@ -112,9 +113,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
stats={}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
- sidebarToggle={
-
- }
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
@@ -126,7 +124,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
- kinds={kinds}
+ kinds={treeKinds}
activeDiff={activeDiff}
/>
}
diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx
index 9cbdd40df1..04ad1e3c82 100644
--- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx
+++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx
@@ -1,14 +1,9 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query"
-import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
-import {
- SessionFilePanelV2,
- SessionFilePanelV2Empty,
- SessionFilePanelV2Title,
-} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
-import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
-import FileTree, { type Kind } from "@/components/file-tree"
+import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
+import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
+import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -93,102 +88,92 @@ export function SessionFileBrowserTab(props: {
})
}
+ // Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
+ // unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return (
-
-
-
-
- {title()}
-
- >
- }
- sidebar={
- {title()}}
- filter={filter()}
- onFilterChange={setFilter}
- onFilterKeyDown={onFilterKeyDown}
- filterAutofocus={props.placeholder}
- filterRef={props.filterRef}
- filterControls={resultsID}
- filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
- filterExpanded={query().length > 0 && files().length > 0}
- width={props.state.sidebarWidth()}
- onWidthChange={props.state.resizeSidebar}
+ {title()}}
+ filter={filter()}
+ onFilterChange={setFilter}
+ onFilterKeyDown={onFilterKeyDown}
+ filterAutofocus={props.placeholder}
+ filterRef={props.filterRef}
+ filterControls={resultsID}
+ filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
+ filterExpanded={query().length > 0 && files().length > 0}
+ width={props.state.sidebarWidth()}
+ onWidthChange={props.state.resizeSidebar}
+ >
+ props.onSelect(node.path)}
+ onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
+ />
+ }
>
props.onSelect(node.path)}
- onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
- />
+
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
+
}
>
0}
fallback={
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
+ {language.t("palette.empty")}
}
>
- 0}
- fallback={
-
- {language.t("palette.empty")}
-
- }
- >
- {
- setExplicitHighlight(path)
- props.onSelect(path)
- }}
- onFileDoubleClick={props.onSelectPermanent}
- />
-
+ {
+ setExplicitHighlight(path)
+ props.onSelect(path)
+ }}
+ onFileDoubleClick={props.onSelectPermanent}
+ />
-
+
+
+ }
+ >
+
+
+
+
{language.t("command.file.open")}
+
{language.t("session.files.selectToOpen")}
+
+
}
>
-
-
-
-
{language.t("command.file.open")}
-
{language.t("session.files.selectToOpen")}
-
-
- }
- >
-
-
- {(tab) => }
-
-
-
-
-
+
+
+ {(tab) => }
+
+
+
+
)
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e5172df3c4..2a3503dff6 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
- "version": "1.17.18",
+ "version": "1.17.19",
"type": "module",
"license": "MIT",
"bin": {
diff --git a/packages/codemode/package.json b/packages/codemode/package.json
index 263daa6a53..a7e2ce3f4b 100644
--- a/packages/codemode/package.json
+++ b/packages/codemode/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
- "version": "1.17.18",
+ "version": "1.17.19",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 35d31f9342..3db6b14fc3 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.17.18",
+ "version": "1.17.19",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index ffc7a756d2..66be843c05 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.17.18",
+ "version": "1.17.19",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index 47ae1efd7a..08c0a56049 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.17.18",
+ "version": "1.17.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index d0f16d771f..1ca0ed2b71 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.17.18",
+ "version": "1.17.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/console/support/package.json b/packages/console/support/package.json
index 159a59722b..c9d0cc9b8a 100644
--- a/packages/console/support/package.json
+++ b/packages/console/support/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
- "version": "1.17.18",
+ "version": "1.17.19",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/core/package.json b/packages/core/package.json
index 4a3b6b01cd..7def5deb9a 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.18",
+ "version": "1.17.19",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -64,7 +64,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
- "@ai-sdk/azure": "3.0.49",
+ "@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -73,7 +73,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
- "@ai-sdk/openai": "3.0.53",
+ "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -110,7 +110,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
- "gitlab-ai-provider": "6.10.0",
+ "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index ece963cbc2..ab0be7d0d8 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.17.18",
+ "version": "1.17.19",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index 073d288e5c..d6bc5bb3a8 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) {
})
})
+ ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
+ const exists = await stat(path).then(
+ () => true,
+ () => false,
+ )
+ if (!exists) return false
+ shell.showItemInFolder(path)
+ return true
+ })
+
ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 47a757a557..a12864c559 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -95,6 +95,7 @@ const api: ElectronAPI = {
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
+ revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index b57ac83e7f..b94acd6615 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -86,6 +86,7 @@ export type ElectronAPI = {
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise
+ revealPath: (path: string) => Promise
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 966fb0c0bf..2f14f43481 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
}
return window.api.openPath(path, app)
},
+ async revealPath(path: string) {
+ return window.api.revealPath(path)
+ },
back() {
window.history.back()
diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json
index 6aebc6f41d..1a9a7c13e6 100644
--- a/packages/effect-drizzle-sqlite/package.json
+++ b/packages/effect-drizzle-sqlite/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.18",
+ "version": "1.17.19",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json
index 117aab177f..aa7367391d 100644
--- a/packages/effect-sqlite-node/package.json
+++ b/packages/effect-sqlite-node/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.18",
+ "version": "1.17.19",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index ea959c4e4f..7d3f07b88d 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.17.18",
+ "version": "1.17.19",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/function/package.json b/packages/function/package.json
index 282076fa65..eee06e20eb 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.17.18",
+ "version": "1.17.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json
index dbb0cafca9..962850f24c 100644
--- a/packages/http-recorder/package.json
+++ b/packages/http-recorder/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.18",
+ "version": "1.17.19",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
diff --git a/packages/llm/package.json b/packages/llm/package.json
index d9a8a8b7eb..9e8c08f1ed 100644
--- a/packages/llm/package.json
+++ b/packages/llm/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.18",
+ "version": "1.17.19",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index f1695e9d89..3938eac6e0 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.17.18",
+ "version": "1.17.19",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -58,7 +58,7 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
- "@ai-sdk/azure": "3.0.49",
+ "@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -67,7 +67,7 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
- "@ai-sdk/openai": "3.0.53",
+ "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
@@ -122,7 +122,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
- "gitlab-ai-provider": "6.10.0",
+ "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts
index af8ef761eb..4b49d2a748 100644
--- a/packages/opencode/src/account/account.ts
+++ b/packages/opencode/src/account/account.ts
@@ -348,6 +348,18 @@ const layer: Layer.Layer
+ group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })),
+ )[0]
+ if (!next) return
+ yield* repo.use(next.accountID, Option.some(next.orgID))
+ })
+
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
@@ -445,7 +457,7 @@ const layer: Layer.Layer {
+ if (model.options.reasoningMode === "pro") return false
if (ALLOWED_MODELS.has(model.api.id)) return true
if (DISALLOWED_MODELS.has(model.api.id)) return false
+ if (model.api.id === "gpt-5.6") return false
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false
})
@@ -416,6 +421,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
const requestInit = {
...init,
+ body: parsed.pathname.endsWith("/responses")
+ ? prepareResponsesLiteRequest(init?.body, headers)
+ : init?.body,
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
@@ -560,3 +568,61 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
},
}
}
+
+function prepareResponsesLiteRequest(body: BodyInit | null | undefined, headers: Headers) {
+ if (typeof body !== "string") return body
+ const request: unknown = JSON.parse(body)
+ if (!isRecord(request)) return body
+ if (request.model !== RESPONSES_LITE_MODEL) return body
+ if (!Array.isArray(request.input)) throw new Error("Responses Lite requires an input array")
+ if (request.tools !== undefined && !Array.isArray(request.tools)) {
+ throw new Error("Responses Lite requires a tools array")
+ }
+ if (request.instructions !== undefined && typeof request.instructions !== "string") {
+ throw new Error("Responses Lite requires string instructions")
+ }
+
+ const sessionID = headers.get("session-id")
+ if (!sessionID) throw new Error("Responses Lite requires a session-id header")
+
+ function stripImageDetail(value: unknown) {
+ if (Array.isArray(value)) {
+ value.forEach(stripImageDetail)
+ return
+ }
+ if (!isRecord(value)) return
+ if (value.type === "input_image") delete value.detail
+ Object.values(value).forEach(stripImageDetail)
+ }
+
+ stripImageDetail(request.input)
+ request.input = [
+ { type: "additional_tools", role: "developer", tools: request.tools ?? [] },
+ ...(request.instructions
+ ? [
+ {
+ type: "message",
+ role: "developer",
+ content: [{ type: "input_text", text: request.instructions }],
+ },
+ ]
+ : []),
+ ...request.input,
+ ]
+ delete request.tools
+ delete request.instructions
+ request.tool_choice = "auto"
+ request.parallel_tool_calls = false
+ request.prompt_cache_key = sessionID
+ request.reasoning = {
+ ...(isRecord(request.reasoning) ? request.reasoning : {}),
+ context: "all_turns",
+ }
+
+ headers.set("session-id", sessionID)
+ headers.set("x-session-affinity", sessionID)
+ headers.set("version", CODEX_COMPATIBILITY_VERSION)
+ headers.set(OpenAIWebSocketPool.RESPONSES_LITE_HEADER, "true")
+ headers.delete("content-length")
+ return JSON.stringify(request)
+}
diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts
index 3cbb29a301..2cc7dcc940 100644
--- a/packages/opencode/src/plugin/openai/ws-pool.ts
+++ b/packages/opencode/src/plugin/openai/ws-pool.ts
@@ -4,6 +4,8 @@ import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title"
+export const RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite"
+const RESPONSES_LITE_CLIENT_METADATA = "ws_request_header_x_openai_internal_codex_responses_lite"
export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch
@@ -98,7 +100,16 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
- body,
+ body:
+ internalHeaders[RESPONSES_LITE_HEADER] === "true"
+ ? {
+ ...body,
+ client_metadata: {
+ ...(isRecord(body.client_metadata) ? body.client_metadata : {}),
+ [RESPONSES_LITE_CLIENT_METADATA]: "true",
+ },
+ }
+ : body,
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: (error) => resolveFirstEvent(error ?? true),
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index c6051c8996..811e44a180 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -1250,9 +1250,11 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
variants: {},
}
+ const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base)
+
return {
...base,
- variants: mapValues(ProviderTransform.variants(base), (v) => v),
+ variants: mapValues(variants, (v) => v),
}
}
@@ -1273,17 +1275,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
// @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data.
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
- // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data.
- options: opts.provider?.body
- ? Object.fromEntries(
- // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data.
- Object.entries(opts.provider.body).map(([k, v]) => [
- k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
- v,
- ]),
- )
- : base.options,
- // @ts-expect-error dead V1 expects raw mode headers inside normalized ModelsDev data.
+ options: modeOptions(base, opts.provider?.body),
headers: opts.provider?.headers ?? base.headers,
}
}
@@ -1298,6 +1290,17 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
+function modeOptions(model: Model, body: Record | undefined) {
+ if (!body) return model.options
+ const options = Object.fromEntries(
+ Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]),
+ )
+ const reasoning = body.reasoning
+ if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options
+ const { reasoning: _, ...rest } = options
+ return { ...rest, reasoningMode: reasoning.mode }
+}
+
function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
const available = provider
? Object.keys(provider.models).filter((id) => {
@@ -1507,7 +1510,11 @@ const layer = Layer.effect(
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
- const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
+ const variants =
+ existingModel?.api.npm === parsedModel.api.npm
+ ? (existingModel.variants ?? ProviderTransform.variants(parsedModel))
+ : ProviderTransform.variants(parsedModel)
+ const merged = mergeDeep(variants, model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
@@ -1638,7 +1645,7 @@ const layer = Layer.effect(
)
delete provider.models[modelID]
- if (!model.variants || Object.keys(model.variants).length === 0) {
+ if (model.variants === undefined) {
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
}
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 9f2b343163..8ef3d222e8 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -761,7 +761,7 @@ export function variants(model: Provider.Model): Record [
@@ -794,8 +794,8 @@ export function variants(model: Provider.Model): Record option.type === "effort")
+ if (effort) return nonEmptyVariants(effortVariants(target, effort.values))
+
+ const toggle = options.some((option) => option.type === "toggle")
+ const budget = options.find((option) => option.type === "budget_tokens")
+ if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined
+
+ return nonEmptyVariants({
+ ...(toggle ? reasoningToggle(target) : {}),
+ ...budgetVariants(target, budget.min, budget.max),
+ })
+}
+
+function effortVariants(model: Provider.Model, values: readonly unknown[]) {
+ return Object.fromEntries(
+ values.flatMap((value) => {
+ const id = (() => {
+ if (value === null) return "none"
+ if (typeof value === "string") return value
+ })()
+ if (id === undefined) return []
+ const settings = reasoningEffort(model, id)
+ return settings ? [[id, settings]] : []
+ }),
+ )
+}
+
+function budgetVariants(model: Provider.Model, min?: number, max?: number) {
+ const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1)
+ if (maximum <= 0) return {}
+ const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
+ return Object.fromEntries(
+ [
+ { id: "high", budget: high },
+ { id: "max", budget: maximum },
+ ].flatMap((item) => {
+ const settings = reasoningBudget(model, item.budget)
+ return settings ? [[item.id, settings]] : []
+ }),
+ )
+}
+
+function nonEmptyVariants(variants: NonNullable): Provider.Model["variants"] {
+ return Object.keys(variants).length > 0 ? variants : undefined
+}
+
+function reasoningToggle(model: Provider.Model): NonNullable {
+ if (model.api.npm === "@ai-sdk/alibaba")
+ return {
+ none: { enableThinking: false },
+ high: { enableThinking: true },
+ }
+ if (model.api.npm === "@ai-sdk/cohere")
+ return {
+ none: { thinking: { type: "disabled" } },
+ high: { thinking: { type: "enabled" } },
+ }
+ return {}
+}
+
+function reasoningEffort(model: Provider.Model, effort: string) {
+ switch (model.api.npm) {
+ case "@openrouter/ai-sdk-provider":
+ return { reasoning: { effort } }
+ case "@ai-sdk/anthropic":
+ case "@ai-sdk/google-vertex/anthropic":
+ return anthropicEffort(model, effort)
+ case "@ai-sdk/google":
+ case "@ai-sdk/google-vertex":
+ return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
+ case "@ai-sdk/amazon-bedrock":
+ if (anthropicAdaptiveEfforts(model.api.id))
+ return {
+ reasoningConfig: {
+ type: "adaptive",
+ maxReasoningEffort: effort,
+ ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
+ },
+ }
+ if (model.api.id.includes("anthropic")) return
+ return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
+ case "@ai-sdk/gateway":
+ if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort }
+ if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
+ return { reasoningEffort: effort }
+ case "@ai-sdk/github-copilot":
+ // OAuth discovery replaces these with variants from Copilot's /models capabilities.
+ if (model.id.includes("gemini")) return
+ if (model.id.includes("claude")) return { reasoningEffort: effort }
+ return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
+ case "@ai-sdk/openai":
+ case "@ai-sdk/amazon-bedrock/mantle":
+ return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
+ case "@ai-sdk/azure":
+ return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
+ case "@jerome-benoit/sap-ai-provider-v2":
+ if (model.id.includes("anthropic"))
+ return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } }
+ return { modelParams: { reasoning_effort: effort } }
+ case "@ai-sdk/openai-compatible":
+ case "@ai-sdk/xai":
+ case "@ai-sdk/mistral":
+ case "@ai-sdk/groq":
+ case "@ai-sdk/cerebras":
+ case "@ai-sdk/deepinfra":
+ case "@ai-sdk/togetherai":
+ case "venice-ai-sdk-provider":
+ case "ai-gateway-provider":
+ return { reasoningEffort: effort }
+ case "@ai-sdk/cohere":
+ case "@ai-sdk/perplexity":
+ case "@ai-sdk/vercel":
+ case "@ai-sdk/alibaba":
+ case "gitlab-ai-provider":
+ return
+ }
+}
+
+function anthropicEffort(model: Provider.Model, effort: string) {
+ if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort }
+ if (!anthropicAdaptiveEfforts(model.api.id)) return
+ return {
+ thinking: {
+ type: "adaptive",
+ ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
+ },
+ effort,
+ }
+}
+
+function reasoningBudget(model: Provider.Model, budget: number) {
+ switch (model.api.npm) {
+ case "@openrouter/ai-sdk-provider":
+ return { reasoning: { max_tokens: budget } }
+ case "@ai-sdk/anthropic":
+ case "@ai-sdk/google-vertex/anthropic":
+ return { thinking: { type: "enabled", budgetTokens: budget } }
+ case "@ai-sdk/google":
+ case "@ai-sdk/google-vertex":
+ return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
+ case "@ai-sdk/amazon-bedrock":
+ return { reasoningConfig: { type: "enabled", budgetTokens: budget } }
+ case "@ai-sdk/gateway":
+ if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } }
+ if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
+ return
+ case "@ai-sdk/cohere":
+ return { thinking: { type: "enabled", tokenBudget: budget } }
+ case "@ai-sdk/alibaba":
+ return { enableThinking: true, thinkingBudget: budget }
+ case "@jerome-benoit/sap-ai-provider-v2":
+ if (model.id.includes("anthropic"))
+ return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } }
+ if (model.id.includes("gemini"))
+ return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
+ return
+ case "@ai-sdk/amazon-bedrock/mantle":
+ case "@ai-sdk/azure":
+ case "@ai-sdk/cerebras":
+ case "@ai-sdk/deepinfra":
+ case "@ai-sdk/github-copilot":
+ case "@ai-sdk/groq":
+ case "@ai-sdk/mistral":
+ case "@ai-sdk/openai":
+ case "@ai-sdk/openai-compatible":
+ case "@ai-sdk/perplexity":
+ case "@ai-sdk/togetherai":
+ case "@ai-sdk/vercel":
+ case "@ai-sdk/xai":
+ case "ai-gateway-provider":
+ case "gitlab-ai-provider":
+ case "venice-ai-sdk-provider":
+ return
+ }
+}
+
export * as ProviderTransform from "./transform"
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index 0ebe69c239..672d549716 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/packages/opencode/test/account/service.test.ts
@@ -173,6 +173,53 @@ it.live("orgsByAccount groups orgs per account", () =>
}),
)
+it.live("remove switches to another org when the active account is removed", () =>
+ Effect.gen(function* () {
+ const first = AccountID.make("user-1")
+ const second = AccountID.make("user-2")
+
+ yield* AccountRepo.Service.use((r) =>
+ r.persistAccount({
+ id: first,
+ email: "one@example.com",
+ url: "https://one.example.com",
+ accessToken: AccessToken.make("at_1"),
+ refreshToken: RefreshToken.make("rt_1"),
+ expiry: Date.now() + outsideEagerRefreshWindow,
+ orgID: Option.some(OrgID.make("org-1")),
+ }),
+ )
+
+ yield* AccountRepo.Service.use((r) =>
+ r.persistAccount({
+ id: second,
+ email: "two@example.com",
+ url: "https://two.example.com",
+ accessToken: AccessToken.make("at_2"),
+ refreshToken: RefreshToken.make("rt_2"),
+ expiry: Date.now() + outsideEagerRefreshWindow,
+ orgID: Option.some(OrgID.make("org-2")),
+ }),
+ )
+
+ const client = HttpClient.make((req) =>
+ Effect.succeed(
+ req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404),
+ ),
+ )
+
+ yield* Account.use.remove(second).pipe(Effect.provide(live(client)))
+
+ const active = yield* AccountRepo.use.active()
+ expect(Option.getOrThrow(active)).toEqual(
+ expect.objectContaining({
+ id: first,
+ active_org_id: OrgID.make("org-1"),
+ }),
+ )
+ }),
+)
+
it.live("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts
index 0d789bb42b..1381c4ee8a 100644
--- a/packages/opencode/test/plugin/codex.test.ts
+++ b/packages/opencode/test/plugin/codex.test.ts
@@ -149,16 +149,32 @@ describe("plugin.codex", () => {
await enabled.dispose?.()
})
- test("uses Codex context limits for OAuth GPT models", async () => {
+ test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => {
const hooks = await CodexAuthPlugin({} as never)
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
const provider = {
- models: Object.fromEntries(
- ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [
- id,
- { id, api: { id }, limit, cost: {} },
- ]),
- ),
+ models: {
+ ...Object.fromEntries(
+ ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [
+ id,
+ { id, api: { id }, limit, cost: {}, options: {} },
+ ]),
+ ),
+ "gpt-5.4-pro": {
+ id: "gpt-5.4-pro",
+ api: { id: "gpt-5.4" },
+ limit,
+ cost: {},
+ options: { reasoningMode: "pro" },
+ },
+ "gpt-5.6-sol-high": {
+ id: "gpt-5.6-sol-high",
+ api: { id: "gpt-5.6-sol" },
+ limit,
+ cost: {},
+ options: { reasoningEffort: "high" },
+ },
+ },
}
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
@@ -168,6 +184,9 @@ describe("plugin.codex", () => {
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
+ expect(models["gpt-5.4-pro"]).toBeUndefined()
+ expect(models["gpt-5.7-pro"]).toBeDefined()
+ expect(models["gpt-5.6-sol-high"]).toBeDefined()
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
provider.models as never,
)
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 78d8930886..27978776be 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -564,6 +564,45 @@ it.instance(
},
)
+it.instance(
+ "model config preserves explicitly empty models.dev variants",
+ Effect.gen(function* () {
+ yield* set("OPENAI_API_KEY", "test-api-key")
+ const providers = yield* list
+ const model = providers[ProviderV2.ID.openai].models["custom-gpt-chat"]
+ expect(model.name).toBe("Custom GPT Chat")
+ expect(model.variants).toEqual({})
+ }),
+ {
+ config: {
+ provider: {
+ openai: { models: { "custom-gpt-chat": { id: "gpt-5-chat-latest", name: "Custom GPT Chat" } } },
+ },
+ },
+ },
+)
+
+it.instance(
+ "model config regenerates variants when overriding the provider package",
+ Effect.gen(function* () {
+ yield* set("ANTHROPIC_API_KEY", "test-api-key")
+ const providers = yield* list
+ const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
+ expect(model.variants?.low).toEqual({ reasoningEffort: "low" })
+ expect(model.variants?.max).toBeUndefined()
+ }),
+ {
+ config: {
+ provider: {
+ anthropic: {
+ npm: "@ai-sdk/openai-compatible",
+ models: { "claude-sonnet-4-6": { name: "Claude via OpenAI" } },
+ },
+ },
+ },
+ },
+)
+
it.instance(
"disabled_providers prevents loading even with env var",
Effect.gen(function* () {
@@ -1339,16 +1378,17 @@ it.instance(
},
)
-test("mode cost preserves over-200k pricing from base model", () => {
+test("mode options and cost are derived from the base model", () => {
const provider = {
id: "openai",
name: "OpenAI",
env: [],
+ npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
- "gpt-5.4": {
- id: "gpt-5.4",
- name: "GPT-5.4",
+ "gpt-5.6-sol": {
+ id: "gpt-5.6-sol",
+ name: "GPT-5.6 Sol",
family: "gpt",
release_date: "2026-03-05",
attachment: true,
@@ -1384,18 +1424,29 @@ test("mode cost preserves over-200k pricing from base model", () => {
},
},
},
+ pro: {
+ provider: {
+ body: {
+ reasoning: { mode: "pro" },
+ service_tier: "priority",
+ },
+ },
+ },
},
},
},
},
} as unknown as ModelsDevProvider
- const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
+ const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"]
expect(model.cost.input).toEqual(5)
expect(model.cost.output).toEqual(30)
expect(model.cost.cache.read).toEqual(0.5)
expect(model.cost.cache.write).toEqual(0)
expect(model.options["serviceTier"]).toEqual("priority")
+ const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"]
+ expect(pro.api.id).toEqual("gpt-5.6-sol")
+ expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" })
expect(model.cost.experimentalOver200K).toEqual({
input: 5,
output: 22.5,
@@ -1428,6 +1479,62 @@ test("models.dev normalization fills required response fields", () => {
expect(model.release_date).toBe("")
})
+test("models.dev reasoning options replace generated variants and unsupported options fall back", () => {
+ const provider = {
+ id: "reasoning",
+ name: "Reasoning",
+ env: [],
+ npm: "@ai-sdk/openai",
+ models: {
+ explicit: {
+ id: "gpt-5.4",
+ name: "Explicit",
+ reasoning: true,
+ reasoning_options: [{ type: "effort", values: ["low"] }],
+ limit: { context: 128_000, output: 64_000 },
+ },
+ empty: {
+ id: "gpt-5.4",
+ name: "Empty",
+ reasoning: true,
+ reasoning_options: [],
+ limit: { context: 128_000, output: 64_000 },
+ },
+ fallback: {
+ id: "gpt-5.4",
+ name: "Fallback",
+ reasoning: true,
+ reasoning_options: [{ type: "toggle" }],
+ limit: { context: 128_000, output: 64_000 },
+ },
+ override: {
+ id: "gemini-3-pro",
+ name: "Override",
+ reasoning: true,
+ reasoning_options: [{ type: "effort", values: ["high"] }],
+ provider: { npm: "@ai-sdk/google" },
+ limit: { context: 128_000, output: 64_000 },
+ experimental: { modes: { fast: {} } },
+ },
+ },
+ } as unknown as ModelsDev.Provider
+
+ const models = Provider.fromModelsDevProvider(provider).models
+ expect(models.explicit.variants).toEqual({
+ low: {
+ reasoningEffort: "low",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ })
+ expect(models.empty.variants).toEqual({})
+ expect(Object.keys(models.fallback.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh"])
+ expect(models.override.variants).toEqual({
+ high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
+ })
+ expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants)
+})
+
test("public provider info omits invalid models", () => {
const provider = Provider.fromModelsDevProvider({
id: "test",
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index e69dab70f6..4c462bd3dc 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -4,6 +4,7 @@ import { ProviderTransform } from "@/provider/transform"
import { LLMRequestPrep } from "@/session/llm/request"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
+import { ModelsDev } from "@opencode-ai/core/models-dev"
import { jsonSchema } from "ai"
describe("ProviderTransform.options - setCacheKey", () => {
@@ -155,6 +156,43 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.store).toBe(false)
})
+ test("should set store=false for xAI provider by default", () => {
+ const xaiModel = {
+ ...mockModel,
+ providerID: "xai",
+ api: {
+ id: "grok-4",
+ url: "https://api.x.ai",
+ npm: "@ai-sdk/xai",
+ },
+ }
+ const result = ProviderTransform.options({
+ model: xaiModel,
+ sessionID,
+ providerOptions: {},
+ })
+ expect(result.store).toBe(false)
+ expect(result.promptCacheKey).toBe(sessionID)
+ })
+
+ test("should set store=false for xAI SDK regardless of provider ID", () => {
+ const xaiModel = {
+ ...mockModel,
+ providerID: "custom-xai",
+ api: {
+ id: "grok-4",
+ url: "https://api.x.ai",
+ npm: "@ai-sdk/xai",
+ },
+ }
+ const result = ProviderTransform.options({
+ model: xaiModel,
+ sessionID,
+ providerOptions: {},
+ })
+ expect(result.store).toBe(false)
+ })
+
test("should set store=false for azure provider by default", () => {
const azureModel = {
...mockModel,
@@ -2988,6 +3026,268 @@ describe("ProviderTransform.temperature - Cohere North", () => {
})
})
+describe("ProviderTransform.reasoningVariants", () => {
+ const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model
+ const target = (npm: string, id = "test-model") =>
+ ({ id, api: { id, npm, url: "" }, capabilities: { reasoning: true }, limit: { output: 64_000 } }) as any
+
+ test("respects explicitly empty reasoning options", () => {
+ expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({})
+ })
+
+ test.each([
+ ["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }],
+ ["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"],
+ [
+ "@ai-sdk/google-vertex/anthropic",
+ { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
+ "claude-opus-4-7",
+ ],
+ ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
+ ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
+ [
+ "@ai-sdk/azure",
+ {
+ reasoningEffort: "high",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ ],
+ [
+ "@ai-sdk/openai",
+ {
+ reasoningEffort: "high",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ ],
+ [
+ "@ai-sdk/amazon-bedrock/mantle",
+ {
+ reasoningEffort: "high",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ ],
+ [
+ "@ai-sdk/github-copilot",
+ {
+ reasoningEffort: "high",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ ],
+ ["@ai-sdk/openai-compatible", { reasoningEffort: "high" }],
+ ["@ai-sdk/xai", { reasoningEffort: "high" }],
+ ["@ai-sdk/mistral", { reasoningEffort: "high" }],
+ ["@ai-sdk/groq", { reasoningEffort: "high" }],
+ ["@ai-sdk/cerebras", { reasoningEffort: "high" }],
+ ["@ai-sdk/deepinfra", { reasoningEffort: "high" }],
+ ["@ai-sdk/togetherai", { reasoningEffort: "high" }],
+ ["venice-ai-sdk-provider", { reasoningEffort: "high" }],
+ ["ai-gateway-provider", { reasoningEffort: "high" }],
+ ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }],
+ ])("converts effort for %s", (npm, expected, ...args) => {
+ const id = args[0] as string | undefined
+ expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual(
+ { high: expected },
+ )
+ })
+
+ test("uses bare effort for Claude Opus 4.5", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "effort", values: ["high"] }]),
+ target("@ai-sdk/anthropic", "claude-opus-4-5"),
+ ),
+ ).toEqual({ high: { effort: "high" } })
+ })
+
+ test("leaves legacy Anthropic effort options to budget fallback", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "effort", values: ["high"] }]),
+ target("@ai-sdk/anthropic", "claude-sonnet-4"),
+ ),
+ ).toBeUndefined()
+ })
+
+ test("uses adaptive reasoning config for Anthropic models on Bedrock", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "effort", values: ["high"] }]),
+ target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"),
+ ),
+ ).toEqual({
+ high: {
+ reasoningConfig: {
+ type: "adaptive",
+ maxReasoningEffort: "high",
+ display: "summarized",
+ },
+ },
+ })
+ })
+
+ test("leaves legacy Anthropic Bedrock effort options to budget fallback", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "effort", values: ["high"] }]),
+ target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"),
+ ),
+ ).toBeUndefined()
+ })
+
+ test.each([
+ ["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }],
+ ["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }],
+ ["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }],
+ ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }],
+ ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }],
+ ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }],
+ ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }],
+ ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }],
+ ])("converts token budgets for %s", (npm, high) => {
+ const variants = ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024 }]), target(npm))
+ expect(variants?.high).toEqual(high)
+ expect(Object.keys(variants ?? {})).toEqual(["high", "max"])
+ })
+
+ test("maps null effort to none", () => {
+ expect(
+ ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")),
+ ).toEqual({
+ none: {
+ reasoningEffort: "none",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ })
+ })
+
+ test.each([
+ ["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }],
+ [
+ "@ai-sdk/cohere",
+ {
+ none: { thinking: { type: "disabled" } },
+ high: { thinking: { type: "enabled" } },
+ },
+ ],
+ ])("converts toggle options for %s", (npm, expected) => {
+ expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected)
+ })
+
+ test("combines Cohere toggle and budget options", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]),
+ target("@ai-sdk/cohere"),
+ ),
+ ).toEqual({
+ none: { thinking: { type: "disabled" } },
+ high: { thinking: { type: "enabled", tokenBudget: 16_000 } },
+ max: { thinking: { type: "enabled", tokenBudget: 31_999 } },
+ })
+ })
+
+ test("generates bounded high and max token budgets", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]),
+ target("@ai-sdk/anthropic"),
+ ),
+ ).toEqual({
+ high: { thinking: { type: "enabled", budgetTokens: 16_000 } },
+ max: { thinking: { type: "enabled", budgetTokens: 31_999 } },
+ })
+ })
+
+ test("caps token budgets below the model output limit", () => {
+ const anthropic = target("@ai-sdk/anthropic")
+ anthropic.limit.output = 5_000
+ expect(
+ ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic),
+ ).toEqual({
+ high: { thinking: { type: "enabled", budgetTokens: 2_500 } },
+ max: { thinking: { type: "enabled", budgetTokens: 4_999 } },
+ })
+ })
+
+ test("derives high and max budgets when models.dev omits max", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "budget_tokens", min: 1_024 }]),
+ target("@ai-sdk/anthropic", "claude-haiku-4-5"),
+ ),
+ ).toEqual({
+ high: { thinking: { type: "enabled", budgetTokens: 16_000 } },
+ max: { thinking: { type: "enabled", budgetTokens: 31_999 } },
+ })
+ })
+
+ test("preserves explicit inclusive budget maxima", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([{ type: "budget_tokens", min: 1_024, max: 24_576 }]),
+ target("@ai-sdk/google", "gemini-2.5-pro"),
+ ),
+ ).toEqual({
+ high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 12_288 } },
+ max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } },
+ })
+ })
+
+ test("prefers effort options over token budgets", () => {
+ expect(
+ ProviderTransform.reasoningVariants(
+ model([
+ { type: "budget_tokens", min: 1_024, max: 64_000 },
+ { type: "effort", values: ["low"] },
+ ]),
+ target("@ai-sdk/openai"),
+ ),
+ ).toEqual({
+ low: {
+ reasoningEffort: "low",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ },
+ })
+ })
+
+ test("leaves unsupported options for heuristic fallback", () => {
+ expect(
+ ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")),
+ ).toBeUndefined()
+ expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined()
+ })
+
+ test("uses model-family options for gateway and GitHub Copilot", () => {
+ const effort = model([{ type: "effort", values: ["high"] }])
+ expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual(
+ {
+ high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
+ },
+ )
+ expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({
+ high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } },
+ })
+ expect(
+ ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro")),
+ ).toBeUndefined()
+ })
+
+ test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])(
+ "does not invent effort controls for %s",
+ (npm) => {
+ expect(
+ ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm)),
+ ).toBeUndefined()
+ },
+ )
+})
+
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial = {}): any => ({
id: "test/test-model",
@@ -3372,6 +3672,42 @@ describe("ProviderTransform.variants", () => {
})
describe("@ai-sdk/gateway", () => {
+ test("configured anthropic aliases route by the API ID", () => {
+ const model = createMockModel({
+ id: "my-claude",
+ providerID: "gateway",
+ api: {
+ id: "anthropic/claude-sonnet-4-6",
+ url: "https://gateway.ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
+ expect(result.high).toEqual({
+ thinking: {
+ type: "adaptive",
+ },
+ effort: "high",
+ })
+ })
+
+ test("configured google aliases route by the API ID", () => {
+ const model = createMockModel({
+ id: "my-gemini",
+ providerID: "gateway",
+ api: {
+ id: "google/gemini-2.5-pro",
+ url: "https://gateway.ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+ expect(ProviderTransform.variants(model)).toEqual({
+ high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } },
+ max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } },
+ })
+ })
+
test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
const model = createMockModel({
id: "anthropic/claude-sonnet-4-6",
diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts
index 15e2251b75..33c2957372 100644
--- a/packages/opencode/test/session/llm.test.ts
+++ b/packages/opencode/test/session/llm.test.ts
@@ -1095,7 +1095,7 @@ describe("session.llm.stream", () => {
const agent = {
name: "test",
mode: "primary",
- options: {},
+ options: { reasoningMode: "pro" },
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0.2,
} satisfies Agent.Info
@@ -1126,6 +1126,7 @@ describe("session.llm.stream", () => {
expect(body.model).toBe(resolved.api.id)
expect(body.stream).toBe(true)
expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
+ expect((body.reasoning as { mode?: string } | undefined)?.mode).toBe("pro")
const maxTokens = body.max_output_tokens as number | undefined
expect(maxTokens).toBe(undefined) // match codex cli behavior
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 65b6bcb9d6..ab722b4527 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.17.18",
+ "version": "1.17.19",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 81e184968f..555d4dd70e 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.17.18",
+ "version": "1.17.19",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/server/package.json b/packages/server/package.json
index 17507ea5b4..8e6a76b126 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
- "version": "1.17.18",
+ "version": "1.17.19",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index cf3be61f36..d32280dce6 100644
--- a/packages/session-ui/package.json
+++ b/packages/session-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
- "version": "1.17.18",
+ "version": "1.17.19",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts
index 7bdf007631..3882be027e 100644
--- a/packages/session-ui/src/components/message-file.test.ts
+++ b/packages/session-ui/src/components/message-file.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FilePart } from "@opencode-ai/sdk/v2"
-import { attached, inline, kind } from "./message-file"
+import { attached, inline, kind, typeLabel } from "./message-file"
function file(part: Partial = {}): FilePart {
return {
@@ -52,4 +52,14 @@ describe("message-file", () => {
expect(kind(file({ mime: "image/png" }))).toBe("image")
expect(kind(file({ mime: "application/pdf" }))).toBe("file")
})
+
+ test("labels attachment types from the basename extension", () => {
+ expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
+ expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
+ expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
+ expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
+ expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
+ expect(typeLabel(".gitignore", "text/plain")).toBe("File")
+ expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
+ })
})
diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts
index ecc7456902..81ce97827f 100644
--- a/packages/session-ui/src/components/message-file.ts
+++ b/packages/session-ui/src/components/message-file.ts
@@ -1,3 +1,5 @@
+import { bundledLanguagesInfo } from "shiki"
+import { getFilename } from "@opencode-ai/core/util/path"
import type { FilePart } from "@opencode-ai/sdk/v2"
export function attached(part: FilePart) {
@@ -12,3 +14,22 @@ export function inline(part: FilePart) {
export function kind(part: FilePart) {
return part.mime.startsWith("image/") ? "image" : "file"
}
+
+// language metadata only; grammars stay behind shiki's lazy imports
+const LANGUAGE_NAMES = new Map(
+ bundledLanguagesInfo.flatMap((info) =>
+ [info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]),
+ ),
+)
+
+// attachments carry text/plain for all text files, so the label comes from the extension;
+// filename may be an absolute path, so extract the basename before looking for one
+export function typeLabel(filename: string, mime: string) {
+ if (mime === "application/pdf") return "PDF"
+ const base = getFilename(filename)
+ // idx 0 is a dotfile like .gitignore, not an extension
+ const idx = base.lastIndexOf(".")
+ const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
+ if (!suffix) return "File"
+ return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
+}
diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css
index b50f0902d0..d88961aae0 100644
--- a/packages/session-ui/src/components/message-part.css
+++ b/packages/session-ui/src/components/message-part.css
@@ -57,8 +57,20 @@
}
&[data-type="image"] {
- width: 48px;
- height: 48px;
+ position: relative;
+ width: 58px;
+ height: 46px;
+ border: none;
+
+ /* inset box-shadows do not paint over
content, so the hairline is an overlay */
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
+ pointer-events: none;
+ }
}
&[data-type="file"] {
@@ -1301,6 +1313,16 @@ body:not([data-new-layout]) {
&:hover {
border-color: var(--border-strong-base);
}
+
+ &[data-type="image"] {
+ width: 48px;
+ height: 48px;
+ border: 1px solid var(--border-weak-base);
+
+ &::after {
+ content: none;
+ }
+ }
}
[data-slot="user-message-attachment-name"] {
diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx
index b16b0344f3..717e304678 100644
--- a/packages/session-ui/src/components/message-part.tsx
+++ b/packages/session-ui/src/components/message-part.tsx
@@ -45,6 +45,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import { AttachmentCardV2 } from "../v2/components/attachment-card-v2"
+import { CommentCardV2 } from "../v2/components/comment-card-v2"
import { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -58,7 +60,7 @@ import { ToolStatusTitle } from "./tool-status-title"
import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
-import { attached, inline, kind } from "./message-file"
+import { attached, inline, kind, typeLabel } from "./message-file"
import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
@@ -165,6 +167,7 @@ export interface MessageProps {
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
useV2Actions?: boolean
+ comments?: UserMessageComment[]
}
export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void
@@ -172,6 +175,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) =>
export type UserActions = {
fork?: SessionAction
revert?: SessionAction
+ openAttachment?: (file: FilePart) => void
+}
+
+export type UserMessageComment = {
+ path: string
+ comment: string
+ selection?: {
+ startLine: number
+ endLine: number
+ }
}
export interface MessagePartProps {
@@ -937,6 +950,7 @@ export function Message(props: MessageProps) {
parts={props.parts}
actions={props.actions}
useV2Actions={props.useV2Actions}
+ comments={props.comments}
/>
)}
@@ -1162,6 +1176,7 @@ export function UserMessageDisplay(props: {
parts: PartType[]
actions?: UserActions
useV2Actions?: boolean
+ comments?: UserMessageComment[]
}) {
const data = useData()
const dialog = useDialog()
@@ -1183,6 +1198,8 @@ export function UserMessageDisplay(props: {
const attachments = createMemo(() => files().filter(attached))
+ const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
+
const inlineFiles = createMemo(() => files().filter(inline))
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
@@ -1239,35 +1256,59 @@ export function UserMessageDisplay(props: {
return (
-
0}>
+ 0 || messageComments().length > 0}>
+
+ {(comment) => (
+
+ )}
+
{(file) => {
const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return (
- {
- if (type === "image") openImagePreview(file.url, name)
- }}
+ {
+ if (type === "image") openImagePreview(file.url, name)
+ }}
+ >
+
+
+ {name}
+
+ }
+ >
+
+
+
+ }
>
-
-
- {name}
-
- }
+ props.actions?.openAttachment?.(file)}
>
-
-
-
+ {typeLabel(name, file.mime)}
+
+
)
}}
diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.css b/packages/session-ui/src/v2/components/attachment-card-v2.css
new file mode 100644
index 0000000000..1a19dc7b5e
--- /dev/null
+++ b/packages/session-ui/src/v2/components/attachment-card-v2.css
@@ -0,0 +1,58 @@
+[data-component="attachment-card-v2"] {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ box-sizing: border-box;
+ width: 160px;
+ min-width: 160px;
+ max-width: 160px;
+ padding: 8px;
+ border-radius: 6px;
+ background: var(--v2-overlay-simple-overlay-hover);
+ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
+ cursor: default;
+
+ &[data-active] {
+ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
+ }
+
+ &[data-clickable] {
+ cursor: pointer;
+ }
+
+ [data-slot="attachment-card-v2-title"],
+ [data-slot="attachment-card-v2-subtitle"] {
+ max-width: 100%;
+ font-family: var(--v2-font-family-sans, "Inter", sans-serif);
+ font-style: normal;
+ font-size: 11px;
+ line-height: 12px;
+ letter-spacing: 0.05px;
+ font-variation-settings: "slnt" 0;
+ }
+
+ [data-slot="attachment-card-v2-title"] {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 530;
+ color: var(--v2-text-text-base);
+ }
+
+ [data-slot="attachment-card-v2-subtitle"] {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ font-weight: 440;
+ color: var(--v2-text-text-muted);
+
+ [data-component="file-icon"] {
+ width: 12px;
+ height: 12px;
+ flex: none;
+ }
+ }
+}
diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.tsx b/packages/session-ui/src/v2/components/attachment-card-v2.tsx
new file mode 100644
index 0000000000..2e896383a5
--- /dev/null
+++ b/packages/session-ui/src/v2/components/attachment-card-v2.tsx
@@ -0,0 +1,26 @@
+import type { JSX } from "solid-js"
+import "./attachment-card-v2.css"
+
+/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */
+export function AttachmentCardV2(props: {
+ title: string
+ active?: boolean
+ clickable?: boolean
+ /** native title attribute */
+ hover?: string
+ onClick?: () => void
+ children: JSX.Element
+}) {
+ return (
+ props.onClick?.()}
+ >
+ {props.title}
+ {props.children}
+
+ )
+}
diff --git a/packages/session-ui/src/v2/components/comment-card-v2.tsx b/packages/session-ui/src/v2/components/comment-card-v2.tsx
new file mode 100644
index 0000000000..fa2fe08c32
--- /dev/null
+++ b/packages/session-ui/src/v2/components/comment-card-v2.tsx
@@ -0,0 +1,27 @@
+import { Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { getFilenameTruncated } from "@opencode-ai/core/util/path"
+import { AttachmentCardV2 } from "./attachment-card-v2"
+
+export function CommentCardV2(props: {
+ comment: string
+ path: string
+ selection?: { startLine: number; endLine: number }
+ active?: boolean
+ title?: string
+ onClick?: () => void
+}) {
+ return (
+
+
+
+ {getFilenameTruncated(props.path, 14)}
+
+ {(sel) =>
+ sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
+ }
+
+
+
+ )
+}
diff --git a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx
index 4e8ed86732..cd27a1ecc7 100644
--- a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx
+++ b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx
@@ -11,6 +11,7 @@ import {
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2"
+import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2"
type LineCommentControllerV2Props = {
comments: Accessor
@@ -23,6 +24,7 @@ type LineCommentControllerV2Props = {
onDelete?: (comment: T) => void
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
editSubmitLabel?: string
+ mention?: LineCommentEditorV2Mention
}
type CommentProps = {
@@ -43,6 +45,7 @@ type DraftProps = {
onSubmit: (value: string) => void
cancelLabel?: string
submitLabel?: string
+ mention?: LineCommentEditorV2Mention
}
function lineCommentElementV2(view: Accessor) {
@@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor) {
onSubmit={view().editor!.onSubmit}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
+ mention={view().editor!.mention}
/>
@@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor