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 dcac3e7781..7ac248dc1d 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/i18n/en.ts b/packages/app/src/i18n/en.ts
index 808025cb14..3ab9f2106a 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 })
+ }}
>
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 e009177682..184fa684ec 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 { SnapshotFileDiff, 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"
@@ -53,6 +71,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
@@ -66,7 +85,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
@@ -98,11 +119,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)
@@ -159,6 +178,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
@@ -176,10 +196,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)
@@ -202,10 +220,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)
@@ -291,72 +325,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}
@@ -392,106 +637,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)}
+ />
+
+
+
+
+
@@ -519,10 +762,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/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx
index a202a1e39d..143b4f5f52 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"
@@ -66,6 +65,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.
@@ -113,9 +114,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.
@@ -127,7 +125,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/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) {
onSubmit={view().onSubmit}
cancelLabel={view().cancelLabel}
submitLabel={view().submitLabel}
+ mention={view().mention}
/>
)
@@ -142,6 +147,7 @@ export function createLineCommentControllerV2(props:
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: props.editSubmitLabel,
+ mention: props.mention,
}
: undefined
},
@@ -165,6 +171,7 @@ export function createLineCommentControllerV2(props:
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: i18n.t("ui.lineComment.submit"),
+ mention: props.mention,
}),
})
@@ -202,6 +209,7 @@ export function createLineCommentControllerV2(props:
}
return {
+ note,
annotations,
renderAnnotation,
renderGutterUtility,
diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css
index b116ebb43f..9db2ba580c 100644
--- a/packages/session-ui/src/v2/components/session-review-v2.css
+++ b/packages/session-ui/src/v2/components/session-review-v2.css
@@ -76,6 +76,11 @@
color: var(--text-strong, var(--v2-text-text-base));
}
+[data-component="session-review-v2-sidebar-root"]
+ [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) {
+ margin-left: 8px;
+}
+
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] {
@@ -97,7 +102,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
- margin-top: 2px;
+ margin-top: 3px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx
index 3c13c75d7b..6efb2b593a 100644
--- a/packages/session-ui/src/v2/components/session-review-v2.tsx
+++ b/packages/session-ui/src/v2/components/session-review-v2.tsx
@@ -26,7 +26,6 @@ export type SessionReviewV2Props = {
empty?: JSX.Element
sidebarOpen?: boolean
sidebar?: JSX.Element
- sidebarToggle?: JSX.Element
activeFile?: string
files: string[]
onSelectFile: (file: string) => void
@@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const toolbarStart = () => (
<>
- {props.sidebarToggle}
@@ -319,7 +317,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
)
}
-export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) {
+export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) {
const i18n = useI18n()
return (
@@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle:
class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened}
+ disabled={props.disabled}
onClick={props.onToggle}
icon={}
/>
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index cd26b0cd50..0978ec5ad2 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -698,3 +698,104 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm
}
}
}
+
+body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] {
+ [data-slot="tabs-list"] {
+ height: 52px;
+ padding-right: 12px;
+ gap: 8px;
+ --tabs-review-fade: 8px;
+ }
+
+ [data-slot="tabs-trigger-wrapper"] {
+ height: 28px;
+ padding-inline: 10px;
+ border: 0;
+ background-color: transparent;
+ box-shadow: none;
+
+ &::after {
+ display: none;
+ }
+
+ [data-slot="tabs-trigger"] {
+ padding: 0 !important;
+ }
+
+ &:has([data-slot="tabs-trigger-close-button"]) {
+ padding-right: 10px;
+ }
+
+ &:has([data-selected]) {
+ background-color: var(--v2-background-bg-layer-02);
+ box-shadow: none;
+ }
+ }
+
+ [data-slot="tabs-trigger"] {
+ font-family: Inter, sans-serif;
+ font-style: normal;
+ font-weight: 440;
+ font-size: 13px;
+ line-height: 16px;
+ letter-spacing: -0.04px;
+ color: var(--v2-text-text-muted);
+ font-variation-settings: "slnt" 0;
+ font-variant-numeric: tabular-nums;
+
+ &[data-selected] {
+ color: var(--v2-text-text-base);
+ }
+
+ .tab-fileicon-color,
+ .tab-fileicon-mono,
+ [data-slot="icon-svg"] {
+ margin-left: -2px;
+ }
+ }
+
+ /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */
+ [data-slot="tabs-trigger"] .italic {
+ font-style: italic !important;
+ font-variation-settings: "slnt" -10 !important;
+ }
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar {
+ width: 100%;
+ min-width: 0;
+ gap: 8px;
+ box-sizing: border-box;
+ border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
+ background-color: var(--v2-background-bg-base);
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"],
+body[data-new-layout]
+ #review-panel
+ [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"]
+ .session-review-v2-tabs-bar
+ [data-slot="tabs-list"] {
+ flex: 1 1 0;
+ min-width: 0;
+ width: auto;
+ border-bottom: none;
+}
+
+body[data-new-layout]
+ #review-panel
+ [data-component="tabs"]
+ .session-review-v2-tabs-bar
+ [data-slot="tabs-list"]
+ > .sticky {
+ padding-right: 0;
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot {
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app {
+ flex-shrink: 0;
+}
diff --git a/packages/ui/src/v2/components/file-tree-v2.css b/packages/ui/src/v2/components/file-tree-v2.css
index e4236bcccd..ba88d09228 100644
--- a/packages/ui/src/v2/components/file-tree-v2.css
+++ b/packages/ui/src/v2/components/file-tree-v2.css
@@ -14,12 +14,14 @@
justify-content: flex-start;
gap: 6px;
padding-right: 8px;
+ overflow: visible;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--v2-text-text-muted);
text-align: left;
cursor: pointer;
+ scroll-margin-block: 8px;
transition:
background-color 120ms ease,
color 120ms ease;
@@ -42,6 +44,21 @@
background-color: var(--v2-overlay-simple-overlay-pressed);
}
+[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] {
+ position: absolute;
+ top: 0;
+ margin-top: -2px;
+ height: calc(100% + 2px);
+ width: 1px;
+ pointer-events: none;
+ background-color: var(--v2-border-border-muted);
+ opacity: 0;
+}
+
+[data-component="file-tree-v2"]:hover [data-slot="file-tree-v2-guide"] {
+ opacity: 0.5;
+}
+
[data-component="file-tree-v2"] .filetree-icon--mono {
color: var(--v2-icon-icon-muted);
}
@@ -108,7 +125,7 @@
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] {
- opacity: 0;
+ color: var(--v2-state-fg-info);
}
[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="added"] {
diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx
index b0b70bf37f..da402fd4d9 100644
--- a/packages/ui/src/v2/components/icon.tsx
+++ b/packages/ui/src/v2/components/icon.tsx
@@ -129,6 +129,10 @@ const icons = {
viewBox: "0 0 16 16",
body: ``,
},
+ "outline-share": {
+ viewBox: "0 0 16 16",
+ body: ``,
+ },
reset: {
viewBox: "0 0 20 20",
body: ``,
diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css
index 31be02ae28..e46500469e 100644
--- a/packages/ui/src/v2/components/line-comment-v2.css
+++ b/packages/ui/src/v2/components/line-comment-v2.css
@@ -203,3 +203,56 @@
gap: 8px;
flex-shrink: 0;
}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-list"] {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 180px;
+ overflow: auto;
+ margin-top: 8px;
+ padding: 4px;
+ border: 0.5px solid var(--v2-border-border-muted);
+ border-radius: 6px;
+ background: var(--v2-background-bg-layer-02);
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+ padding: 6px 8px;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--v2-text-text-base);
+ text-align: left;
+ cursor: pointer;
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"][data-active] {
+ background: var(--v2-background-bg-layer-03);
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-path"] {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-dir"] {
+ min-width: 0;
+ color: var(--v2-text-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-file"] {
+ color: var(--v2-text-text-base);
+ white-space: nowrap;
+}
diff --git a/packages/ui/src/v2/components/line-comment-v2.tsx b/packages/ui/src/v2/components/line-comment-v2.tsx
index 29242fad31..ae0c000a80 100644
--- a/packages/ui/src/v2/components/line-comment-v2.tsx
+++ b/packages/ui/src/v2/components/line-comment-v2.tsx
@@ -1,4 +1,6 @@
-import { type ComponentProps, type JSX, Show, onMount, splitProps } from "solid-js"
+import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { FileIcon } from "../../components/file-icon"
+import { useFilteredList } from "../../hooks"
import { ButtonV2 } from "./button-v2"
import "./line-comment-v2.css"
@@ -53,6 +55,10 @@ export function LineCommentV2(props: LineCommentV2Props) {
)
}
+export type LineCommentEditorV2Mention = {
+ items: (query: string) => string[] | Promise
+}
+
export interface LineCommentEditorV2Props extends Omit, "children" | "onInput" | "onSubmit"> {
/** Visible field label above the textarea (default: “Comment”). */
heading?: JSX.Element | string
@@ -66,10 +72,22 @@ export interface LineCommentEditorV2Props extends Omit, "c
cancelLabel?: string
submitLabel?: string
autofocus?: boolean
+ mention?: LineCommentEditorV2Mention
+}
+
+function pathFilename(path: string) {
+ const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
+ return index === -1 ? path : path.slice(index + 1)
+}
+
+function pathDirectory(path: string) {
+ const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
+ return index === -1 ? "" : path.slice(0, index + 1)
}
export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
let textareaRef: HTMLTextAreaElement | undefined
+ const [mentionOpen, setMentionOpen] = createSignal(false)
const [local, rest] = splitProps(props, [
"heading",
@@ -83,6 +101,7 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
"cancelLabel",
"submitLabel",
"autofocus",
+ "mention",
"class",
"classList",
])
@@ -90,6 +109,78 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
const heading = () => local.heading ?? "Comment"
const canSubmit = () => local.value.trim().length > 0
+ const closeMention = () => {
+ setMentionOpen(false)
+ mention.clear()
+ }
+
+ const currentMention = () => {
+ const textarea = textareaRef
+ if (!textarea) return
+ if (!local.mention) return
+ if (textarea.selectionStart !== textarea.selectionEnd) return
+
+ const end = textarea.selectionStart
+ const match = textarea.value.slice(0, end).match(/@(\S*)$/)
+ if (!match) return
+
+ return {
+ query: match[1] ?? "",
+ start: end - match[0].length,
+ end,
+ }
+ }
+
+ function selectMention(item: { path: string } | undefined) {
+ if (!item) return
+
+ const textarea = textareaRef
+ const query = currentMention()
+ if (!textarea || !query) return
+
+ const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
+ const cursor = query.start + item.path.length + 2
+
+ local.onInput(value)
+ closeMention()
+
+ requestAnimationFrame(() => {
+ textarea.focus()
+ textarea.setSelectionRange(cursor, cursor)
+ })
+ }
+
+ const mention = useFilteredList<{ path: string }>({
+ items: async (query) => {
+ if (!local.mention) return []
+ if (!query.trim()) return []
+ const paths = await local.mention.items(query)
+ return paths.map((path) => ({ path }))
+ },
+ key: (item) => item.path,
+ filterKeys: ["path"],
+ skipFilter: () => true,
+ onSelect: selectMention,
+ })
+
+ const syncMention = () => {
+ const item = currentMention()
+ if (!item) {
+ closeMention()
+ return
+ }
+
+ setMentionOpen(true)
+ mention.onInput(item.query)
+ }
+
+ const selectActiveMention = () => {
+ const items = mention.flat()
+ if (items.length === 0) return
+ const active = mention.active()
+ selectMention(items.find((item) => item.path === active) ?? items[0])
+ }
+
const submit = () => {
const v = local.value.trim()
if (!v) return
@@ -122,9 +213,39 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
rows={local.rows ?? 3}
placeholder={local.placeholder ?? "Add context for this change"}
value={local.value}
- onInput={(e) => local.onInput(e.currentTarget.value)}
+ onInput={(e) => {
+ local.onInput(e.currentTarget.value)
+ syncMention()
+ }}
+ onClick={() => syncMention()}
+ onSelect={() => syncMention()}
onKeyDown={(e) => {
e.stopPropagation()
+ if (e.isComposing || e.keyCode === 229) return
+
+ if (mentionOpen()) {
+ if (e.key === "Escape") {
+ e.preventDefault()
+ closeMention()
+ return
+ }
+
+ if (e.key === "Tab") {
+ if (mention.flat().length === 0) return
+ e.preventDefault()
+ selectActiveMention()
+ return
+ }
+
+ const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
+ const ctrlNav = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")
+ if ((nav || ctrlNav) && mention.flat().length > 0) {
+ mention.onKeyDown(e)
+ e.preventDefault()
+ return
+ }
+ }
+
if (e.key === "Escape") {
e.preventDefault()
e.currentTarget.blur()
@@ -137,6 +258,34 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
}
}}
/>
+ 0}>
+
+
+ {(item) => {
+ const directory = item.path.endsWith("/") ? item.path : pathDirectory(item.path)
+ const name = item.path.endsWith("/") ? "" : pathFilename(item.path)
+ return (
+
+ )
+ }}
+
+
+
{local.selection}
diff --git a/packages/ui/src/v2/components/project-avatar-v2.tsx b/packages/ui/src/v2/components/project-avatar-v2.tsx
index 22da7f6b72..cbc571ab53 100644
--- a/packages/ui/src/v2/components/project-avatar-v2.tsx
+++ b/packages/ui/src/v2/components/project-avatar-v2.tsx
@@ -38,7 +38,6 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> {
export function ProjectAvatar(props: ProjectAvatarProps) {
const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"])
- const src = split.src
return (
-
+
{(value) =>
}
diff --git a/packages/ui/src/v2/components/split-button-v2.css b/packages/ui/src/v2/components/split-button-v2.css
new file mode 100644
index 0000000000..e8469f494d
--- /dev/null
+++ b/packages/ui/src/v2/components/split-button-v2.css
@@ -0,0 +1,99 @@
+[data-component="split-button-v2"] {
+ display: inline-flex;
+ align-items: stretch;
+ flex-shrink: 0;
+ height: 28px;
+ border-radius: 6px;
+ background-color: transparent;
+ box-shadow: none;
+ overflow: hidden;
+}
+
+[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) {
+ box-shadow: inset 0 0 0 1px var(--v2-border-border-muted);
+}
+
+[data-component="split-button-v2-action"],
+[data-component="split-button-v2-menu-trigger"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 28px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--v2-icon-icon-base);
+ cursor: default;
+ outline: none;
+}
+
+[data-component="split-button-v2-action"] {
+ width: 28px;
+ flex-shrink: 0;
+}
+
+[data-component="split-button-v2-action"]:hover:not(:disabled) {
+ background-color: var(--v2-overlay-simple-overlay-hover);
+}
+
+[data-component="split-button-v2-action"]:is(:active, [data-pressed]):not(:disabled) {
+ background-color: var(--v2-overlay-simple-overlay-pressed);
+}
+
+[data-component="split-button-v2-action"]:is(:focus-visible, [data-focus]) {
+ outline: 2px solid var(--v2-border-border-focus);
+ outline-offset: -2px;
+ z-index: 1;
+}
+
+[data-component="split-button-v2-action"]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+[data-component="split-button-v2-menu-trigger"] {
+ width: 20px;
+ flex-shrink: 0;
+}
+
+[data-component="split-button-v2-menu-trigger"]:is(:hover, [data-expanded]):not(:disabled) {
+ background-color: var(--v2-overlay-simple-overlay-hover);
+}
+
+[data-component="split-button-v2-menu-trigger"]:is(:active, [data-pressed]):not(:disabled) {
+ background-color: var(--v2-overlay-simple-overlay-pressed);
+}
+
+[data-component="split-button-v2-menu-trigger"]:is(:focus-visible, [data-focus]) {
+ outline: 2px solid var(--v2-border-border-focus);
+ outline-offset: -2px;
+ z-index: 1;
+}
+
+[data-component="split-button-v2-menu-trigger"]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+[data-component="split-button-v2-action"] [data-component="app-icon"] {
+ width: 16px;
+ height: 16px;
+}
+
+[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="app-icon"] {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] [data-slot="icon-svg"] {
+ width: 16px;
+ height: 16px;
+}
diff --git a/packages/ui/src/v2/components/split-button-v2.tsx b/packages/ui/src/v2/components/split-button-v2.tsx
new file mode 100644
index 0000000000..9585f8a4a7
--- /dev/null
+++ b/packages/ui/src/v2/components/split-button-v2.tsx
@@ -0,0 +1,48 @@
+import { splitProps, type ComponentProps, type ParentProps } from "solid-js"
+import "./split-button-v2.css"
+
+export function SplitButtonV2(props: ParentProps
>) {
+ const [split, rest] = splitProps(props, ["class", "classList", "children"])
+ return (
+
+ {split.children}
+
+ )
+}
+
+export function SplitButtonV2Action(props: ComponentProps<"button">) {
+ const [split, rest] = splitProps(props, ["class", "classList"])
+ return (
+
+ )
+}
+
+export function SplitButtonV2MenuTrigger(props: ComponentProps<"button">) {
+ const [split, rest] = splitProps(props, ["class", "classList"])
+ return (
+
+ )
+}