mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 11:16:13 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1140d8422e | ||
|
|
9ecfb847fa | ||
|
|
588288ce87 | ||
|
|
0156d3028f | ||
|
|
5e5e305bb7 | ||
|
|
d87f987800 | ||
|
|
545b86587d | ||
|
|
7fb3fbdf27 | ||
|
|
31f946eed7 | ||
|
|
45daa7fece | ||
|
|
a5bbe636e7 | ||
|
|
2e1ce87bf3 | ||
|
|
56e8a4f7d7 | ||
|
|
b3a012cbdd | ||
|
|
51b9c726cf | ||
|
|
b4e49d5b32 | ||
|
|
2b29854589 | ||
|
|
8ce5038bd8 | ||
|
|
446510a6a9 | ||
|
|
49028b6245 | ||
|
|
d595c7e265 | ||
|
|
17cd4a8c32 | ||
|
|
9bbf7918de | ||
|
|
97f502e79a | ||
|
|
ac5e249083 | ||
|
|
140e796b21 | ||
|
|
dd02cea9e7 | ||
|
|
a8062ea314 | ||
|
|
f47684787a | ||
|
|
8168f0f0f6 | ||
|
|
8036440693 | ||
|
|
cf7503687a | ||
|
|
6f8e1dda15 | ||
|
|
4dcfd9182c | ||
|
|
d7c0db8cee | ||
|
|
184da0e42e | ||
|
|
a244d82aba | ||
|
|
34e5809059 | ||
|
|
2db96c9b7e |
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/FileBrowserSidebar"
|
||||
const projectID = "proj_file_browser_sidebar"
|
||||
const sessionID = "ses_file_browser_sidebar"
|
||||
const title = "File browser sidebar"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
||||
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
||||
// switches. Remounting resets scroll and filter state.
|
||||
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
|
||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||
await expect(sidebar).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
||||
|
||||
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(0)
|
||||
await writeProbe(page)
|
||||
|
||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
|
||||
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page
|
||||
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
||||
.evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "file-browser-sidebar",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [],
|
||||
fileList: (path) => {
|
||||
if (path) return []
|
||||
return files.map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${directory}/${name}`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
}))
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
}
|
||||
@@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await expect(sidebar).toBeVisible()
|
||||
await contextButton.click()
|
||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
await expect(sidebar).toBeHidden()
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
@@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
|
||||
await panel.getByRole("button", { name: "README.md" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
@@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||
await filter.press("Enter")
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||
|
||||
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const reviewTab = page.getByRole("tab", { name: /Review/ })
|
||||
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
|
||||
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
|
||||
@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
||||
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
@@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
await setupTimeline(page, {
|
||||
messages: [user, assistantMessage(), nextUser, nextAssistant],
|
||||
settings: { newLayoutDesigns: false },
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
|
||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
|
||||
return (
|
||||
<Dialog fit>
|
||||
<form onSubmit={model.submit} class="contents">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInputV2
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
src={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
variant={getProjectAvatarVariant(model.store.color)}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => {
|
||||
model.setIconInput(element)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<TextareaV2
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={3}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +1,32 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show } from "solid-js"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const serverSDK = () => serverCtx().sdk
|
||||
const serverSync = () => serverCtx().sync
|
||||
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
const defaultName = createMemo(() => props.project.name || folderName())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color,
|
||||
iconOverride: props.project.icon?.override,
|
||||
startup: props.project.commands?.start ?? "",
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
|
||||
let iconInput: HTMLInputElement | undefined
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setStore("iconOverride", e.target?.result as string)
|
||||
setStore("iconHover", false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setStore("dragOver", false)
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setStore("dragOver", true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setStore("dragOver", false)
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
setStore("iconOverride", "")
|
||||
}
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverSDK().client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverSync().project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (saveMutation.isPending) return
|
||||
saveMutation.mutate()
|
||||
}
|
||||
const model = createEditProjectModel(props)
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("dialog.project.edit.name")}
|
||||
placeholder={folderName()}
|
||||
value={store.name}
|
||||
onChange={(v) => setStore("name", v)}
|
||||
placeholder={model.folderName()}
|
||||
value={model.store.name}
|
||||
onChange={(v) => model.setStore("name", v)}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<div class="flex gap-3 items-start">
|
||||
<div
|
||||
class="relative"
|
||||
onMouseEnter={() => setStore("iconHover", true)}
|
||||
onMouseLeave={() => setStore("iconHover", false)}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
>
|
||||
<div
|
||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
||||
"overflow-hidden": !!store.iconOverride,
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => {
|
||||
if (store.iconOverride && store.iconHover) {
|
||||
clearIcon()
|
||||
} else {
|
||||
iconInput?.click()
|
||||
}
|
||||
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||
"overflow-hidden": !!model.store.iconOverride,
|
||||
}}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<Show
|
||||
when={getProjectAvatarSource(props.project.id, {
|
||||
color: store.color,
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: store.iconOverride,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
fallback={
|
||||
<div class="size-full flex items-center justify-center">
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(store.color)}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
{...getAvatarColors(model.store.color)}
|
||||
class="size-full text-[32px]"
|
||||
/>
|
||||
</div>
|
||||
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": store.iconHover && !store.iconOverride,
|
||||
"opacity-0": !(store.iconHover && !store.iconOverride),
|
||||
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||
}}
|
||||
>
|
||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": store.iconHover && !!store.iconOverride,
|
||||
"opacity-0": !(store.iconHover && !!store.iconOverride),
|
||||
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<input
|
||||
id="icon-upload"
|
||||
ref={(el) => {
|
||||
iconInput = el
|
||||
model.setIconInput(el)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={handleInputChange}
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!store.iconOverride}>
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||
<div class="flex gap-1.5">
|
||||
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={store.color === color}
|
||||
aria-pressed={model.store.color === color}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||
store.color === color,
|
||||
model.store.color === color,
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
store.color !== color,
|
||||
model.store.color !== color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (store.color === color && !props.project.icon?.url) return
|
||||
setStore("color", store.color === color ? undefined : color)
|
||||
if (model.store.color === color && !props.project.icon?.url) return
|
||||
model.setStore("color", model.store.color === color ? undefined : color)
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
{...getAvatarColors(color)}
|
||||
class="size-full rounded"
|
||||
/>
|
||||
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
label={language.t("dialog.project.edit.worktree.startup")}
|
||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
value={store.startup}
|
||||
onChange={(v) => setStore("startup", v)}
|
||||
value={model.store.startup}
|
||||
onChange={(v) => model.setStore("startup", v)}
|
||||
spellcheck={false}
|
||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
const defaultName = createMemo(() => props.project.name || folderName())
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color,
|
||||
iconOverride: props.project.icon?.override,
|
||||
startup: props.project.commands?.start ?? "",
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
})
|
||||
let iconInput: HTMLInputElement | undefined
|
||||
|
||||
function selectFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result
|
||||
if (typeof result !== "string") return
|
||||
setStore("iconOverride", result)
|
||||
setStore("iconHover", false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", false)
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function dragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", true)
|
||||
}
|
||||
|
||||
function dragLeave() {
|
||||
setStore("dragOver", false)
|
||||
}
|
||||
|
||||
function inputChange(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function iconClick() {
|
||||
if (store.iconOverride && store.iconHover) {
|
||||
setStore("iconOverride", "")
|
||||
return
|
||||
}
|
||||
iconInput?.click()
|
||||
}
|
||||
|
||||
const save = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverCtx().sdk.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
if (save.isPending) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
setStore,
|
||||
folderName,
|
||||
defaultName,
|
||||
save,
|
||||
submit,
|
||||
drop,
|
||||
dragOver,
|
||||
dragLeave,
|
||||
inputChange,
|
||||
iconClick,
|
||||
close() {
|
||||
dialog.close()
|
||||
},
|
||||
setIconInput(input: HTMLInputElement) {
|
||||
iconInput = input
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
describe("file tree v2 model", () => {
|
||||
test("builds sorted depth-first rows", () => {
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
test("builds a sorted tree and flattens expanded directories", () => {
|
||||
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||
|
||||
expect(model.total).toBe(8)
|
||||
@@ -18,7 +19,7 @@ describe("file tree v2 model", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("omits descendants of collapsed directories", () => {
|
||||
test("skips children of collapsed directories", () => {
|
||||
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||
|
||||
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||
@@ -28,19 +29,46 @@ describe("file tree v2 model", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes separators and duplicate paths", () => {
|
||||
test("normalizes duplicate and messy paths", () => {
|
||||
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||
const rows = flattenFileTreeV2(model, () => true)
|
||||
|
||||
expect(model.total).toBe(4)
|
||||
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||
})
|
||||
|
||||
test("supports paths deeper than the legacy recursion limit", () => {
|
||||
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
|
||||
test("handles deeply nested paths", () => {
|
||||
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
|
||||
const model = buildFileTreeV2Model([file])
|
||||
|
||||
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||
})
|
||||
})
|
||||
|
||||
describe("flattenLiveFileTreeV2", () => {
|
||||
test("flattens live children using original paths for nested lookups", () => {
|
||||
const nodes: Record<string, FileNode[]> = {
|
||||
"": [
|
||||
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
|
||||
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
|
||||
],
|
||||
src: [
|
||||
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
|
||||
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
|
||||
],
|
||||
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
|
||||
}
|
||||
|
||||
expect(
|
||||
flattenLiveFileTreeV2(
|
||||
(path) => nodes[path] ?? [],
|
||||
(path) => path === "src",
|
||||
).map((row) => [row.node.path, row.node.originalPath, row.level]),
|
||||
).toEqual([
|
||||
["src", "src", 0],
|
||||
["src/a.ts", "src/a.ts", 1],
|
||||
["src/lib", "src/lib", 1],
|
||||
["README.md", "README.md", 0],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function flattenLiveFileTreeV2(
|
||||
children: (path: string) => readonly FileNode[],
|
||||
expanded: (path: string) => boolean,
|
||||
) {
|
||||
const rows: FileTreeV2Row[] = []
|
||||
const stack = children("")
|
||||
.toReversed()
|
||||
.map((node) => ({ node: toLiveNode(node), level: 0 }))
|
||||
|
||||
while (stack.length > 0) {
|
||||
const row = stack.pop()!
|
||||
rows.push(row)
|
||||
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||
const nested = children(row.node.originalPath)
|
||||
for (let index = nested.length - 1; index >= 0; index--) {
|
||||
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function toLiveNode(node: FileNode): FileTreeV2Node {
|
||||
return {
|
||||
...node,
|
||||
path: normalizeFileTreeV2Path(node.path),
|
||||
originalPath: node.path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
import {
|
||||
buildFileTreeV2Model,
|
||||
flattenFileTreeV2,
|
||||
flattenLiveFileTreeV2,
|
||||
normalizeFileTreeV2Path,
|
||||
type FileTreeV2Node,
|
||||
} from "@/components/file-tree-v2-model"
|
||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
|
||||
export const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return ""
|
||||
return "M"
|
||||
}
|
||||
|
||||
export const kindChange = (kind: Kind) => {
|
||||
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => local.kinds?.get(local.node.path)
|
||||
const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
|
||||
function GuideLines(props: { level: number }) {
|
||||
return (
|
||||
<For each={Array.from({ length: props.level })}>
|
||||
{(_, index) => (
|
||||
<div
|
||||
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
|
||||
style={`left: ${guideLineLeft(index())}px`}
|
||||
/>
|
||||
)}
|
||||
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
onFileDoubleClick?: (file: FileNode) => void
|
||||
}) {
|
||||
const file = useFile()
|
||||
const live = () => props.allowed === undefined
|
||||
const draggable = () => props.draggable ?? true
|
||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
|
||||
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
|
||||
const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
|
||||
const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
|
||||
const rows = createMemo(() => {
|
||||
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
|
||||
return flattenFileTreeV2(model()!, expanded)
|
||||
})
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
|
||||
return [...indexes, index].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!live()) return
|
||||
void file.tree.list("")
|
||||
})
|
||||
|
||||
// Only scroll when the active path changes (or first appears in the tree).
|
||||
// Do not re-scroll when expand/collapse reshuffles `rows()`.
|
||||
let scrolledActive: string | undefined
|
||||
createEffect(() => {
|
||||
const path = active()
|
||||
if (!path) return
|
||||
if (!path) {
|
||||
scrolledActive = undefined
|
||||
return
|
||||
}
|
||||
const index = rows().findIndex((row) => row.node.path === path)
|
||||
if (index < 0) return
|
||||
if (scrolledActive === path) return
|
||||
scrolledActive = path
|
||||
queueMicrotask(() => {
|
||||
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(index, { align: "auto" })
|
||||
const next = rows().findIndex((row) => row.node.path === path)
|
||||
if (next < 0) return
|
||||
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(next, { align: "auto" })
|
||||
})
|
||||
})
|
||||
|
||||
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
|
||||
action?.({
|
||||
...node,
|
||||
path: node.originalPath,
|
||||
absolute: node.originalPath,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDirectory = (path: string, originalPath: string) => {
|
||||
if (expanded(path)) {
|
||||
file.tree.collapse(originalPath)
|
||||
return
|
||||
}
|
||||
file.tree.expand(originalPath, live() ? undefined : { list: false })
|
||||
}
|
||||
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
|
||||
<div
|
||||
ref={setRoot}
|
||||
data-component="file-tree-v2"
|
||||
data-total-rows={model().total}
|
||||
data-total-rows={live() ? rows().length : model()!.total}
|
||||
class="group/file-tree-v2"
|
||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
@@ -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)}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<Show when={row().level > 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)}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
|
||||
data-expanded={expanded(row().node.path) ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
|
||||
@@ -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<PromptInputProps> = (props) => {
|
||||
)}
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
items={contextItems().filter((item) => !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<PromptInputProps> = (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<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
@@ -1645,6 +1647,17 @@ export const PromptInput: Component<PromptInputProps> = (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)
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="relative min-h-[52px]"
|
||||
@@ -1852,6 +1865,7 @@ export const PromptInput: Component<PromptInputProps> = (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<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
@@ -1861,6 +1875,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
@@ -2341,7 +2356,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="truncate leading-4">{props.state.modelName}</span>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
|
||||
@@ -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<ContextItemsProps> = (props) => {
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<TooltipV2
|
||||
<Dynamic
|
||||
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
<span
|
||||
classList={{
|
||||
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
|
||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
||||
"text-text-invert-base": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{directory}
|
||||
</span>
|
||||
<span class="shrink-0">{filename}</span>
|
||||
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</Dynamic>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <img> 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<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<div class="relative group">
|
||||
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
|
||||
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
|
||||
<div
|
||||
data-slot="prompt-attachments-scroll"
|
||||
classList={{
|
||||
"flex gap-2": true,
|
||||
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
|
||||
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
<Show when={props.newLayoutDesigns}>
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
selection={item.selection}
|
||||
active={props.commentActive?.(item)}
|
||||
onClick={() => props.onOpenComment?.(item)}
|
||||
/>
|
||||
</TooltipV2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemoveComment?.(item)}
|
||||
class={removeClassV2}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => {
|
||||
const image = attachment.mime.startsWith("image/")
|
||||
const media = () => (
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
when={image}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
<Show
|
||||
when={props.newLayoutDesigns}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AttachmentCardV2 title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime)}
|
||||
</AttachmentCardV2>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class={imageClass}
|
||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class={removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
)
|
||||
const name = () => (
|
||||
<div class={nameClass}>
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
const remove = () => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
|
||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
|
||||
return (
|
||||
<Show
|
||||
when={props.newLayoutDesigns}
|
||||
fallback={
|
||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||
<div class="relative group">
|
||||
{media()}
|
||||
{name()}
|
||||
{remove()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
|
||||
{media()}
|
||||
<Show when={image}>
|
||||
<div class={imageHairlineClassV2} />
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
{remove()}
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={props.newLayoutDesigns}>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-left"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-right"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={branch()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<Show when={props.directory() && state.canOpen()}>
|
||||
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
|
||||
class="flex items-center"
|
||||
>
|
||||
<SplitButtonV2Action
|
||||
onPointerDown={(event) => 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 })}
|
||||
>
|
||||
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
|
||||
<Spinner class="size-3.5" />
|
||||
</Show>
|
||||
</SplitButtonV2Action>
|
||||
</TooltipV2>
|
||||
<MenuV2
|
||||
gutter={4}
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
open={state.menu.open}
|
||||
onOpenChange={(open) => state.setMenu("open", open)}
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={SplitButtonV2MenuTrigger}
|
||||
disabled={state.opening()}
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconV2 name="chevron-down" size="small" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="open-in-app-v2-menu">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup
|
||||
value={state.current().id}
|
||||
onChange={(value) => {
|
||||
state.selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={state.options()}>
|
||||
{(option) => (
|
||||
<MenuV2.RadioItem
|
||||
value={option.id}
|
||||
disabled={state.opening()}
|
||||
onSelect={() => {
|
||||
state.selectApp(option.id)
|
||||
state.setMenu("open", false)
|
||||
state.openDir(option.id)
|
||||
}}
|
||||
>
|
||||
<AppIcon id={option.icon} />
|
||||
{option.label}
|
||||
</MenuV2.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Group>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item
|
||||
onSelect={() => {
|
||||
state.setMenu("open", false)
|
||||
state.copyPath()
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</SplitButtonV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof usePlatform>): 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<typeof useLanguage>, 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<Partial<Record<OpenApp, boolean>>>({
|
||||
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<Record<OpenApp, boolean>>)
|
||||
|
||||
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<Record<OpenApp, boolean>>)
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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 <FileVisual path={value} temporary={props.temporary} />
|
||||
})
|
||||
return (
|
||||
<div ref={sortable.ref} class="h-full flex items-center">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{language.t("settings.general.row.newInterface.title")}
|
||||
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
@@ -335,23 +380,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -718,6 +746,14 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
|
||||
},
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<LayoutTransitionToggle
|
||||
title={language.t("settings.general.row.newInterface.title")}
|
||||
badge={language.t("settings.general.row.newInterface.badge")}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<LayoutRetirementNotice
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||
/>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @ts-nocheck
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
|
||||
const copy = {
|
||||
title: "New layout",
|
||||
badge: "New",
|
||||
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
noticeTitle: "You're now using new layout",
|
||||
noticeDescription: "The previous layout is no longer available",
|
||||
dismiss: "Dismiss",
|
||||
}
|
||||
|
||||
function Frame(props) {
|
||||
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||
}
|
||||
|
||||
function ToggleExample(props) {
|
||||
const [state, setState] = createStore({ checked: props.checked })
|
||||
return (
|
||||
<Frame>
|
||||
<LayoutTransitionToggle
|
||||
title={copy.title}
|
||||
badge={copy.badge}
|
||||
description={copy.description}
|
||||
checked={state.checked}
|
||||
onChange={(checked) => setState("checked", checked)}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function NoticeExample() {
|
||||
const [state, setState] = createStore({ dismissed: false })
|
||||
return (
|
||||
<Frame>
|
||||
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||
<LayoutRetirementNotice
|
||||
title={copy.noticeTitle}
|
||||
description={copy.noticeDescription}
|
||||
dismiss={copy.dismiss}
|
||||
onDismiss={() => setState("dismissed", true)}
|
||||
/>
|
||||
</Show>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Settings/Layout transition",
|
||||
id: "app-settings-layout-transition",
|
||||
component: LayoutTransitionToggle,
|
||||
}
|
||||
|
||||
export const NewLayoutEnabled = {
|
||||
render: () => <ToggleExample checked />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutEnabled = {
|
||||
render: () => <ToggleExample checked={false} />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutRetired = {
|
||||
render: () => <NoticeExample />,
|
||||
}
|
||||
|
||||
export const AllStates = {
|
||||
render: () => (
|
||||
<div class="flex flex-col gap-8">
|
||||
<ToggleExample checked />
|
||||
<ToggleExample checked={false} />
|
||||
<NoticeExample />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
|
||||
export function LayoutTransitionToggle(props: {
|
||||
title: string
|
||||
badge: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<div class="settings-v2-interface-feature">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{props.title}
|
||||
<Tag variant="accent">{props.badge}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={props.description}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch checked={props.checked} onChange={props.onChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutRetirementNotice(props: {
|
||||
title: string
|
||||
description: string
|
||||
dismiss: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||
{props.dismiss}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -90,6 +90,11 @@
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
.settings-v2-interface-feature [data-component="settings-v2-list"] {
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -128,7 +133,7 @@
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ type PlatformBase = {
|
||||
/** Open a local path in a local app (desktop only) */
|
||||
openPath?(path: string, app?: string): Promise<void>
|
||||
|
||||
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
|
||||
revealPath?(path: string): Promise<boolean>
|
||||
|
||||
/** Restart the app */
|
||||
restart(): Promise<void>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
createPromptSession,
|
||||
createPromptState,
|
||||
DEFAULT_PROMPT,
|
||||
isCommentItem,
|
||||
isPromptEqual,
|
||||
} from "./prompt-state"
|
||||
export type {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
test("blank profiles default to the new layout", () => {
|
||||
expect(newLayoutDesignsDefault).toBe(true)
|
||||
})
|
||||
|
||||
test("hides the transition until a sunset is scheduled", () => {
|
||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||
})
|
||||
|
||||
test("existing profiles can switch before sunset", () => {
|
||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||
})
|
||||
|
||||
test("preserves explicit and default layout preferences", () => {
|
||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||
})
|
||||
|
||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -52,7 +54,28 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
export const newLayoutDesignsDefault = true
|
||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
available: scheduled && eligible && !retired,
|
||||
notice: scheduled && eligible && retired && !dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
export const maximumSunsetTimeout = 2_147_483_647
|
||||
|
||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||
}
|
||||
|
||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||
if (retired) return true
|
||||
return preference ?? fallback
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -160,9 +183,50 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
)
|
||||
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
||||
const sunset = oldInterfaceSunset
|
||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||
const layoutTransitionClassified = createMemo(
|
||||
() => typeof store.general?.layoutTransitionEligible === "boolean",
|
||||
)
|
||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
|
||||
}
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||
)
|
||||
})
|
||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||
|
||||
if (sunset && !oldInterfaceRetired()) {
|
||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const checkSunset = () => {
|
||||
if (Date.now() >= sunset.getTime()) {
|
||||
setOldInterfaceRetired(true)
|
||||
return
|
||||
}
|
||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||
}
|
||||
checkSunset()
|
||||
onCleanup(() => {
|
||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
@@ -250,7 +314,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
const current = store.general?.layoutTransitionEligible
|
||||
if (typeof current === "boolean") return
|
||||
setStore("general", "layoutTransitionEligible", eligible)
|
||||
},
|
||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
|
||||
@@ -735,9 +735,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.newLayoutDesigns.title": "التخطيط والتصاميم الجديدة",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"تمكين التخطيط والصفحة الرئيسية ومحرر الرسائل وواجهة الجلسة المعاد تصميمها",
|
||||
"settings.general.row.newInterface.title": "التخطيط الجديد",
|
||||
"settings.general.row.newInterface.badge": "جديد",
|
||||
"settings.general.row.newInterface.description":
|
||||
"استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.",
|
||||
"settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد",
|
||||
"settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "رفض",
|
||||
"settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص",
|
||||
"settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
|
||||
@@ -746,9 +746,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.newLayoutDesigns.title": "Novo layout e design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Ativar o layout, a página inicial, a área de composição e a interface de sessão reformulados",
|
||||
"settings.general.row.newInterface.title": "Novo layout",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Zoom com gesto de pinça",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom",
|
||||
|
||||
@@ -811,9 +811,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.newLayoutDesigns.title": "Novi raspored i dizajn",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Omogući redizajnirani raspored, početnu stranicu, uređivač poruke i interfejs sesije",
|
||||
"settings.general.row.newInterface.title": "Novi raspored",
|
||||
"settings.general.row.newInterface.badge": "Novo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored",
|
||||
"settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Odbaci",
|
||||
"settings.general.row.pinchZoom.title": "Zumiranje štipanjem",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja",
|
||||
|
||||
@@ -804,9 +804,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nyt layout og design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Aktivér det nydesignede layout, startsiden, promptfeltet og sessionsbrugerfladen",
|
||||
"settings.general.row.newInterface.title": "Nyt layout",
|
||||
"settings.general.row.newInterface.badge": "Ny",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Afvis",
|
||||
"settings.general.row.pinchZoom.title": "Knib for at zoome",
|
||||
"settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome",
|
||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||
|
||||
@@ -759,9 +759,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.newLayoutDesigns.title": "Neues Layout und Design",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Neu gestaltetes Layout, Startseite, Eingabebereich und Sitzungsoberfläche aktivieren",
|
||||
"settings.general.row.newInterface.title": "Neues Layout",
|
||||
"settings.general.row.newInterface.badge": "Neu",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Verwerfen",
|
||||
"settings.general.row.pinchZoom.title": "Zoom per Fingergeste",
|
||||
"settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
|
||||
@@ -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...",
|
||||
@@ -855,9 +855,8 @@ export const dict = {
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
"settings.general.row.shell.title": "Terminal Shell",
|
||||
"settings.general.row.shell.description":
|
||||
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
|
||||
"settings.general.row.shell.title": "Terminal shell",
|
||||
"settings.general.row.shell.description": "Shell used by the terminal and agent tools",
|
||||
"settings.general.row.shell.autoDefault": "Auto (Default)",
|
||||
"settings.general.row.shell.terminalOnly": "terminal only",
|
||||
"settings.general.row.appearance.title": "Appearance",
|
||||
@@ -889,8 +888,9 @@ export const dict = {
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -899,8 +899,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.newInterface.title": "New layout",
|
||||
"settings.general.row.newInterface.badge": "New",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
"settings.general.row.newInterfaceNotice.title": "You're now using new layout",
|
||||
"settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Dismiss",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
|
||||
@@ -816,9 +816,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nuevo diseño y estilos",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Activar el nuevo diseño de la interfaz, la página de inicio, el editor y las sesiones",
|
||||
"settings.general.row.newInterface.title": "Nuevo diseño",
|
||||
"settings.general.row.newInterface.badge": "Nuevo",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño",
|
||||
"settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Descartar",
|
||||
"settings.general.row.pinchZoom.title": "Pellizcar para ampliar",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll",
|
||||
|
||||
@@ -756,9 +756,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nouvelle mise en page et nouveaux designs",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Activer la nouvelle mise en page et les nouvelles interfaces d'accueil, de saisie et de session",
|
||||
"settings.general.row.newInterface.title": "Nouvelle mise en page",
|
||||
"settings.general.row.newInterface.badge": "Nouveau",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page",
|
||||
"settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Ignorer",
|
||||
"settings.general.row.pinchZoom.title": "Pincer pour zoomer",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer",
|
||||
|
||||
@@ -741,9 +741,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.newLayoutDesigns.title": "新しいレイアウトとデザイン",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"刷新されたレイアウト、ホーム、コンポーザー、セッションUIを有効にします",
|
||||
"settings.general.row.newInterface.title": "新しいレイアウト",
|
||||
"settings.general.row.newInterface.badge": "新機能",
|
||||
"settings.general.row.newInterface.description":
|
||||
"新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。",
|
||||
"settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています",
|
||||
"settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "閉じる",
|
||||
"settings.general.row.pinchZoom.title": "ピンチでズーム",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します",
|
||||
|
||||
@@ -999,8 +999,13 @@ export const dict = {
|
||||
"settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치",
|
||||
"settings.general.row.showCustomAgents.title": "사용자 지정 에이전트",
|
||||
"settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시",
|
||||
"settings.general.row.newLayoutDesigns.title": "새 레이아웃 및 디자인",
|
||||
"settings.general.row.newLayoutDesigns.description": "새롭게 디자인된 레이아웃, 홈, 입력창 및 세션 UI 활성화",
|
||||
"settings.general.row.newInterface.title": "새 레이아웃",
|
||||
"settings.general.row.newInterface.badge": "신규",
|
||||
"settings.general.row.newInterface.description":
|
||||
"새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.",
|
||||
"settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다",
|
||||
"settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "닫기",
|
||||
"settings.general.row.pinchZoom.title": "핀치 줌",
|
||||
"settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용",
|
||||
"settings.updates.action.downloading": "다운로드 중...",
|
||||
|
||||
@@ -1095,9 +1095,13 @@ export const dict = {
|
||||
"Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen",
|
||||
"settings.general.row.showCustomAgents.title": "Egendefinerte agenter",
|
||||
"settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet",
|
||||
"settings.general.row.newLayoutDesigns.title": "Ny layout og utforming",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Aktiver ny layout og utforming for startsiden, skrivefeltet og sesjonsgrensesnittet",
|
||||
"settings.general.row.newInterface.title": "Nytt oppsett",
|
||||
"settings.general.row.newInterface.badge": "Ny",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Bruk de nye fanene og startsidens oppsett. I en begrenset periode kan du bytte mellom oppsettene.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Du bruker nå det nye oppsettet",
|
||||
"settings.general.row.newInterfaceNotice.description": "Det forrige oppsettet er ikke lenger tilgjengelig",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Avvis",
|
||||
"settings.general.row.pinchZoom.title": "Knip for å zoome",
|
||||
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
|
||||
"settings.updates.action.downloading": "Laster ned...",
|
||||
|
||||
@@ -746,9 +746,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
||||
"settings.general.row.newLayoutDesigns.title": "Nowy układ i wygląd",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Włącz przeprojektowany układ, stronę główną, edytor wiadomości i interfejs sesji",
|
||||
"settings.general.row.newInterface.title": "Nowy układ",
|
||||
"settings.general.row.newInterface.badge": "Nowość",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Używaj nowych kart i układu strony głównej. Przez ograniczony czas możesz przełączać się między układami.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Korzystasz teraz z nowego układu",
|
||||
"settings.general.row.newInterfaceNotice.description": "Poprzedni układ nie jest już dostępny",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Odrzuć",
|
||||
"settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl",
|
||||
|
||||
@@ -812,9 +812,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.newLayoutDesigns.title": "Новая компоновка и оформление",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Включить обновлённую компоновку, главную страницу, редактор запросов и интерфейс сессии",
|
||||
"settings.general.row.newInterface.title": "Новая компоновка",
|
||||
"settings.general.row.newInterface.badge": "Новое",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Используйте новые вкладки и компоновку главной страницы. В течение ограниченного времени можно переключаться между вариантами компоновки.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Теперь вы используете новую компоновку",
|
||||
"settings.general.row.newInterfaceNotice.description": "Прежняя компоновка больше недоступна",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Закрыть",
|
||||
"settings.general.row.pinchZoom.title": "Масштабирование щипком",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl",
|
||||
|
||||
@@ -801,9 +801,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.newLayoutDesigns.title": "เลย์เอาต์และดีไซน์ใหม่",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"เปิดใช้เลย์เอาต์ หน้าแรก ช่องเขียนข้อความ และ UI เซสชันที่ออกแบบใหม่",
|
||||
"settings.general.row.newInterface.title": "เลย์เอาต์ใหม่",
|
||||
"settings.general.row.newInterface.badge": "ใหม่",
|
||||
"settings.general.row.newInterface.description":
|
||||
"ใช้แท็บใหม่และเลย์เอาต์หน้าแรก คุณสามารถสลับระหว่างเลย์เอาต์ได้ในช่วงเวลาจำกัด",
|
||||
"settings.general.row.newInterfaceNotice.title": "ขณะนี้คุณกำลังใช้เลย์เอาต์ใหม่",
|
||||
"settings.general.row.newInterfaceNotice.description": "เลย์เอาต์ก่อนหน้าไม่พร้อมใช้งานอีกต่อไป",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "ปิด",
|
||||
"settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม",
|
||||
"settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม",
|
||||
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
||||
|
||||
@@ -816,9 +816,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
"settings.general.row.newLayoutDesigns.title": "Yeni düzen ve tasarımlar",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Yeniden tasarlanan düzeni, ana sayfayı, yazma alanını ve oturum arayüzünü etkinleştir",
|
||||
"settings.general.row.newInterface.title": "Yeni düzen",
|
||||
"settings.general.row.newInterface.badge": "Yeni",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Yeni sekmeleri ve ana sayfa düzenini kullanın. Sınırlı bir süre boyunca düzenler arasında geçiş yapabilirsiniz.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Artık yeni düzeni kullanıyorsunuz",
|
||||
"settings.general.row.newInterfaceNotice.description": "Önceki düzen artık kullanılamıyor",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Kapat",
|
||||
"settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver",
|
||||
|
||||
@@ -902,9 +902,13 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
|
||||
"settings.general.row.newLayoutDesigns.title": "Новий макет і дизайн",
|
||||
"settings.general.row.newLayoutDesigns.description":
|
||||
"Увімкнути оновлений макет та інтерфейси головної сторінки, редактора запитів і сесії",
|
||||
"settings.general.row.newInterface.title": "Новий макет",
|
||||
"settings.general.row.newInterface.badge": "Нове",
|
||||
"settings.general.row.newInterface.description":
|
||||
"Використовуйте нові вкладки та макет головної сторінки. Протягом обмеженого часу можна перемикатися між макетами.",
|
||||
"settings.general.row.newInterfaceNotice.title": "Тепер ви використовуєте новий макет",
|
||||
"settings.general.row.newInterfaceNotice.description": "Попередній макет більше недоступний",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "Відхилити",
|
||||
"settings.general.row.pinchZoom.title": "Масштабування щипком",
|
||||
"settings.general.row.pinchZoom.description":
|
||||
"Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl",
|
||||
|
||||
@@ -796,8 +796,12 @@ export const dict = {
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
||||
"settings.general.row.newLayoutDesigns.title": "新版布局和设计",
|
||||
"settings.general.row.newLayoutDesigns.description": "启用重新设计的布局、主页、输入框和会话界面",
|
||||
"settings.general.row.newInterface.title": "新布局",
|
||||
"settings.general.row.newInterface.badge": "新",
|
||||
"settings.general.row.newInterface.description": "使用新的标签页和主页布局。在有限时间内可在不同布局之间切换。",
|
||||
"settings.general.row.newInterfaceNotice.title": "你现在使用的是新布局",
|
||||
"settings.general.row.newInterfaceNotice.description": "之前的布局不再可用",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
|
||||
"settings.general.row.pinchZoom.title": "双指缩放",
|
||||
"settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
|
||||
@@ -792,8 +792,12 @@ export const dict = {
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
||||
"settings.general.row.newLayoutDesigns.title": "新版面與設計",
|
||||
"settings.general.row.newLayoutDesigns.description": "啟用重新設計的版面、首頁、輸入區和工作階段介面",
|
||||
"settings.general.row.newInterface.title": "新版面",
|
||||
"settings.general.row.newInterface.badge": "新",
|
||||
"settings.general.row.newInterface.description": "使用新的分頁和首頁版面。在限定時間內可切換版面。",
|
||||
"settings.general.row.newInterfaceNotice.title": "你現在使用的是新版面",
|
||||
"settings.general.row.newInterfaceNotice.description": "先前的版面已無法使用",
|
||||
"settings.general.row.newInterfaceNotice.dismiss": "忽略",
|
||||
"settings.general.row.pinchZoom.title": "雙指縮放",
|
||||
"settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
|
||||
@@ -3,6 +3,7 @@ export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServerSync } from "./context/server-sync"
|
||||
export { useServer } from "./context/server"
|
||||
export { useSettings } from "./context/settings"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { useProviders } from "./hooks/use-providers"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => <x.DialogEditProject server={conn} project={project} />)
|
||||
void import("@/components/dialog-edit-project-v2").then((x) => {
|
||||
void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
@@ -1446,7 +1455,15 @@ function HomeSessionRow(props: {
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onMouseDown={(event) => {
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -79,6 +80,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
|
||||
@@ -1904,7 +1906,30 @@ export default function Page() {
|
||||
.map((item) => ({ 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
|
||||
@@ -2276,6 +2301,13 @@ export default function Page() {
|
||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={reviewV2State.sidebarOpened()}
|
||||
disabled={disabled}
|
||||
onToggle={reviewV2State.toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
fileBrowserState={reviewV2State}
|
||||
activeDiff={activeReviewFile()}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -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 (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<MenuV2 gutter={4}>
|
||||
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
|
||||
<LineCommentV2OverflowIcon />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ScrollPos = { x: number; y: number }
|
||||
|
||||
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||
@@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
|
||||
}
|
||||
|
||||
export function SessionFileView(props: { tab: string }) {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
|
||||
<SessionFileViewV2 tab={props.tab} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
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<SelectedLineRange | null>(() => {
|
||||
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) => (
|
||||
<FileCommentMenuV2
|
||||
moreLabel={language.t("common.moreOptions")}
|
||||
editLabel={language.t("common.edit")}
|
||||
deleteLabel={language.t("common.delete")}
|
||||
onEdit={controls.edit}
|
||||
onDelete={controls.remove}
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
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) => (
|
||||
<div class="relative overflow-hidden pb-40">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
enableGutterUtility
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
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"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const content = () => (
|
||||
<div class="mt-3 relative h-full min-h-0">
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
|
||||
return content()
|
||||
}
|
||||
|
||||
@@ -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<string, "add" | "del" | "mix">()
|
||||
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(),
|
||||
}}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={activateTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||
<Show
|
||||
when={props.fileBrowserState}
|
||||
fallback={
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={activateTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={panelTabs()}>
|
||||
{(tab) => (
|
||||
<Show
|
||||
when={tab === SESSION_OPEN_FILE_TAB}
|
||||
fallback={
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
temporary={temporaryTab() === tab}
|
||||
onTabClose={tabs().close}
|
||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 italic">
|
||||
<Icon name="open-file" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div
|
||||
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
|
||||
classList={{
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => 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(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
{props.reviewPanel()}
|
||||
</div>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "context"}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path}>
|
||||
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
}
|
||||
>
|
||||
<DndKitProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<Tabs value={activeTab()} onChange={activateTab}>
|
||||
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
tabList = el
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={props.reviewSidebarToggle}>
|
||||
{(toggle) => (
|
||||
<div class="h-full shrink-0 flex items-center justify-center">
|
||||
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||
>
|
||||
{props.hasReview()
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount() })
|
||||
: language.t("session.tab.review")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<For each={panelTabs()}>
|
||||
{(tab) => (
|
||||
<Show
|
||||
when={tab === SESSION_OPEN_FILE_TAB}
|
||||
fallback={
|
||||
<SortableTab
|
||||
<SortableTabV2
|
||||
tab={tab}
|
||||
index={() => tabs().all().indexOf(tab)}
|
||||
temporary={temporaryTab() === tab}
|
||||
onTabClose={tabs().close}
|
||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||
@@ -392,106 +637,104 @@ export function SessionSidePanel(props: {
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div
|
||||
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
|
||||
classList={{
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
<div
|
||||
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
|
||||
classList={{
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() => {
|
||||
if (props.fileBrowserState) {
|
||||
openFileBrowser()
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButtonV2
|
||||
icon={<Icon name="plus-small" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppV2 directory={projectDirectory} />
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
{props.reviewPanel()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
>
|
||||
{props.reviewPanel()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === "context"}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={browserTab()}>
|
||||
<SessionFileBrowserTab
|
||||
tab={browserTab()!}
|
||||
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
|
||||
active={file.pathFromTab(browserTab()!)}
|
||||
kinds={browserKinds()}
|
||||
state={props.fileBrowserState!}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
onSelectPermanent={(path) => openTab(file.tab(path))}
|
||||
filterRef={(element) => (fileFilter = element)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path}>
|
||||
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
||||
</Show>
|
||||
<Show when={activeTab() === "context"}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={fileBrowserMounted()}>
|
||||
<div
|
||||
id={fileBrowserTabPanelID}
|
||||
role="tabpanel"
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !fileBrowserVisible() }}
|
||||
inert={!fileBrowserVisible() || undefined}
|
||||
>
|
||||
<SessionFileBrowserTab
|
||||
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={
|
||||
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
}
|
||||
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
|
||||
kinds={kinds()}
|
||||
state={props.fileBrowserState!}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
onSelectPermanent={(path) => openTab(file.tab(path))}
|
||||
filterRef={(element) => (fileFilter = element)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs>
|
||||
</DndKitProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -519,10 +762,19 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<>
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
|
||||
@@ -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 (
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
|
||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||
actions={props.actions}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
comments={messageComments()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
|
||||
parts: (messageID: string) => Part[]
|
||||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
inlineComments: Accessor<boolean>
|
||||
}) {
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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={<DiffChanges changes={diffs()} />}
|
||||
empty={props.empty}
|
||||
sidebarOpen={props.state.sidebarOpened()}
|
||||
sidebarToggle={
|
||||
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
||||
}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
|
||||
<SessionFilePanelV2
|
||||
toolbar
|
||||
toolbarStart={
|
||||
<>
|
||||
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
||||
<Show when={!sidebarOpened()}>
|
||||
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
sidebar={
|
||||
<SessionReviewV2Sidebar
|
||||
open={sidebarOpened()}
|
||||
title={<span class="truncate">{title()}</span>}
|
||||
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}
|
||||
<SessionFilePanelV2
|
||||
toolbar={false}
|
||||
sidebar={
|
||||
<SessionReviewV2Sidebar
|
||||
open={sidebarOpened()}
|
||||
title={<span class="truncate">{title()}</span>}
|
||||
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}
|
||||
>
|
||||
<Show
|
||||
when={query()}
|
||||
fallback={
|
||||
<FileTreeV2
|
||||
active={props.active}
|
||||
kinds={props.kinds}
|
||||
onFileClick={(node) => props.onSelect(node.path)}
|
||||
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={query()}
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-1"
|
||||
active={props.active}
|
||||
kinds={props.kinds}
|
||||
onFileClick={(node) => props.onSelect(node.path)}
|
||||
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||
/>
|
||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!loading()}
|
||||
when={files().length > 0}
|
||||
fallback={
|
||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
{language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={files().length > 0}
|
||||
fallback={
|
||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionFileListV2
|
||||
id={resultsID}
|
||||
role="listbox"
|
||||
optionID={optionID}
|
||||
files={files()}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
highlighted={highlighted()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
props.onSelect(path)
|
||||
}}
|
||||
onFileDoubleClick={props.onSelectPermanent}
|
||||
/>
|
||||
</Show>
|
||||
<SessionFileListV2
|
||||
id={resultsID}
|
||||
role="listbox"
|
||||
optionID={optionID}
|
||||
files={files()}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
highlighted={highlighted()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
props.onSelect(path)
|
||||
}}
|
||||
onFileDoubleClick={props.onSelectPermanent}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</SessionReviewV2Sidebar>
|
||||
</Show>
|
||||
</SessionReviewV2Sidebar>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!props.placeholder}
|
||||
fallback={
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" />
|
||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={!props.placeholder}
|
||||
fallback={
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" />
|
||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
}
|
||||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<Show when={props.tab} keyed>
|
||||
{(tab) => <SessionFileView tab={tab} />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</SessionFilePanelV2>
|
||||
</Tabs.Content>
|
||||
<div class="min-h-0 flex-1">
|
||||
<Show when={props.tab} keyed>
|
||||
{(tab) => <SessionFileView tab={tab} />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</SessionFilePanelV2>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ export const fffLayer = Layer.effect(
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
|
||||
@@ -44,6 +44,21 @@ const Cost = Schema.Struct({
|
||||
),
|
||||
})
|
||||
|
||||
const ReasoningOption = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
values: Schema.Array(Schema.NullOr(Schema.String)),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("toggle"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("budget_tokens"),
|
||||
min: Schema.optional(Schema.Finite),
|
||||
max: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
@@ -53,6 +68,7 @@ export const Model = Schema.Struct({
|
||||
reasoning: Schema.Boolean,
|
||||
temperature: Schema.Boolean,
|
||||
tool_call: Schema.Boolean,
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(true),
|
||||
|
||||
@@ -19,7 +19,12 @@ import { forwardInitializationFailure } from "./initialization"
|
||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
|
||||
import {
|
||||
finishFirstLaunchOnboarding,
|
||||
initializeOldLayoutEligibility,
|
||||
isFirstLaunchOnboardingPending,
|
||||
isOldLayoutEligible,
|
||||
} from "./onboarding"
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
preferAppEnv,
|
||||
@@ -137,6 +142,7 @@ const main = Effect.gen(function* () {
|
||||
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
|
||||
)
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
initializeOldLayoutEligibility(app.getPath("userData"))
|
||||
logger = initLogging()
|
||||
initCrashReporter()
|
||||
|
||||
@@ -280,6 +286,7 @@ const main = Effect.gen(function* () {
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
isFirstLaunchOnboardingPending,
|
||||
finishFirstLaunchOnboarding,
|
||||
isOldLayoutEligible,
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { hasExistingAppState } from "./install-state"
|
||||
|
||||
const file = (name: string) => ({ name, isDirectory: () => false })
|
||||
const directory = (name: string) => ({ name, isDirectory: () => true })
|
||||
|
||||
describe("hasExistingAppState", () => {
|
||||
test("ignores files Electron may create on a fresh install", () => {
|
||||
expect(hasExistingAppState([])).toBe(false)
|
||||
expect(hasExistingAppState([file("Local State"), directory("Crashpad")])).toBe(false)
|
||||
})
|
||||
|
||||
test("recognizes state written by an earlier OpenCode launch", () => {
|
||||
expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
|
||||
expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
|
||||
expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
|
||||
expect(hasExistingAppState([directory("opencode")])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => boolean }>) {
|
||||
return entries.some((entry) => {
|
||||
if (entry.name === "opencode.settings") return true
|
||||
if (entry.name.endsWith(".dat")) return true
|
||||
if (/^window-state-.+\.json$/.test(entry.name)) return true
|
||||
return entry.isDirectory() && entry.name === "opencode"
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type Deps = {
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
|
||||
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
|
||||
isOldLayoutEligible: () => Promise<boolean> | boolean
|
||||
getDisplayBackend: () => Promise<string | null>
|
||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||
@@ -56,6 +57,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
|
||||
deps.finishFirstLaunchOnboarding(createDefaultProject),
|
||||
)
|
||||
ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible())
|
||||
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
||||
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
||||
deps.setDisplayBackend(backend),
|
||||
@@ -184,6 +186,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
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { app } from "electron"
|
||||
import { getStore } from "./store"
|
||||
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
|
||||
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from "./store-keys"
|
||||
import { write as writeLog } from "./logging"
|
||||
import { hasExistingAppState } from "./install-state"
|
||||
|
||||
const DEFAULT_PROJECT_DIR = "New OpenCode Project"
|
||||
|
||||
export function initializeOldLayoutEligibility(userDataPath: string) {
|
||||
const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []
|
||||
const store = getStore()
|
||||
const current = store.get(OLD_LAYOUT_ELIGIBLE_KEY)
|
||||
if (typeof current === "boolean") return current
|
||||
|
||||
const eligible = hasExistingAppState(entries)
|
||||
store.set(OLD_LAYOUT_ELIGIBLE_KEY, eligible)
|
||||
return eligible
|
||||
}
|
||||
|
||||
export function isOldLayoutEligible() {
|
||||
return getStore().get(OLD_LAYOUT_ELIGIBLE_KEY) === true
|
||||
}
|
||||
|
||||
export function isFirstLaunchOnboardingPending() {
|
||||
const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
|
||||
writeLog("onboarding", "first launch onboarding pending checked", { pending })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
|
||||
export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
|
||||
@@ -62,6 +62,7 @@ const api: ElectronAPI = {
|
||||
isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
|
||||
finishFirstLaunchOnboarding: (createDefaultProject) =>
|
||||
ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
|
||||
isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"),
|
||||
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
|
||||
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
||||
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
||||
@@ -95,6 +96,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"),
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ElectronAPI = {
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||
isFirstLaunchOnboardingPending: () => Promise<boolean>
|
||||
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
|
||||
isOldLayoutEligible: () => Promise<boolean>
|
||||
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
||||
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
||||
parseMarkdownCommand: (markdown: string) => Promise<string>
|
||||
@@ -86,6 +87,7 @@ export type ElectronAPI = {
|
||||
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||
openLink: (url: string) => void
|
||||
openPath: (path: string, app?: string) => Promise<void>
|
||||
revealPath: (path: string) => Promise<boolean>
|
||||
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
|
||||
showNotification: (title: string, body?: string) => void
|
||||
getWindowFocused: () => Promise<boolean>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useServer,
|
||||
useServerSDK,
|
||||
useServerSync,
|
||||
useSettings,
|
||||
useTabs,
|
||||
} from "@opencode-ai/app"
|
||||
import { onMount, startTransition } from "solid-js"
|
||||
@@ -13,6 +14,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
|
||||
const server = useServer()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const settings = useSettings()
|
||||
const layout = useLayout()
|
||||
const providers = useProviders()
|
||||
const tabs = useTabs()
|
||||
@@ -28,6 +30,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
|
||||
(p) => p ?? Promise.resolve(),
|
||||
),
|
||||
)
|
||||
settings.general.setOldLayoutEligible(await window.api.isOldLayoutEligible())
|
||||
if (!server.isLocal()) return
|
||||
|
||||
const pending = await window.api.isFirstLaunchOnboardingPending()
|
||||
|
||||
@@ -348,6 +348,18 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Account.remove")(function* (accountID: AccountID) {
|
||||
const active = yield* repo.active()
|
||||
yield* repo.remove(accountID)
|
||||
if (Option.isNone(active) || active.value.id !== accountID) return
|
||||
|
||||
const next = (yield* orgsByAccount()).flatMap((group) =>
|
||||
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<Service, never, AccountRepo.Service | HttpClient.HttpCl
|
||||
activeOrg,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
|
||||
@@ -285,6 +285,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
.filter(([, model]) => {
|
||||
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
|
||||
})
|
||||
@@ -303,7 +304,13 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
input: 272_000,
|
||||
output: 128_000,
|
||||
}
|
||||
: model.limit,
|
||||
: model.id.includes("gpt-5.6")
|
||||
? {
|
||||
context: 500_000,
|
||||
input: 372_000,
|
||||
output: 128_000,
|
||||
}
|
||||
: model.limit,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1247,9 +1247,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,7 +1494,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"]),
|
||||
@@ -1620,7 +1626,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -759,7 +759,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
}
|
||||
|
||||
case "@ai-sdk/gateway":
|
||||
if (model.id.includes("anthropic")) {
|
||||
if (model.api.id.includes("anthropic")) {
|
||||
if (adaptiveEfforts) {
|
||||
return Object.fromEntries(
|
||||
adaptiveEfforts.map((effort) => [
|
||||
@@ -792,8 +792,8 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
},
|
||||
}
|
||||
}
|
||||
if (model.id.includes("google")) {
|
||||
if (id.includes("2.5")) {
|
||||
if (model.api.id.includes("google")) {
|
||||
if (model.api.id.includes("2.5")) {
|
||||
return {
|
||||
high: {
|
||||
thinkingConfig: {
|
||||
@@ -804,7 +804,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
max: {
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: googleThinkingBudgetMax(id),
|
||||
thinkingBudget: googleThinkingBudgetMax(model.api.id.toLowerCase()),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1578,4 +1578,189 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
|
||||
return schema
|
||||
}
|
||||
|
||||
export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] {
|
||||
const options = model.reasoning_options
|
||||
if (options === undefined) return
|
||||
if (options.length === 0) return {}
|
||||
|
||||
const effort = options.find((option) => 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 limit = model.limit.output - 1
|
||||
if (limit <= 0) return {}
|
||||
const high = Math.min(
|
||||
max === undefined ? Math.max(min ?? 0, 16_000) : Math.min(Math.max(min ?? 0, 16_000), max),
|
||||
limit,
|
||||
)
|
||||
const maximum = max === undefined ? undefined : Math.min(max, limit)
|
||||
return Object.fromEntries(
|
||||
[
|
||||
{ id: "high", budget: high },
|
||||
...(maximum === undefined || maximum === 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"]>): Provider.Model["variants"] {
|
||||
return Object.keys(variants).length > 0 ? variants : undefined
|
||||
}
|
||||
|
||||
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
|
||||
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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -149,6 +149,30 @@ describe("plugin.codex", () => {
|
||||
await enabled.dispose?.()
|
||||
})
|
||||
|
||||
test("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: {} },
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
|
||||
|
||||
expect(models["gpt-5.4"]?.limit).toEqual(limit)
|
||||
expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
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(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
|
||||
provider.models as never,
|
||||
)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||
let auth = {
|
||||
type: "oauth" as const,
|
||||
|
||||
@@ -159,10 +159,10 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).toContain("claude-sonnet-4-6")
|
||||
expect(models.length).toBe(1)
|
||||
}),
|
||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-20250514"] } } } },
|
||||
{ config: { provider: { anthropic: { whitelist: ["claude-sonnet-4-6"] } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -301,10 +301,10 @@ it.instance("getModel returns model for valid provider/model", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-6")
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(language).toBeDefined()
|
||||
}),
|
||||
@@ -435,7 +435,7 @@ it.instance(
|
||||
"model options are merged from existing model",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
}),
|
||||
{
|
||||
@@ -443,7 +443,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: { apiKey: "test-api-key" },
|
||||
models: { "claude-sonnet-4-20250514": { options: { customOption: "custom-value" } } },
|
||||
models: { "claude-sonnet-4-6": { options: { customOption: "custom-value" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -549,7 +549,7 @@ it.instance(
|
||||
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-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.name).toBe("Custom Name for Sonnet")
|
||||
expect(model.capabilities.toolcall).toBe(true)
|
||||
expect(model.capabilities.attachment).toBe(true)
|
||||
@@ -557,7 +557,46 @@ it.instance(
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { name: "Custom Name for Sonnet" } } } },
|
||||
provider: { anthropic: { models: { "claude-sonnet-4-6": { name: "Custom Name for Sonnet" } } } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -590,16 +629,16 @@ it.instance(
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).not.toContain("claude-opus-4-20250514")
|
||||
expect(models).toContain("claude-sonnet-4-6")
|
||||
expect(models).not.toContain("claude-opus-4-6")
|
||||
expect(models.length).toBe(1)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
|
||||
blacklist: ["claude-opus-4-20250514"],
|
||||
whitelist: ["claude-sonnet-4-6", "claude-opus-4-6"],
|
||||
blacklist: ["claude-opus-4-6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -773,9 +812,9 @@ it.instance(
|
||||
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model?.providerID)).toBe("anthropic")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-6")
|
||||
}),
|
||||
{ config: { small_model: "anthropic/claude-sonnet-4-20250514" } },
|
||||
{ config: { small_model: "anthropic/claude-sonnet-4-6" } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
@@ -1094,8 +1133,8 @@ it.instance(
|
||||
it.instance("getModel returns consistent results", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
|
||||
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-6"))
|
||||
expect(model1.providerID).toEqual(model2.providerID)
|
||||
expect(model1.id).toEqual(model2.id)
|
||||
expect(model1).toEqual(model2)
|
||||
@@ -1426,6 +1465,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",
|
||||
@@ -1457,7 +1552,7 @@ it.instance("model variants are generated for reasoning models", () =>
|
||||
yield* set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = yield* list
|
||||
// Claude sonnet 4 has reasoning capability
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.capabilities.reasoning).toBe(true)
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
|
||||
@@ -1469,7 +1564,7 @@ it.instance(
|
||||
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-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
// max variant should still exist
|
||||
@@ -1479,7 +1574,7 @@ it.instance(
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: { "claude-sonnet-4-20250514": { variants: { high: { disabled: true } } } },
|
||||
models: { "claude-sonnet-4-6": { variants: { high: { disabled: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1491,7 +1586,7 @@ it.instance(
|
||||
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-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
||||
}),
|
||||
@@ -1500,7 +1595,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: { high: { thinking: { type: "enabled", budgetTokens: 20000 } } },
|
||||
},
|
||||
},
|
||||
@@ -1515,7 +1610,7 @@ it.instance(
|
||||
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-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["max"]).toBeDefined()
|
||||
expect(model.variants!["max"].disabled).toBeUndefined()
|
||||
expect(model.variants!["max"].customField).toBe("test")
|
||||
@@ -1525,7 +1620,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
"claude-sonnet-4-6": {
|
||||
variants: { max: { disabled: false, customField: "test" } },
|
||||
},
|
||||
},
|
||||
@@ -1540,7 +1635,7 @@ it.instance(
|
||||
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-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBe(0)
|
||||
}),
|
||||
@@ -1549,8 +1644,13 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
variants: { high: { disabled: true }, max: { disabled: true } },
|
||||
"claude-sonnet-4-6": {
|
||||
variants: {
|
||||
low: { disabled: true },
|
||||
medium: { disabled: true },
|
||||
high: { disabled: true },
|
||||
max: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1564,7 +1664,7 @@ it.instance(
|
||||
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-20250514"]
|
||||
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-6"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
// Should have both the generated thinking config and the custom option
|
||||
expect(model.variants!["high"].thinking).toBeDefined()
|
||||
@@ -1575,7 +1675,7 @@ it.instance(
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": { variants: { high: { extraOption: "custom-value" } } },
|
||||
"claude-sonnet-4-6": { variants: { high: { extraOption: "custom-value" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -2988,6 +2989,242 @@ 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) => {
|
||||
expect(
|
||||
ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 16_000 }]), target(npm)),
|
||||
).toEqual({ high })
|
||||
})
|
||||
|
||||
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 } },
|
||||
})
|
||||
})
|
||||
|
||||
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: 63_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: 4_999 } },
|
||||
})
|
||||
})
|
||||
|
||||
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> = {}): any => ({
|
||||
id: "test/test-model",
|
||||
@@ -3372,6 +3609,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",
|
||||
|
||||
+165931
-110538
File diff suppressed because it is too large
Load Diff
@@ -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> = {}): 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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string>(
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 <img> 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"] {
|
||||
@@ -1323,6 +1335,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"] {
|
||||
|
||||
@@ -47,6 +47,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"
|
||||
@@ -60,7 +62,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"
|
||||
|
||||
@@ -167,6 +169,7 @@ export interface MessageProps {
|
||||
showAssistantCopyPartID?: string | null
|
||||
showReasoningSummaries?: boolean
|
||||
useV2Actions?: boolean
|
||||
comments?: UserMessageComment[]
|
||||
}
|
||||
|
||||
export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
@@ -174,6 +177,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 {
|
||||
@@ -946,6 +959,7 @@ export function Message(props: MessageProps) {
|
||||
parts={props.parts}
|
||||
actions={props.actions}
|
||||
useV2Actions={props.useV2Actions}
|
||||
comments={props.comments}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
@@ -1163,6 +1177,7 @@ export function UserMessageDisplay(props: {
|
||||
parts: PartType[]
|
||||
actions?: UserActions
|
||||
useV2Actions?: boolean
|
||||
comments?: UserMessageComment[]
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
@@ -1184,6 +1199,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[]) ?? [])
|
||||
@@ -1240,35 +1257,59 @@ export function UserMessageDisplay(props: {
|
||||
|
||||
return (
|
||||
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
|
||||
<Show when={attachments().length > 0}>
|
||||
<Show when={attachments().length > 0 || messageComments().length > 0}>
|
||||
<div data-slot="user-message-attachments">
|
||||
<For each={messageComments()}>
|
||||
{(comment) => (
|
||||
<CommentCardV2
|
||||
comment={comment.comment}
|
||||
path={comment.path}
|
||||
selection={comment.selection}
|
||||
title={comment.comment}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<For each={attachments()}>
|
||||
{(file) => {
|
||||
const type = kind(file)
|
||||
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="user-message-attachment"
|
||||
data-type={type}
|
||||
data-clickable={type === "image" ? "true" : undefined}
|
||||
title={type === "file" ? name : undefined}
|
||||
onClick={() => {
|
||||
if (type === "image") openImagePreview(file.url, name)
|
||||
}}
|
||||
<Show
|
||||
when={newLayout() && type === "file"}
|
||||
fallback={
|
||||
<div
|
||||
data-slot="user-message-attachment"
|
||||
data-type={type}
|
||||
data-clickable={type === "image" ? "true" : undefined}
|
||||
title={type === "file" ? name : undefined}
|
||||
onClick={() => {
|
||||
if (type === "image") openImagePreview(file.url, name)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={type === "image"}
|
||||
fallback={
|
||||
<div data-slot="user-message-attachment-file">
|
||||
<FileIcon node={{ path: name, type: "file" }} />
|
||||
<span data-slot="user-message-attachment-name">{name}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={type === "image"}
|
||||
fallback={
|
||||
<div data-slot="user-message-attachment-file">
|
||||
<FileIcon node={{ path: name, type: "file" }} />
|
||||
<span data-slot="user-message-attachment-name">{name}</span>
|
||||
</div>
|
||||
}
|
||||
<AttachmentCardV2
|
||||
title={getFilename(name)}
|
||||
hover={name}
|
||||
clickable={!!props.actions?.openAttachment}
|
||||
onClick={() => props.actions?.openAttachment?.(file)}
|
||||
>
|
||||
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
|
||||
</Show>
|
||||
</div>
|
||||
{typeLabel(name, file.mime)}
|
||||
</AttachmentCardV2>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-component="attachment-card-v2"
|
||||
data-active={props.active ? "true" : undefined}
|
||||
data-clickable={props.clickable ? "true" : undefined}
|
||||
title={props.hover}
|
||||
onClick={() => props.onClick?.()}
|
||||
>
|
||||
<span data-slot="attachment-card-v2-title">{props.title}</span>
|
||||
<span data-slot="attachment-card-v2-subtitle">{props.children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<AttachmentCardV2 title={props.comment} active={props.active} hover={props.title} onClick={props.onClick}>
|
||||
<FileIcon node={{ path: props.path, type: "file" }} />
|
||||
<span>
|
||||
{getFilenameTruncated(props.path, 14)}
|
||||
<Show when={props.selection}>
|
||||
{(sel) =>
|
||||
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
|
||||
}
|
||||
</Show>
|
||||
</span>
|
||||
</AttachmentCardV2>
|
||||
)
|
||||
}
|
||||
@@ -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<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
@@ -23,6 +24,7 @@ type LineCommentControllerV2Props<T extends LineCommentShape> = {
|
||||
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<CommentProps>) {
|
||||
@@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
|
||||
onSubmit={view().editor!.onSubmit}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
|
||||
onSubmit={view().onSubmit}
|
||||
cancelLabel={view().cancelLabel}
|
||||
submitLabel={view().submitLabel}
|
||||
mention={view().mention}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -142,6 +147,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
|
||||
},
|
||||
cancelLabel: i18n.t("ui.lineComment.cancel"),
|
||||
submitLabel: props.editSubmitLabel,
|
||||
mention: props.mention,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -165,6 +171,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
|
||||
},
|
||||
cancelLabel: i18n.t("ui.lineComment.cancel"),
|
||||
submitLabel: i18n.t("ui.lineComment.submit"),
|
||||
mention: props.mention,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -202,6 +209,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
|
||||
}
|
||||
|
||||
return {
|
||||
note,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderGutterUtility,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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}
|
||||
<Show when={showCollapsedMeta()}>
|
||||
<div data-slot="session-review-v2-toolbar-collapsed-meta">
|
||||
<Show when={title()}>
|
||||
@@ -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={<Icon name="filetree" />}
|
||||
/>
|
||||
|
||||
@@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) {
|
||||
return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
|
||||
}
|
||||
|
||||
export function isCommentItem(item: ContextItem) {
|
||||
return !!item.comment?.trim()
|
||||
}
|
||||
|
||||
export function createPromptState() {
|
||||
const [store, setStore] = createStore({
|
||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useTuiConfig } from "../../config"
|
||||
import { usePromptWorkspace } from "./workspace"
|
||||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useLocation } from "../../context/location"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -148,6 +149,7 @@ export function Prompt(props: PromptProps) {
|
||||
const local = useLocal()
|
||||
const args = useArgs()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
const clipboard = useClipboard()
|
||||
const sdk = useSDK()
|
||||
@@ -1637,7 +1639,15 @@ export function Prompt(props: PromptProps) {
|
||||
<text fg={theme.accent}>(new working copy)</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>{props.hint ?? <text />}</Match>
|
||||
<Match when={true}>
|
||||
{props.hint ?? (
|
||||
<Show when={props.sessionID}>
|
||||
<box marginLeft={1}>
|
||||
<text fg={theme.textMuted}>{location()?.directory ?? paths.cwd}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,3 +25,9 @@
|
||||
[data-component="tag"][data-high-contrast] {
|
||||
border-color: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
[data-component="tag"][data-variant="accent"] {
|
||||
border: 0;
|
||||
background: var(--v2-background-bg-accent);
|
||||
color: var(--v2-text-text-contrast);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ Use alongside headings or lists for quick metadata.
|
||||
|
||||
### API
|
||||
- Accepts standard span props.
|
||||
- Optional: \`variant\` is \`neutral\` (default) or \`accent\`.
|
||||
- Optional: \`data-high-contrast\` attribute for stronger border contrast.
|
||||
|
||||
### Variants and states
|
||||
- Single size style.
|
||||
- Neutral and accent variants.
|
||||
- Optional high-contrast border style.
|
||||
|
||||
### Behavior
|
||||
@@ -52,3 +53,7 @@ export const HighContrast = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Accent = {
|
||||
render: () => <Tag variant="accent">New</Tag>,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
import "./badge-v2.css"
|
||||
|
||||
export interface TagProps extends ComponentProps<"span"> {}
|
||||
export interface TagProps extends ComponentProps<"span"> {
|
||||
variant?: "neutral" | "accent"
|
||||
}
|
||||
|
||||
export function Tag(props: TagProps) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children", "variant"])
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
data-component="tag"
|
||||
data-variant={split.variant ?? "neutral"}
|
||||
classList={{
|
||||
...split.classList,
|
||||
[split.class ?? ""]: !!split.class,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user