diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 393bf90518e..51465b6b180 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,29 +1,45 @@ -### Issue for this PR +## Problem -Closes # +Briefly describe what this PR fixes. What was broken, confusing, or missing? -### Type of change +For simple fixes: one sentence is enough. +For complex features: explain the problem and why it matters. -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor / code improvement -- [ ] Documentation +Closes #XXXX -### What does this PR do? +--- -Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. +## Changes -**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** +**For simple fixes**: 1-2 bullet points maximum -### How did you verify your code works? +- Fix specific issue in `ComponentName` +- Add missing feature to `ModuleName` -### Screenshots / recordings +**For complex features**: concise breakdown -_If this is a UI change, please include a screenshot or recording._ +- `file.ext`: What changed +- `file.ext`: What changed +- Add tests for coverage -### Checklist +--- -- [ ] I have tested my changes locally -- [ ] I have not included unrelated changes in this PR +## Testing -_If you do not follow this template your PR will be automatically rejected._ +```bash +# Test commands to verify this PR +bun test path/to/test.test.ts +# If applicable: run examples, manual testing steps, etc. +``` + +--- + +### Additional Context (only if needed) + +**Breaking changes**: What users need to know and migration steps + +**Performance impact**: Before/after metrics if relevant + +**Configuration**: New options or defaults + +**Design decisions**: Why this approach over alternatives diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0dbd04f8215..079e6d6f094 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -149,6 +149,10 @@ jobs: - uses: ./.github/actions/setup-bun + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Cache apt packages if: contains(matrix.settings.host, 'ubuntu') uses: actions/cache@v4 diff --git a/bun.lock b/bun.lock index 1721ba330b3..86d18bf8c6f 100644 --- a/bun.lock +++ b/bun.lock @@ -446,6 +446,22 @@ "typescript": "catalog:", }, }, + "packages/session-modal": { + "name": "@opencode-ai/session-modal", + "version": "0.0.0", + "dependencies": { + "@opencode-ai/sdk": "workspace:*", + "@tauri-apps/api": "^2", + "solid-js": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-solid": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.2.24", @@ -1419,6 +1435,8 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/session-modal": ["@opencode-ai/session-modal@workspace:packages/session-modal"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], diff --git a/package.json b/package.json index 13e931573ed..c1eee18f868 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packageManager": "bun@1.3.10", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop tauri dev", + "dev:desktop": "bun run --cwd packages/desktop predev && bun --cwd packages/desktop tauri dev", "dev:web": "bun --cwd packages/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "typecheck": "bun turbo typecheck", diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 86147dc65d5..4febb53bb3e 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -17,6 +17,7 @@ import { listItemKeyStartsWithSelector, workspaceItemSelector, workspaceMenuTriggerSelector, + workspacePinToggleSelector, } from "./selectors" export async function defocus(page: Page) { @@ -711,3 +712,16 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { await expect(menu).toBeVisible() return menu } + +export async function setWorkspacePinned(page: Page, workspaceSlug: string, enabled: boolean) { + const menu = await openWorkspaceMenu(page, workspaceSlug) + const toggle = menu.locator(workspacePinToggleSelector(workspaceSlug)).first() + await expect(toggle).toBeVisible() + const name = await toggle.textContent() + const pinned = (name ?? "").toLowerCase().includes("unpin") + if (pinned === enabled) { + await page.keyboard.press("Escape") + return + } + await toggle.click({ force: true }) +} diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 6ad64f59278..09fa18f6ec2 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -2,7 +2,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" -import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" +import { projectSwitchSelector, promptSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" async function workspaces(page: Page, directory: string, enabled: boolean) { @@ -90,19 +90,8 @@ test("switching back to a project opens the latest workspace session", async ({ const space = await resolveDirectory(dir) const next = dirSlug(space) trackDirectory(space) - await openSidebar(page) - - const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() - await expect(item).toBeVisible() - await item.hover() - - const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() - await expect(btn).toBeVisible() - await btn.click({ force: true }) - - // A new workspace can be discovered via a transient slug before the route and sidebar - // settle to the canonical workspace path on Windows, so interact with either and assert - // against the resolved workspace slug. + // A new workspace can be discovered via a transient slug before the route settles to + // the canonical workspace path on Windows. await waitSlug(page) await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aeeccb9bba9..10aaca15cff 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -8,18 +8,43 @@ import { test, expect } from "../fixtures" test.describe.configure({ mode: "serial" }) import { + createTestProject, cleanupTestProject, clickMenuItem, confirmDialog, openSidebar, openWorkspaceMenu, + setWorkspacePinned, setWorkspacesEnabled, - slugFromUrl, waitSlug, } from "../actions" -import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" +import { + dropdownMenuContentSelector, + inlineInputSelector, + projectSwitchSelector, + workspaceDividerSelector, + workspaceItemSelector, +} from "../selectors" import { createSdk, dirSlug } from "../utils" +function slugFromUrl(url: string) { + return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" +} + +async function ensureWorkspacesEnabled(page: Page, slug: string) { + for (const _ of [0, 1, 2]) { + await openSidebar(page) + await setWorkspacesEnabled(page, slug, true) + const visible = await page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) + if (visible) return + } + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible({ timeout: 60_000 }) +} async function setupWorkspaceTest(page: Page, project: { slug: string }) { const rootSlug = project.slug await openSidebar(page) @@ -279,6 +304,257 @@ test("can delete a workspace", async ({ page, withProject }) => { }) }) +test("can pin and unpin a workspace with persistence", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await withProject(async ({ slug: rootSlug }) => { + await ensureWorkspacesEnabled(page, rootSlug) + + const workspaces = [] as string[] + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + workspaces.push(slugFromUrl(page.url())) + await openSidebar(page) + } + + const a = workspaces[0] + const b = workspaces[1] + if (!a || !b) throw new Error("Expected two created workspaces") + + const key = (slug: string) => { + return base64Decode(slug) + .replace(/[\\/]+/g, "/") + .replace(/\/+$/, "") + .toLowerCase() + } + + const aKey = key(a) + const bKey = key(b) + const rootKey = key(rootSlug) + + const list = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const seen = new Set() + return slugs + .filter((slug) => { + const slugKey = key(slug) + if (seen.has(slugKey)) return false + seen.add(slugKey) + return true + }) + .filter((slug) => { + const slugKey = key(slug) + return slugKey === aKey || slugKey === bKey + }) + } + + const listAll = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const seen = new Set() + return slugs + .filter((slug) => { + const slugKey = key(slug) + if (seen.has(slugKey)) return false + seen.add(slugKey) + return true + }) + .filter((slug) => { + const slugKey = key(slug) + return slugKey === rootKey || slugKey === aKey || slugKey === bKey + }) + } + + const find = async (target: string) => { + const slugs = await listAll() + return slugs.find((slug) => key(slug) === target) + } + + await expect.poll(async () => (await list()).length).toBe(2) + const before = await list() + const aSlug = await find(aKey) + if (!aSlug) throw new Error("Missing first workspace slug") + + await setWorkspacePinned(page, aSlug, true) + await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey]) + + await setWorkspacePinned(page, rootSlug, false) + await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(aKey) + + await setWorkspacePinned(page, rootSlug, true) + await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(rootKey) + + await page.reload() + await openSidebar(page) + await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey]) + + const pinnedSlug = await find(aKey) + if (!pinnedSlug) throw new Error("Missing pinned workspace slug") + await setWorkspacePinned(page, pinnedSlug, false) + await expect.poll(async () => await list()).toEqual(before) + }) +}) + +test("workspace pinning is isolated per project", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherSlug = dirSlug(other) + const dirs = [] as string[] + const key = (slug: string) => + base64Decode(slug) + .replace(/[\\/]+/g, "/") + .replace(/\/+$/, "") + .toLowerCase() + + try { + await withProject( + async ({ slug }) => { + await ensureWorkspacesEnabled(page, slug) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const next = slugFromUrl(page.url()) + if (!next) return "" + if (next === slug) return "" + return next + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const pinnedSlug = slugFromUrl(page.url()) + dirs.push(base64Decode(pinnedSlug)) + const pinnedKey = key(pinnedSlug) + + await openSidebar(page) + await setWorkspacePinned(page, pinnedSlug, true) + + const pinnedMenu = await openWorkspaceMenu(page, pinnedSlug) + await expect( + pinnedMenu + .getByRole("menuitem") + .filter({ hasText: /^Unpin$/i }) + .first(), + ).toBeVisible() + await page.keyboard.press("Escape") + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + + await ensureWorkspacesEnabled(page, otherSlug) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const next = slugFromUrl(page.url()) + if (!next) return "" + if (next === otherSlug) return "" + return next + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const otherWorkspace = slugFromUrl(page.url()) + dirs.push(base64Decode(otherWorkspace)) + + await openSidebar(page) + const otherMenu = await openWorkspaceMenu(page, otherWorkspace) + await expect(otherMenu.getByRole("menuitem").filter({ hasText: /^Pin$/i }).first()).toBeVisible() + await page.keyboard.press("Escape") + + const rootButton = page.locator(projectSwitchSelector(slug)).first() + await expect(rootButton).toBeVisible() + await rootButton.click() + + await openSidebar(page) + const slugs = await page + .locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + .evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const rootSlug = slugs.find((slug) => key(slug) === pinnedKey) + if (!rootSlug) throw new Error("Could not find pinned workspace in original project") + + const rootMenu = await openWorkspaceMenu(page, rootSlug) + await expect( + rootMenu + .getByRole("menuitem") + .filter({ hasText: /^Unpin$/i }) + .first(), + ).toBeVisible() + }, + { extra: [other] }, + ) + } finally { + await Promise.all(dirs.map((dir) => cleanupTestProject(dir))) + await cleanupTestProject(other) + } +}) + +test("workspace divider is shown only with mixed pin state", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + await withProject(async ({ slug: rootSlug }) => { + await ensureWorkspacesEnabled(page, rootSlug) + + const workspaces = [] as string[] + try { + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + workspaces.push(slugFromUrl(page.url())) + await openSidebar(page) + } + + const a = workspaces[0] + const b = workspaces[1] + if (!a || !b) throw new Error("Expected two created workspaces") + + await setWorkspacePinned(page, rootSlug, false) + await setWorkspacePinned(page, a, true) + await setWorkspacePinned(page, b, false) + await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBeGreaterThan(0) + + await setWorkspacePinned(page, a, false) + await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBe(0) + } finally { + await Promise.all(workspaces.map((slug) => cleanupTestProject(base64Decode(slug)))) + } + }) +}) + test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async ({ slug: rootSlug }) => { diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 64b7bfe5456..90e21822d3d 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -57,9 +57,14 @@ export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} export const workspaceItemSelector = (slug: string) => `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]` +export const workspaceDividerSelector = `${sidebarNavSelector} [data-component="workspace-item"][data-workspace-divider="true"]` + export const workspaceMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` +export const workspacePinToggleSelector = (slug: string) => + `[data-action="workspace-pin-toggle"][data-workspace="${slug}"]` + export const workspaceNewSessionSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]` diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 52a1dac6a2f..f6c9eeb381d 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -61,6 +61,7 @@ declare global { updaterEnabled?: boolean deepLinks?: string[] wsl?: boolean + openSessionSearchOnStart?: boolean } } } diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ec0793c540e..1bcfca2ebdf 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -26,7 +26,7 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.override || "", + iconUrl: props.project.icon?.url || props.project.icon?.override || "", startup: props.project.commands?.start ?? "", saving: false, dragOver: false, @@ -83,9 +83,8 @@ export function DialogEditProject(props: { project: LocalProject }) { if (props.project.id && props.project.id !== "global") { await globalSDK.client.project.update({ projectID: props.project.id, - directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl }, + icon: { color: store.color, url: store.iconUrl }, commands: { start }, }) globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) diff --git a/packages/app/src/components/dialog-open-project.helpers.test.ts b/packages/app/src/components/dialog-open-project.helpers.test.ts new file mode 100644 index 00000000000..86e82ef7eda --- /dev/null +++ b/packages/app/src/components/dialog-open-project.helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import { + cloneRepositoryName, + isGitRepositoryUrl, + nextProjectOpenMode, + parseProjectInput, + projectOpenError, + resolveCloneRepositoryUrl, + suggestCloneTargetPath, +} from "./dialog-open-project.helpers" + +describe("dialog-open-project helpers", () => { + test("trims project input", () => { + expect(parseProjectInput(" ~/code/foo ")).toBe("~/code/foo") + }) + + test("validates supported git url formats", () => { + expect(isGitRepositoryUrl("https://github.com/anomalyco/opencode.git")).toBeTrue() + expect(isGitRepositoryUrl("ssh://git@github.com/anomalyco/opencode.git")).toBeTrue() + expect(isGitRepositoryUrl("git@github.com:anomalyco/opencode.git")).toBeTrue() + expect(isGitRepositoryUrl("~/code/opencode")).toBeFalse() + }) + + test("switches mode between git and path", () => { + expect(nextProjectOpenMode("git")).toBe("path") + expect(nextProjectOpenMode("path")).toBe("git") + }) + + test("normalizes unknown errors", () => { + expect(projectOpenError(new Error("boom"))).toBe("boom") + expect(projectOpenError("broken")).toBe("broken") + expect(projectOpenError(null)).toBe("Unknown error") + }) + + test("resolves clone urls for github and gitlab", () => { + expect(resolveCloneRepositoryUrl("anomalyco/opencode")).toBe("https://github.com/anomalyco/opencode.git") + expect(resolveCloneRepositoryUrl("gitlab.com/group/subgroup/project")).toBe( + "https://gitlab.com/group/subgroup/project.git", + ) + expect(resolveCloneRepositoryUrl("gitlab:group/project")).toBe("https://gitlab.com/group/project.git") + expect(resolveCloneRepositoryUrl("github:anomalyco/opencode")).toBe("https://github.com/anomalyco/opencode.git") + }) + + test("extracts repository name for clone target", () => { + expect(cloneRepositoryName("https://github.com/Infatoshi/magic.rs")).toBe("magic.rs") + expect(cloneRepositoryName("gitlab.com/group/subgroup/project")).toBe("project") + expect(cloneRepositoryName("invalid input")).toBe("") + }) + + test("suggests clone path from root and repository", () => { + expect(suggestCloneTargetPath("https://github.com/Infatoshi/magic.rs", "/Users/me/Documents/code")).toBe( + "/Users/me/Documents/code/magic.rs", + ) + expect(suggestCloneTargetPath("", "/Users/me/Documents/code")).toBe("/Users/me/Documents/code") + }) +}) diff --git a/packages/app/src/components/dialog-open-project.helpers.ts b/packages/app/src/components/dialog-open-project.helpers.ts new file mode 100644 index 00000000000..0f77cbb73e3 --- /dev/null +++ b/packages/app/src/components/dialog-open-project.helpers.ts @@ -0,0 +1,73 @@ +export type ProjectOpenMode = "git" | "path" + +const gitPattern = /^(https?:\/\/|ssh:\/\/|git@).+/i +const providerHost = { + github: "github.com", + gitlab: "gitlab.com", +} as const + +function withGitSuffix(value: string) { + if (value.endsWith(".git")) return value + return `${value}.git` +} + +export function parseProjectInput(value: string) { + return value.trim() +} + +export function isGitRepositoryUrl(value: string) { + return gitPattern.test(parseProjectInput(value)) +} + +export function nextProjectOpenMode(mode: ProjectOpenMode) { + if (mode === "git") return "path" + return "git" +} + +export function resolveCloneRepositoryUrl(value: string) { + const input = parseProjectInput(value) + if (!input) return "" + if (isGitRepositoryUrl(input)) return input + + const provider = /^(github|gitlab):([a-z0-9._-]+(?:\/[a-z0-9._-]+)+)$/i.exec(input) + if (provider) { + const host = providerHost[provider[1].toLowerCase() as keyof typeof providerHost] + return withGitSuffix(`https://${host}/${provider[2]}`) + } + + const hostPath = /^([^\s/:]+(?:\.[^\s/:]+)+)\/([a-z0-9._-]+(?:\/[a-z0-9._-]+)+)$/i.exec(input) + if (hostPath) { + return withGitSuffix(`https://${hostPath[1]}/${hostPath[2]}`) + } + + const short = /^([a-z0-9._-]+)\/([a-z0-9._-]+)$/i.exec(input) + if (short) { + return withGitSuffix(`https://github.com/${short[1]}/${short[2]}`) + } + + return "" +} + +export function cloneRepositoryName(value: string) { + const url = resolveCloneRepositoryUrl(value) + if (!url) return "" + const parts = url.replace(/[\\/]+$/, "").split("/") + const tail = parts.at(-1) ?? "" + return tail.replace(/\.git$/i, "") +} + +export function suggestCloneTargetPath(value: string, root: string) { + const base = parseProjectInput(root) + if (!base) return "" + const name = cloneRepositoryName(value) + if (!name) return base + const sep = base.includes("\\") && !base.includes("/") ? "\\" : "/" + const trimmed = base.replace(/[\\/]+$/, "") + return `${trimmed}${sep}${name}` +} + +export function projectOpenError(error: unknown) { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + return "Unknown error" +} diff --git a/packages/app/src/components/dialog-open-project.tsx b/packages/app/src/components/dialog-open-project.tsx new file mode 100644 index 00000000000..0b782aca947 --- /dev/null +++ b/packages/app/src/components/dialog-open-project.tsx @@ -0,0 +1,299 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Progress } from "@opencode-ai/ui/progress" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { createStore } from "solid-js/store" +import { createEffect, onMount, Show } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { + parseProjectInput, + projectOpenError, + resolveCloneRepositoryUrl, + suggestCloneTargetPath, +} from "./dialog-open-project.helpers" + +type Mode = "git" | "path" + +export function DialogOpenProject(props: { + onSelect: (directory: string) => void + mode?: Mode + lockMode?: boolean + title?: string +}) { + const dialog = useDialog() + const platform = usePlatform() + const language = useLanguage() + const sdk = useGlobalSDK() + const title = () => props.title ?? language.t("command.project.open") + + const [store, setStore] = createStore({ + mode: (props.mode ?? "git") as Mode, + value: "", + target: "", + targetRoot: "", + targetManual: false, + busy: false, + error: "", + }) + + onMount(() => { + if (!platform.getDefaultCloneDirectory) return + void platform.getDefaultCloneDirectory().then((root) => { + if (!root) return + setStore("targetRoot", root) + }) + }) + + createEffect(() => { + if (store.mode !== "git") return + if (store.targetManual) return + const root = parseProjectInput(store.targetRoot) + if (!root) return + setStore("target", suggestCloneTargetPath(store.value, root)) + }) + + async function browse() { + if (!platform.openDirectoryPickerDialog) return + const result = await platform.openDirectoryPickerDialog({ + title: title(), + multiple: false, + }) + + if (Array.isArray(result)) { + if (!result[0]) return + setStore("value", result[0]) + setStore("error", "") + return + } + + if (!result) return + setStore("value", result) + setStore("error", "") + } + + async function browseTarget() { + if (!platform.openDirectoryPickerDialog) return + const result = await platform.openDirectoryPickerDialog({ + title: title(), + multiple: false, + }) + + if (Array.isArray(result)) { + if (!result[0]) return + setStore("target", result[0]) + setStore("targetManual", true) + return + } + + if (!result) return + setStore("target", result) + setStore("targetManual", true) + } + + async function openPath(input: string) { + if (!input) throw new Error(language.t("dialog.project.open.error.pathRequired")) + const directory = platform.normalizeProjectPath ? await platform.normalizeProjectPath(input) : input + + await sdk.client.file.list({ directory, path: "" }) + props.onSelect(directory) + } + + async function clone(input: string) { + const url = resolveCloneRepositoryUrl(input) + if (!url) { + throw new Error(language.t("dialog.project.open.error.gitInvalid")) + } + if (!platform.cloneGitRepository) throw new Error(language.t("common.requestFailed")) + + const target = parseProjectInput(store.target) + const directory = await platform.cloneGitRepository( + url, + target ? (platform.normalizeProjectPath ? await platform.normalizeProjectPath(target) : target) : undefined, + ) + + await sdk.client.file.list({ directory, path: "" }) + props.onSelect(directory) + } + + async function submit(e: SubmitEvent) { + e.preventDefault() + if (store.busy) return + + setStore("busy", true) + setStore("error", "") + + await Promise.resolve() + .then(async () => { + const value = parseProjectInput(store.value) + if (store.mode === "git") { + await clone(value) + dialog.close() + return + } + + await openPath(value) + dialog.close() + }) + .catch((error) => { + setStore("error", projectOpenError(error)) + }) + .finally(() => { + setStore("busy", false) + }) + } + + return ( + +
+ +
+
+ {language.t("dialog.project.open.git.label")} + ({language.t("dialog.project.open.git.helper")}) +
+ { + setStore("value", value) + if (!store.error) return + setStore("error", "") + }} + /> +
+ +
+
Local Path
+
+ { + setStore("target", event.currentTarget.value) + setStore("targetManual", true) + }} + /> + +
+
{language.t("dialog.project.open.path.hint")}
+
+
+ + +
+ + +
+
+ + + { + setStore("value", value) + if (!store.error) return + setStore("error", "") + }} + /> + + + +
+ +
+
+ + +
{store.error}
+
+ + + + {language.t("dialog.project.open.submit.cloning")} + + + +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index b530aff532f..e7760c603c0 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -24,6 +24,7 @@ type Entry = { type: EntryType title: string description?: string + keywords?: string keybind?: string category: string option?: CommandOption @@ -62,6 +63,7 @@ const createCommandEntry = (option: CommandOption, category: string): Entry => ( type: "command", title: option.title, description: option.description, + keywords: option.keywords, keybind: option.keybind, category, option, @@ -392,7 +394,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil loadingMessage={language.t("common.loading")} items={items} key={(item) => item.id} - filterKeys={["title", "description", "category"]} + filterKeys={["title", "description", "keywords", "category"]} groupBy={grouped() ? (item) => item.category : () => ""} onMove={handleMove} onSelect={handleSelect} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index bcee3f501f5..5bd6207a320 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -1,6 +1,7 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" +import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" @@ -18,6 +19,13 @@ export const DialogSelectModelUnpaid: Component = () => { const dialog = useDialog() const providers = useProviders() const language = useLanguage() + const favorite = (providerID: string, modelID: string) => local.model.isFavorite({ providerID, modelID }) + + const toggleFavorite = (event: MouseEvent, providerID: string, modelID: string) => { + event.preventDefault() + event.stopPropagation() + local.model.toggleFavorite({ providerID, modelID }) + } let listRef: ListRef | undefined const handleKeyDown = (e: KeyboardEvent) => { @@ -38,6 +46,12 @@ export const DialogSelectModelUnpaid: Component = () => { items={local.model.list} current={local.model.current()} key={(x) => `${x.provider.id}:${x.id}`} + sortBy={(a, b) => { + const af = favorite(a.provider.id, a.id) + const bf = favorite(b.provider.id, b.id) + if (af !== bf) return af ? -1 : 1 + return a.name.localeCompare(b.name) + }} itemWrapper={(item, node) => ( { > {(i) => (
- {i.name} - {language.t("model.tag.free")} - - {language.t("model.tag.latest")} - +
+ {i.name} + {language.t("model.tag.free")} + + {language.t("model.tag.latest")} + +
+ + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => toggleFavorite(event, i.provider.id, i.id)} + /> +
)} diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 9f7afb8cd27..4123e675794 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -34,6 +34,14 @@ const ModelList: Component<{ .filter((m) => (props.provider ? m.provider.id === props.provider : true)), ) + const favorite = (providerID: string, modelID: string) => local.model.isFavorite({ providerID, modelID }) + + const toggleFavorite = (event: MouseEvent, providerID: string, modelID: string) => { + event.preventDefault() + event.stopPropagation() + local.model.toggleFavorite({ providerID, modelID }) + } + return ( a.name.localeCompare(b.name)} + sortBy={(a, b) => { + const af = favorite(a.provider.id, a.id) + const bf = favorite(b.provider.id, b.id) + if (af !== bf) return af ? -1 : 1 + return a.name.localeCompare(b.name) + }} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { const aProvider = a.items[0].provider.id @@ -71,13 +84,34 @@ const ModelList: Component<{ > {(i) => (
- {i.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - +
+ {i.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + +
+ + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => toggleFavorite(event, i.provider.id, i.id)} + /> +
)}
diff --git a/packages/app/src/components/dialog-select-session.tsx b/packages/app/src/components/dialog-select-session.tsx new file mode 100644 index 00000000000..86c050b3c1c --- /dev/null +++ b/packages/app/src/components/dialog-select-session.tsx @@ -0,0 +1,219 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Spinner } from "@opencode-ai/ui/spinner" +import { base64Encode } from "@opencode-ai/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useNavigate } from "@solidjs/router" +import { createMemo, Match, type Accessor, Switch } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useNotification } from "@/context/notification" +import { usePermission } from "@/context/permission" +import { getRelativeTime } from "@/utils/time" +import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" + +type Entry = { + id: string + title: string + description: string + directory: string + sessionID: string + archived?: number + updated?: number +} + +function SessionStatus(props: { + isWorking: Accessor + hasPermissions: Accessor + hasError: Accessor + unseenCount: Accessor +}) { + return ( +
+ -
}> + + + + +
+ + +
+ + 0}> +
+ + +
+ ) +} + +function SessionEntryRow(props: { item: Entry }) { + const language = useLanguage() + const notification = useNotification() + const permission = usePermission() + const globalSync = useGlobalSync() + const [store] = globalSync.child(props.item.directory, { bootstrap: false }) + const hasPermissions = createMemo(() => { + return !!sessionPermissionRequest(store.session, store.permission, props.item.sessionID, (item) => { + return !permission.autoResponds(item, props.item.directory) + }) + }) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + const status = store.session_status[props.item.sessionID] + return status?.type === "busy" || status?.type === "retry" + }) + const hasError = createMemo(() => notification.session.unseenHasError(props.item.sessionID)) + const unseenCount = createMemo(() => notification.session.unseenCount(props.item.sessionID)) + const showLatest = createMemo(() => isWorking() || unseenCount() > 0 || hasPermissions()) + const latest = createMemo(() => { + const messages = store.message[props.item.sessionID] + if (!messages) return + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "assistant") continue + const text = extractText(store.part[message.id] ?? []) + if (text) return text + } + }) + + return ( +
+
+ +
+
+ + {props.item.title} + + + {props.item.description} + +
+ +
{latest()}
+
+
+
+ + {props.item.updated ? getRelativeTime(new Date(props.item.updated).toISOString(), language.t) : ""} + +
+ ) +} + +function extractText( + parts: Array<{ type?: string; text?: string; synthetic?: boolean; ignored?: boolean } | undefined>, +) { + for (const part of parts) { + if (!part || part.type !== "text") continue + if (part.synthetic || part.ignored) continue + const value = part.text?.trim() + if (value) return value + } +} + +export function DialogSelectSession() { + const dialog = useDialog() + const navigate = useNavigate() + const language = useLanguage() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const homedir = createMemo(() => globalSync.data.path.home) + + const state: { + inflight: Promise | undefined + cached: Entry[] | undefined + } = { + inflight: undefined, + cached: undefined, + } + + const load = () => { + if (state.cached) return state.cached + if (state.inflight) return state.inflight + + state.inflight = globalSDK.client.session + .list({ roots: true, limit: 500 }) + .then((x) => { + const home = homedir() + const next = (x.data ?? []) + .filter((session) => !!session?.id && !!session.directory) + .map((session) => { + const path = home ? session.directory.replace(home, "~") : session.directory + const directory = getFilename(session.directory) + const parent = getFilename(getDirectory(session.directory)) + const description = parent && parent !== "/" ? `${parent}/${directory}` : directory || path + return { + id: `${session.directory}:${session.id}`, + title: session.title ?? language.t("command.session.new"), + description, + directory: session.directory, + sessionID: session.id, + archived: session.time?.archived, + updated: session.time?.updated, + } + }) + .sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0)) + state.cached = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + state.inflight = undefined + }) + + return state.inflight + } + + const items = async (text: string) => { + const query = text.trim().toLowerCase() + const filter = (items: Entry[]) => { + if (!query) return items.slice(0, 100) + return items.filter((item) => { + const title = item.title.toLowerCase() + const description = item.description.toLowerCase() + const directory = item.directory.toLowerCase() + return title.includes(query) || description.includes(query) || directory.includes(query) + }) + } + + const result = load() + if (Array.isArray(result)) return filter(result) + return result.then(filter) + } + + const onSelect = (item: Entry | undefined) => { + if (!item) return + dialog.close() + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + } + + return ( + + item.id} + filterKeys={["title", "description"]} + onSelect={onSelect} + > + {(item) => } + + + ) +} diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f5d..afb5426093e 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -9,13 +9,20 @@ import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" -export const DialogSettings: Component = () => { +export type SettingsTab = "general" | "shortcuts" | "providers" | "models" + +export const DialogSettings: Component<{ tab?: SettingsTab }> = (props) => { const language = useLanguage() const platform = usePlatform() return ( - +
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 97f0530e98d..c25738f546b 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -361,6 +361,22 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } + const copySessionID = () => { + const id = params.id + if (!id) return + navigator.clipboard + .writeText(id) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: id, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + const share = useSessionShare({ globalSDK, currentSession, @@ -425,6 +441,20 @@ export function SessionHeader() { {language.t("session.header.open.copyPath")} + +
+ +
} > @@ -515,6 +545,20 @@ export function SessionHeader() { {language.t("session.header.open.copyPath")} + { + setMenu("open", false) + copySessionID() + }} + disabled={!params.id} + > +
+ +
+ + {language.t("session.header.open.copySessionID")} + +
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 42ee4092f68..73e820b8279 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,15 +1,16 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" 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 { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings, monoFontFamily } from "@/context/settings" +import { type AssistantCopyFormat, useSettings, monoFontFamily } from "@/context/settings" import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" @@ -45,9 +46,72 @@ export const SettingsGeneral: Component = () => { const [store, setStore] = createStore({ checking: false, + clonePath: "", + cloneBusy: false, + cloneDirty: false, }) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const desktopClonePath = createMemo( + () => platform.platform === "desktop" && !!platform.getDefaultCloneDirectory && !!platform.setDefaultCloneDirectory, + ) + const [clonePathResource, clonePathActions] = createResource(() => + desktopClonePath() ? platform.getDefaultCloneDirectory?.() : null, + ) + + createEffect(() => { + const path = clonePathResource.latest + if (!path) return + if (store.cloneDirty) return + setStore("clonePath", path) + }) + + const saveClonePath = async () => { + const setClonePath = platform.setDefaultCloneDirectory + if (!setClonePath) return + setStore("cloneBusy", true) + const path = store.clonePath.trim() + await Promise.resolve() + .then(async () => { + await setClonePath(path || null) + setStore("cloneDirty", false) + await clonePathActions.refetch() + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("cloneBusy", false)) + } + + const resetClonePath = async () => { + const setClonePath = platform.setDefaultCloneDirectory + if (!setClonePath) return + setStore("cloneBusy", true) + await Promise.resolve() + .then(async () => { + await setClonePath(null) + setStore("cloneDirty", false) + await clonePathActions.refetch() + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("cloneBusy", false)) + } + + const chooseClonePath = async () => { + if (!platform.openDirectoryPickerDialog) return + const result = await platform.openDirectoryPickerDialog({ + title: language.t("settings.desktop.clonePath.title"), + multiple: false, + }) + const value = Array.isArray(result) ? result[0] : result + if (!value) return + setStore("clonePath", value) + setStore("cloneDirty", true) + } const check = () => { if (!platform.checkUpdate) return @@ -120,6 +184,12 @@ export const SettingsGeneral: Component = () => { })), ) + const assistantCopyOptions = createMemo((): { value: AssistantCopyFormat; label: string }[] => [ + { value: "plain", label: language.t("settings.general.row.assistantCopyFormat.option.plain") }, + { value: "rich", label: language.t("settings.general.row.assistantCopyFormat.option.rich") }, + { value: "ask", label: language.t("settings.general.row.assistantCopyFormat.option.ask") }, + ]) + const fontOptions = [ { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" }, { value: "cascadia-code", label: "font.option.cascadiaCode" }, @@ -276,6 +346,26 @@ export const SettingsGeneral: Component = () => {

{language.t("settings.general.section.feed")}

+ + + item.id} + label={(item) => item.label} + onSelect={(item) => models.quick.set("a", item?.key)} + variant="secondary" + size="small" + triggerVariant="settings" + triggerStyle={{ "min-width": "260px" }} + /> + + models.quick.set("a", undefined)} /> + +
+
+
+
+ {language.t("settings.models.quick.second.title")} + + {language.t("settings.models.quick.second.description")} + +
+
+ + + + + +
+
+

Needs attention

+ {attention().length} +
+

+ source={diagnostics().source} auth={diagnostics().hasAuth ? "yes" : "no"} projects={diagnostics().projects}{" "} + dirs= + {diagnostics().directories} sessions={diagnostics().globalSessions} +

+ Loading sessions...

}> + {String(state.error)}

}> + 0} fallback={

No sessions found.

}> +
    + + {(item) => ( +
  • + +
  • + )} +
    +
+
+
+
+
+ + ) +} + +const root = document.getElementById("root") +if (root) render(() => , root) diff --git a/packages/session-modal/src/styles.css b/packages/session-modal/src/styles.css new file mode 100644 index 00000000000..6716f6ba9ae --- /dev/null +++ b/packages/session-modal/src/styles.css @@ -0,0 +1,215 @@ +:root { + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; + color: #f1f4f9; + background: radial-gradient(circle at 10% 0%, #2b4d66, #0f172a 45%), #0f172a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +.shell { + max-width: 980px; + margin: 0 auto; + padding: 28px 20px 40px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.hero { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: flex-start; + background: rgba(11, 19, 32, 0.7); + border: 1px solid rgba(147, 197, 253, 0.2); + border-radius: 14px; + padding: 16px; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; + margin: 0; + color: #93c5fd; +} + +h1 { + margin: 3px 0; + font-size: 28px; +} + +.subtitle { + margin: 0; + color: #bfd3ea; +} + +.server { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.server label { + font-size: 12px; + color: #bfd3ea; +} + +.server input { + min-width: 220px; + border-radius: 8px; + border: 1px solid rgba(147, 197, 253, 0.3); + background: rgba(15, 23, 42, 0.8); + color: #f1f4f9; + padding: 8px 10px; +} + +.server button { + border: 0; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + color: #0f172a; + background: #93c5fd; + font-weight: 600; +} + +.panel { + background: rgba(11, 19, 32, 0.7); + border: 1px solid rgba(147, 197, 253, 0.2); + border-radius: 14px; + padding: 14px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.panel-header h2 { + margin: 0; + font-size: 16px; +} + +.panel-header span { + background: rgba(147, 197, 253, 0.2); + padding: 3px 9px; + border-radius: 999px; +} + +.state { + margin: 0; + color: #bfd3ea; + padding: 12px 2px; +} + +.state.error { + color: #fecaca; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.row { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.75); + color: inherit; + border-radius: 12px; + padding: 10px 12px; + display: flex; + align-items: center; + gap: 12px; + text-align: left; + cursor: pointer; +} + +.row:hover { + border-color: rgba(147, 197, 253, 0.6); + transform: translateY(-1px); +} + +.dot { + width: 10px; + height: 10px; + border-radius: 999px; + flex-shrink: 0; +} + +.dot.active { + background: #60a5fa; +} + +.dot.idle { + background: #64748b; +} + +.meta { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.title { + font-size: 14px; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.path { + font-size: 12px; + color: #bfd3ea; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.badges { + display: flex; + gap: 6px; +} + +.badge { + font-size: 11px; + border-radius: 999px; + padding: 3px 8px; + background: rgba(148, 163, 184, 0.25); +} + +.badge-warn { + background: rgba(251, 191, 36, 0.25); +} + +@media (max-width: 720px) { + .hero { + flex-direction: column; + } + + .server { + width: 100%; + } + + .server input { + min-width: 0; + width: 100%; + } +} diff --git a/packages/session-modal/tsconfig.json b/packages/session-modal/tsconfig.json new file mode 100644 index 00000000000..849c3905d99 --- /dev/null +++ b/packages/session-modal/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "composite": true, + "target": "ESNext", + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noEmit": false, + "emitDeclarationOnly": true, + "outDir": "node_modules/.ts-dist", + "isolatedModules": true + }, + "include": ["src", "package.json"], + "exclude": ["dist", "ts-dist"], + "references": [{ "path": "../sdk/js" }] +} diff --git a/packages/session-modal/vite.config.ts b/packages/session-modal/vite.config.ts new file mode 100644 index 00000000000..b96e6d4863b --- /dev/null +++ b/packages/session-modal/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" + +export default defineConfig({ + plugins: [solid()], + server: { + host: "0.0.0.0", + allowedHosts: true, + port: 3011, + }, + build: { + target: "esnext", + }, +}) diff --git a/packages/ui/src/components/markdown-copy.test.ts b/packages/ui/src/components/markdown-copy.test.ts new file mode 100644 index 00000000000..2d7eabc6144 --- /dev/null +++ b/packages/ui/src/components/markdown-copy.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import { + markdownClipboardFont, + markdownClipboardMonoFont, + serializeMarkdownClipboardHTML, + writeClipboardPayload, +} from "./markdown-copy" + +describe("markdown clipboard html", () => { + test("wraps content with inline font stack", () => { + const html = serializeMarkdownClipboardHTML("

Hello world

") + expect(html).toContain(`
`) + expect(html).toContain("

Hello world

") + expect(html).not.toContain("var(--font-family-sans)") + }) + + test("inlines link and code styles", () => { + if (typeof DOMParser === "undefined") return + const html = serializeMarkdownClipboardHTML( + '

OpenCode

echo test
', + ) + expect(html).toContain("color: #0b66d2") + expect(html).toContain(`font-family: ${markdownClipboardMonoFont}`) + expect(html).toContain("background: #f6f8fa") + }) + + test("returns empty string for blank html", () => { + expect(serializeMarkdownClipboardHTML(" ")).toBe("") + }) +}) + +describe("markdown clipboard payload", () => { + test("writes both plain text and html mime types", async () => { + const originalNavigator = globalThis.navigator + const originalClipboardItem = globalThis.ClipboardItem + + const writes: unknown[][] = [] + class FakeClipboardItem { + constructor(public data: Record) {} + } + + Object.defineProperty(globalThis, "navigator", { + value: { + clipboard: { + write: async (items: unknown[]) => { + writes.push(items) + }, + writeText: async () => {}, + }, + }, + configurable: true, + }) + Object.defineProperty(globalThis, "ClipboardItem", { value: FakeClipboardItem, configurable: true }) + + await writeClipboardPayload({ text: "hello", html: "

hello

" }) + + expect(writes.length).toBe(1) + const item = writes[0]?.[0] as FakeClipboardItem + expect(item.data["text/plain"]).toBeInstanceOf(Blob) + expect(item.data["text/html"]).toBeInstanceOf(Blob) + expect(await item.data["text/plain"]?.text()).toBe("hello") + expect(await item.data["text/html"]?.text()).toBe("

hello

") + + Object.defineProperty(globalThis, "navigator", { value: originalNavigator, configurable: true }) + Object.defineProperty(globalThis, "ClipboardItem", { value: originalClipboardItem, configurable: true }) + }) + + test("falls back to writeText when html is missing", async () => { + const originalNavigator = globalThis.navigator + const originalClipboardItem = globalThis.ClipboardItem + + const textWrites: string[] = [] + Object.defineProperty(globalThis, "navigator", { + value: { + clipboard: { + write: async () => {}, + writeText: async (value: string) => { + textWrites.push(value) + }, + }, + }, + configurable: true, + }) + Object.defineProperty(globalThis, "ClipboardItem", { value: undefined, configurable: true }) + + await writeClipboardPayload({ text: "plain" }) + + expect(textWrites).toEqual(["plain"]) + + Object.defineProperty(globalThis, "navigator", { value: originalNavigator, configurable: true }) + Object.defineProperty(globalThis, "ClipboardItem", { value: originalClipboardItem, configurable: true }) + }) +}) diff --git a/packages/ui/src/components/markdown-copy.ts b/packages/ui/src/components/markdown-copy.ts new file mode 100644 index 00000000000..631b717b829 --- /dev/null +++ b/packages/ui/src/components/markdown-copy.ts @@ -0,0 +1,55 @@ +export const markdownClipboardFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' +export const markdownClipboardMonoFont = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + +export type MarkdownCopyMode = "plain" | "rich" | "ask" + +function addStyle(el: Element, value: string) { + const style = el.getAttribute("style") + if (!style) { + el.setAttribute("style", value) + return + } + el.setAttribute("style", `${style} ${value}`) +} + +function inlineClipboardStyles(value: string) { + if (typeof DOMParser === "undefined") return value + const doc = new DOMParser().parseFromString(`
${value}
`, "text/html") + const root = doc.body.firstElementChild + if (!root) return value + for (const link of root.querySelectorAll("a")) { + addStyle(link, "color: #0b66d2; text-decoration: underline; text-underline-offset: 2px;") + } + for (const block of root.querySelectorAll("pre")) { + addStyle( + block, + `font-family: ${markdownClipboardMonoFont}; font-size: 13px; line-height: 1.45; background: #f6f8fa; border-radius: 6px; padding: 8px 12px;`, + ) + } + for (const code of root.querySelectorAll("code")) { + addStyle(code, `font-family: ${markdownClipboardMonoFont}; font-size: 13px;`) + } + return root.innerHTML +} + +export function serializeMarkdownClipboardHTML(value: string) { + const html = inlineClipboardStyles(value.trim()) + if (!html) return "" + return `
${html}
` +} + +export async function writeClipboardPayload(input: { text: string; html?: string }) { + const clipboard = navigator.clipboard + if (!clipboard) return + if (!input.html || typeof ClipboardItem === "undefined" || typeof clipboard.write !== "function") { + await clipboard.writeText(input.text) + return + } + await clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([input.text], { type: "text/plain" }), + "text/html": new Blob([input.html], { type: "text/html" }), + }), + ]) +} diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts new file mode 100644 index 00000000000..4757449c190 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { parseCodeFileRef } from "./markdown-file-ref" + +describe("parseCodeFileRef", () => { + test("parses relative path with line and trims punctuation", () => { + expect(parseCodeFileRef("src/app.ts:42,", "")).toEqual({ + path: "src/app.ts", + line: 42, + }) + }) + + test("parses hash-based line suffix", () => { + expect(parseCodeFileRef("src/app.ts#L12", "")).toEqual({ + path: "src/app.ts", + line: 12, + }) + }) + + test("parses file urls and strips project root", () => { + expect(parseCodeFileRef("file:///Users/test/repo/src/main.ts:9", "/Users/test/repo")).toEqual({ + path: "src/main.ts", + line: 9, + }) + }) + + test("normalizes windows paths", () => { + expect(parseCodeFileRef("C:\\repo\\src\\main.ts:7", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 7, + }) + }) + + test("parses windows file url paths", () => { + expect(parseCodeFileRef("file:///C:/repo/src/main.ts#L11", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 11, + }) + }) + + test("normalizes line breaks inside long paths", () => { + expect(parseCodeFileRef("clients/notes/reply-to-\nharry-2026-02-27.md", "")).toEqual({ + path: "clients/notes/reply-to-harry-2026-02-27.md", + }) + }) + + test("ignores non-path text", () => { + expect(parseCodeFileRef("hello-world", "")).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts new file mode 100644 index 00000000000..afec68fba41 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -0,0 +1,58 @@ +export type FileRef = { + path: string + line?: number +} + +function looksLikePath(path: string) { + if (!path) return false + if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true + if (/^[a-zA-Z]:[\\/]/.test(path)) return true + return path.includes("/") || path.includes("\\") +} + +function normalizeProjectPath(path: string, directory: string) { + if (!path) return path + const file = path.replace(/\\/g, "/") + const root = directory.replace(/\\/g, "/") + if (/^\/[a-zA-Z]:\//.test(file)) return file.slice(1) + if (file.startsWith(root + "/")) return file.slice(root.length + 1) + if (file === root) return "" + if (file.startsWith("./")) return file.slice(2) + return file +} + +export function parseCodeFileRef(text: string, directory: string): FileRef | undefined { + let value = text + .trim() + .replace(/\s*\n\s*/g, "") + .replace(/[),.;!?]+$/, "") + let lineFromUrlHash: number | undefined + if (!value) return + + if (value.startsWith("file://")) { + try { + const url = new URL(value) + value = decodeURIComponent(url.pathname) + const match = url.hash.match(/^#L(\d+)$/) + lineFromUrlHash = match ? Number(match[1]) : undefined + } catch { + return + } + } + + const hash = value.match(/#L(\d+)$/) + const lineFromHash = hash ? Number(hash[1]) : undefined + if (hash) value = value.slice(0, -hash[0].length) + + const line = value.match(/:(\d+)(?::\d+)?$/) + const lineFromSuffix = line ? Number(line[1]) : undefined + if (line) { + const maybePath = value.slice(0, -line[0].length) + if (looksLikePath(maybePath)) value = maybePath + } + + if (!looksLikePath(value)) return + const path = normalizeProjectPath(value, directory) + if (!path) return + return { path, line: lineFromUrlHash ?? lineFromHash ?? lineFromSuffix } +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f82723807d6..07c96d34eb5 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -264,3 +264,22 @@ text-decoration: underline; text-underline-offset: 2px; } + +[data-component="markdown"] button.file-link { + appearance: none; + border: none; + background: transparent; + display: inline; + padding: 0; + margin: 0; + color: inherit; + font: inherit; + text-align: left; + white-space: normal; + cursor: pointer; +} + +[data-component="markdown"] button.file-link:hover > code { + text-decoration: underline; + text-underline-offset: 2px; +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 01254f11895..1a54c2574a3 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,8 +1,11 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" +import { useData } from "../context/data" +import { parseCodeFileRef, type FileRef } from "./markdown-file-ref" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" +import { serializeMarkdownClipboardHTML } from "./markdown-copy" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" @@ -143,7 +146,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { } } -function markCodeLinks(root: HTMLDivElement) { +function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolean) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { const href = codeUrl(code.textContent ?? "") @@ -152,35 +155,46 @@ function markCodeLinks(root: HTMLDivElement) { ? code.parentElement : null - if (!href) { - if (parentLink) parentLink.replaceWith(code) + if (href) { + if (parentLink) { + parentLink.href = href + } else { + const link = document.createElement("a") + link.href = href + link.className = "external-link" + link.target = "_blank" + link.rel = "noopener noreferrer" + code.parentNode?.replaceChild(link, code) + link.appendChild(code) + } continue } - if (parentLink) { - parentLink.href = href - continue - } + if (parentLink) parentLink.replaceWith(code) + if (!openable) continue + + const file = parseCodeFileRef(code.textContent ?? "", directory) + if (!file) continue - const link = document.createElement("a") - link.href = href - link.className = "external-link" - link.target = "_blank" - link.rel = "noopener noreferrer" - code.parentNode?.replaceChild(link, code) - link.appendChild(code) + const button = document.createElement("button") + button.type = "button" + button.className = "file-link" + button.setAttribute("data-file-path", file.path) + if (file.line) button.setAttribute("data-file-line", String(file.line)) + code.parentNode?.replaceChild(button, code) + button.appendChild(code) } } -function decorate(root: HTMLDivElement, labels: CopyLabels) { +function decorate(root: HTMLDivElement, labels: CopyLabels, directory: string, openable: boolean) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } - markCodeLinks(root) + markCodeLinks(root, directory, openable) } -function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { +function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (input: FileRef) => void) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { @@ -192,6 +206,18 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { const target = event.target if (!(target instanceof Element)) return + const file = target.closest("button.file-link") + if (file instanceof HTMLButtonElement) { + const path = file.getAttribute("data-file-path") + const raw = file.getAttribute("data-file-line") + const line = raw ? Number(raw) : undefined + if (!path || !onFileOpen) return + event.preventDefault() + event.stopPropagation() + onFileOpen({ path, line }) + return + } + const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLButtonElement)) return const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") @@ -207,8 +233,6 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { timeouts.set(button, timeout) } - decorate(root, labels) - const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) { if (button instanceof HTMLButtonElement) updateLabel(button) @@ -224,6 +248,44 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { } } +function setupSelectionCopy(root: HTMLDivElement) { + const handleCopy = (event: ClipboardEvent) => { + const clip = event.clipboardData + if (!clip) return + + const selection = window.getSelection() + if (!selection) return + if (selection.rangeCount === 0 || selection.isCollapsed) return + const anchor = selection.anchorNode + const focus = selection.focusNode + if (!anchor || !focus) return + if (!root.contains(anchor) || !root.contains(focus)) return + + const text = selection.toString() + if (!text) return + + const range = selection.getRangeAt(0) + const wrap = document.createElement("div") + wrap.appendChild(range.cloneContents()) + for (const item of wrap.querySelectorAll('[data-slot="markdown-copy-button"]')) { + item.remove() + } + + const html = serializeMarkdownClipboardHTML(wrap.innerHTML) + if (!html) return + + clip.setData("text/plain", text) + clip.setData("text/html", html) + event.preventDefault() + } + + root.addEventListener("copy", handleCopy) + + return () => { + root.removeEventListener("copy", handleCopy) + } +} + function touch(key: string, value: Entry) { cache.delete(key) cache.set(key, value) @@ -245,6 +307,7 @@ export function Markdown( ) { const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const marked = useMarked() + const data = useData() const i18n = useI18n() const [root, setRoot] = createSignal() const [html] = createResource( @@ -273,6 +336,7 @@ export function Markdown( let copySetupTimer: ReturnType | undefined let copyCleanup: (() => void) | undefined + let selectionCleanup: (() => void) | undefined createEffect(() => { const container = root() @@ -280,6 +344,10 @@ export function Markdown( if (!container) return if (isServer) return + if (!selectionCleanup) { + selectionCleanup = setupSelectionCopy(container) + } + if (!content) { container.innerHTML = "" return @@ -287,10 +355,15 @@ export function Markdown( const temp = document.createElement("div") temp.innerHTML = content - decorate(temp, { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - }) + decorate( + temp, + { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }, + data.directory, + !!data.openFilePath, + ) morphdom(container, temp, { childrenOnly: true, @@ -303,16 +376,21 @@ export function Markdown( if (copySetupTimer) clearTimeout(copySetupTimer) copySetupTimer = setTimeout(() => { if (copyCleanup) copyCleanup() - copyCleanup = setupCodeCopy(container, { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - }) + copyCleanup = setupCodeCopy( + container, + { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }, + data.openFilePath, + ) }, 150) }) onCleanup(() => { if (copySetupTimer) clearTimeout(copySetupTimer) if (copyCleanup) copyCleanup() + if (selectionCleanup) selectionCleanup() }) return ( diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 7ee5378848e..8a3e609493c 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -1202,6 +1202,27 @@ flex-shrink: 0; } + button[data-slot="apply-patch-filename"] { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + text-align: left; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + + &:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 2px; + } + } + [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 0815c333367..0c8aca9d72e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -48,11 +48,13 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" +import { DropdownMenu } from "./dropdown-menu" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { animate } from "motion" import { useLocation } from "@solidjs/router" +import { MarkdownCopyMode, serializeMarkdownClipboardHTML, writeClipboardPayload } from "./markdown-copy" function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined @@ -130,6 +132,7 @@ export interface MessageProps { message: MessageType parts: PartType[] showAssistantCopyPartID?: string | null + assistantCopyMode?: MarkdownCopyMode interrupted?: boolean queued?: boolean showReasoningSummaries?: boolean @@ -141,6 +144,7 @@ export interface MessagePartProps { hideDetails?: boolean defaultOpen?: boolean showAssistantCopyPartID?: string | null + assistantCopyMode?: MarkdownCopyMode turnDurationMs?: number } @@ -202,6 +206,17 @@ function getDirectory(path: string | undefined) { return relativizeProjectPath(_getDirectory(path), data.directory) } +function openProjectFile( + path: string | undefined, + directory: string, + openFilePath?: (input: { path: string }) => void, +) { + if (!path) return + const file = relativizeProjectPath(path, directory).replace(/^\//, "") + if (!file) return + openFilePath?.({ path: file }) +} + import type { IconProps } from "./icon" export type ToolInfo = { @@ -441,6 +456,10 @@ function partByID(parts: readonly PartType[], partID: string) { return parts.find((part) => part.id === partID) } +function index(items: readonly T[]) { + return new Map(items.map((item) => [item.id, item] as const)) +} + function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { if (HIDDEN_TOOLS.has(part.tool)) return false @@ -465,6 +484,7 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null + assistantCopyMode?: MarkdownCopyMode turnDurationMs?: number working?: boolean showReasoningSummaries?: boolean @@ -474,6 +494,13 @@ export function AssistantParts(props: { const data = useData() const emptyParts: PartType[] = [] const emptyTools: ToolPart[] = [] + const msgs = createMemo(() => index(props.messages)) + const part = createMemo( + () => + new Map( + props.messages.map((message) => [message.id, index(list(data.store.part?.[message.id], emptyParts))] as const), + ), + ) const grouped = createMemo( () => @@ -507,7 +534,7 @@ export function AssistantParts(props: { const entry = entryAccessor() if (entry.type !== "context") return emptyTools return entry.refs - .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) + .map((ref) => part().get(ref.messageID)?.get(ref.partID)) .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) }, emptyTools, @@ -527,23 +554,24 @@ export function AssistantParts(props: { const message = createMemo(() => { const entry = entryAccessor() if (entry.type !== "part") return - return props.messages.find((item) => item.id === entry.ref.messageID) + return msgs().get(entry.ref.messageID) }) - const part = createMemo(() => { + const item = createMemo(() => { const entry = entryAccessor() if (entry.type !== "part") return - return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) + return part().get(entry.ref.messageID)?.get(entry.ref.partID) }) return ( - + @@ -680,6 +708,7 @@ export function Message(props: MessageProps) { message={assistantMessage() as AssistantMessage} parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} + assistantCopyMode={props.assistantCopyMode} showReasoningSummaries={props.showReasoningSummaries} /> )} @@ -692,9 +721,11 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage parts: PartType[] showAssistantCopyPartID?: string | null + assistantCopyMode?: MarkdownCopyMode showReasoningSummaries?: boolean }) { const emptyTools: ToolPart[] = [] + const part = createMemo(() => index(props.parts)) const grouped = createMemo( () => groupParts( @@ -723,7 +754,7 @@ export function AssistantMessageDisplay(props: { const entry = entryAccessor() if (entry.type !== "context") return emptyTools return entry.refs - .map((ref) => partByID(props.parts, ref.partID)) + .map((ref) => part().get(ref.partID)) .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) }, emptyTools, @@ -739,18 +770,19 @@ export function AssistantMessageDisplay(props: { {(() => { - const part = createMemo(() => { + const item = createMemo(() => { const entry = entryAccessor() if (entry.type !== "part") return - return partByID(props.parts, entry.ref.partID) + return part().get(entry.ref.partID) }) return ( - + ) @@ -1078,6 +1110,7 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} + assistantCopyMode={props.assistantCopyMode} turnDurationMs={props.turnDurationMs} /> @@ -1120,7 +1153,12 @@ export const ToolRegistry = { render: getTool, } -function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { +function ToolFileAccordion(props: { + path: string + actions?: JSX.Element + children: JSX.Element + onPathClick?: () => void +}) { const value = createMemo(() => props.path || "tool-file") return ( @@ -1140,7 +1178,21 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre {`\u202A${getDirectory(props.path)}\u202C`} - {getFilename(props.path)} + {getFilename(props.path)}} + > + +
@@ -1190,7 +1242,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
) } - return + return }} @@ -1287,38 +1339,84 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id return isLastTextPart() }) + const copyMode = createMemo(() => props.assistantCopyMode ?? "plain") + let root: HTMLDivElement | undefined const [copied, setCopied] = createSignal(false) + const copyLabel = createMemo(() => { + if (copyMode() === "rich") return i18n.t("ui.message.copyResponseRich") + return i18n.t("ui.message.copyResponse") + }) - const handleCopy = async () => { - const content = displayText() - if (!content) return - await navigator.clipboard.writeText(content) + const handleRichCopy = async () => { + const text = displayText() + if (!text) return + const markdown = root?.querySelector('[data-slot="text-part-body"] [data-component="markdown"]') + if (!(markdown instanceof HTMLDivElement)) { + await navigator.clipboard.writeText(text) + return + } + const wrap = document.createElement("div") + wrap.innerHTML = markdown.innerHTML + for (const item of wrap.querySelectorAll('[data-slot="markdown-copy-button"]')) { + item.remove() + } + const html = serializeMarkdownClipboardHTML(wrap.innerHTML) + await writeClipboardPayload({ text, html: html || undefined }) + } + + const copy = async (mode: "plain" | "rich") => { + const text = displayText() + if (!text) return + if (mode === "rich") await handleRichCopy() + if (mode === "plain") await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } return ( -
+
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - + + + e.preventDefault()} + onClick={() => copy(copyMode() === "rich" ? "rich" : "plain")} + aria-label={copied() ? i18n.t("ui.message.copied") : copyLabel()} + /> + + + + + + e.preventDefault()} + aria-label={i18n.t("ui.message.copyOptions")} + /> + + + + void copy("plain")}> + {i18n.t("ui.message.copyResponsePlain")} + + void copy("rich")}> + {i18n.t("ui.message.copyResponseRich")} + + + + + {meta()} @@ -1689,6 +1787,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) @@ -1729,6 +1828,7 @@ ToolRegistry.register({ openProjectFile(path(), data.directory, data.openFilePath)} actions={ @@ -1761,6 +1861,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) @@ -1795,7 +1896,10 @@ ToolRegistry.register({ } > - + openProjectFile(path(), data.directory, data.openFilePath)} + >
(props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1908,7 +2013,16 @@ ToolRegistry.register({ {`\u202A${getDirectory(file.relativePath)}\u202C`} - {getFilename(file.relativePath)} +
@@ -1991,6 +2105,7 @@ ToolRegistry.register({ > openProjectFile(single()!.relativePath, data.directory, data.openFilePath)} actions={ diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 3323a9fc667..bbe4bad2479 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -19,6 +19,7 @@ import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" +import { MarkdownCopyMode } from "./markdown-copy" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -142,6 +143,7 @@ export function SessionTurn( sessionID: string messageID: string showReasoningSummaries?: boolean + assistantCopyMode?: MarkdownCopyMode shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean active?: boolean @@ -388,149 +390,164 @@ export function SessionTurn( >
-
-
- -
- -
- -
-
- 0}> -
- ( +
+
+
- - -
- - - + {(part) => ( +
+ +
+ )} +
+ 0}> +
+ - -
-
- - 0 && !working()}> -
- - -
-
- {i18n.t("ui.sessionReview.change.modified")} - - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
- - +
+ + +
+ + + + +
+
+ + 0 && !working()}> +
+ + +
+
+ + {i18n.t("ui.sessionReview.change.modified")} + + + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + +
+ + +
-
- - - -
- setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - - - -
- - - - {`\u202A${getDirectory(diff.file)}\u202C`} + + + +
+ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(diff) => { + const active = createMemo(() => expanded().includes(diff.file)) + const [visible, setVisible] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }, + { defer: true }, + ), + ) + + return ( + + + +
+ + + + {`\u202A${getDirectory(diff.file)}\u202C`} + + + + {getFilename(diff.file)} - - {getFilename(diff.file)} - -
- - - - - +
+ + + + + + +
-
-
-
- - -
- -
-
-
-
- ) - }} -
-
-
-
-
- -
- - - - {errorText()} - - -
+ + + + +
+ +
+
+
+ + ) + }} + + +
+ + + +
+ + + + {errorText()} + + +
+ )}
{props.children}
diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 13873ae90e6..2e9612b2b3d 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -9,13 +9,14 @@ import { useI18n } from "../context/i18n" export interface ToolErrorCardProps extends Omit, "children" | "variant"> { tool: string error: string + defaultOpen?: boolean } export function ToolErrorCard(props: ToolErrorCardProps) { const i18n = useI18n() - const [open, setOpen] = createSignal(true) + const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [copied, setCopied] = createSignal(false) - const [split, rest] = splitProps(props, ["tool", "error"]) + const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen"]) const name = createMemo(() => { const map: Record = { read: "ui.tool.read", diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb23..5fe5dc8aa90 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -26,6 +26,8 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string +export type OpenFilePathFn = (input: { path: string; line?: number }) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -33,6 +35,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn + onOpenFilePath?: OpenFilePathFn }) => { return { get store() { @@ -43,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, + openFilePath: props.onOpenFilePath, } }, }) diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index afba5f648c8..a74c05a6da5 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -68,8 +68,19 @@ function init() { event.stopPropagation() } + const preventFileDropNavigation = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return + event.preventDefault() + } + window.addEventListener("keydown", onKeyDown, true) - onCleanup(() => window.removeEventListener("keydown", onKeyDown, true)) + window.addEventListener("dragover", preventFileDropNavigation, true) + window.addEventListener("drop", preventFileDropNavigation, true) + onCleanup(() => { + window.removeEventListener("keydown", onKeyDown, true) + window.removeEventListener("dragover", preventFileDropNavigation, true) + window.removeEventListener("drop", preventFileDropNavigation, true) + }) }) const show = (element: DialogElement, owner: Owner, onClose?: () => void) => { diff --git a/packages/ui/src/hooks/filter-search.test.ts b/packages/ui/src/hooks/filter-search.test.ts new file mode 100644 index 00000000000..f3ffaa23f0f --- /dev/null +++ b/packages/ui/src/hooks/filter-search.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { fuzzy, normalize } from "./filter-search" + +describe("filter search", () => { + test("normalizes punctuation and separators", () => { + expect(normalize(" GPT_5,3-mini ")).toBe("gpt53mini") + }) + + test("matches locale punctuation on plain strings", () => { + const list = ["gpt-5.3", "gpt-5.2"] + expect(fuzzy(normalize("5,3"), list)[0]).toBe("gpt-5.3") + }) + + test("matches objects through normalized indexed keys", () => { + const list = [ + { id: "openai:gpt-5.3", name: "GPT-5.3", provider: { name: "OpenAI" } }, + { id: "openai:gpt-5.2", name: "GPT-5.2", provider: { name: "OpenAI" } }, + ] + const result = fuzzy(normalize("openai gpt_5,3"), list, ["provider.name", "name", "id"]) + expect(result[0]?.id).toBe("openai:gpt-5.3") + }) + + test("boosts normalized prefix matches", () => { + const list = ["my-gpt-53", "gpt-5.3", "x-gpt53"] + expect(fuzzy(normalize("gpt53"), list)[0]).toBe("gpt-5.3") + }) +}) diff --git a/packages/ui/src/hooks/filter-search.ts b/packages/ui/src/hooks/filter-search.ts new file mode 100644 index 00000000000..a5319c07b13 --- /dev/null +++ b/packages/ui/src/hooks/filter-search.ts @@ -0,0 +1,44 @@ +import fuzzysort from "fuzzysort" + +type Row = { val: T; text: string; ord: number } + +export const normalize = (value: string) => + value + .toLowerCase() + .normalize("NFKC") + .replaceAll(",", ".") + .replace(/[\s._\-/\\]+/g, "") + +const pull = (value: unknown, key: string) => { + let node = value + for (const part of key.split(".")) { + if (!node || typeof node !== "object") return "" + node = (node as Record)[part] + } + if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") return String(node) + return "" +} + +const build = (list: T[], keys?: string[]) => { + if (!keys || keys.length === 0) { + return list.map((val, ord) => ({ val, ord, text: normalize(String(val)) })) + } + return list.map((val, ord) => ({ + val, + ord, + text: normalize(keys.map((key) => pull(val, key)).join(" ")), + })) +} + +export const fuzzy = (needle: string, list: T[], keys?: string[]) => { + const rows = build(list, keys) + return Array.from(fuzzysort.go(needle, rows, { key: "text" })) + .sort((a, b) => { + const ab = Number(a.obj.text.startsWith(needle)) + const bb = Number(b.obj.text.startsWith(needle)) + if (ab !== bb) return bb - ab + if (a.score !== b.score) return b.score - a.score + return a.obj.ord - b.obj.ord + }) + .map((hit) => hit.obj.val) +} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 2d4e2bdd1aa..b741cafb6de 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -1,8 +1,8 @@ -import fuzzysort from "fuzzysort" -import { entries, flatMap, groupBy, map, pipe } from "remeda" +import { entries, groupBy, map, pipe } from "remeda" import { createEffect, createMemo, createResource, on } from "solid-js" import { createStore } from "solid-js/store" import { createList } from "solid-list" +import { fuzzy, normalize } from "./filter-search" export interface FilteredListProps { items: T[] | ((filter: string) => T[] | Promise) @@ -27,18 +27,16 @@ export function useFilteredList(props: FilteredListProps) { filter: store.filter, items: typeof props.items === "function" ? props.items(store.filter) : props.items, }), - async ({ filter, items }) => { + async ({ filter, items }: { filter: string; items: T[] | Promise }) => { const query = filter ?? "" - const needle = query.toLowerCase() + const needle = normalize(query) const all = (await Promise.resolve(items)) || [] const result = pipe( all, - (x) => { + (x: T[]) => { if (!needle) return x - if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) { - return fuzzysort.go(needle, x).map((x) => x.target) as T[] - } - return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj) + if (props.filterKeys) return fuzzy(needle, x, props.filterKeys) + return fuzzy(needle, x) }, groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), entries(), @@ -50,11 +48,8 @@ export function useFilteredList(props: FilteredListProps) { { initialValue: empty }, ) - const flat = createMemo(() => { - return pipe( - grouped.latest || [], - flatMap((x) => x.items), - ) + const flat = createMemo(() => { + return (grouped.latest || []).flatMap((item) => item.items) }) function initialActive() { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 7f4a4020ad3..bb854dd1399 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -128,6 +128,9 @@ export const dict: Record = { "ui.message.copy": "Copy", "ui.message.copyMessage": "Copy message", "ui.message.copyResponse": "Copy response", + "ui.message.copyResponsePlain": "Copy plain text", + "ui.message.copyResponseRich": "Copy rich text", + "ui.message.copyOptions": "Copy options", "ui.message.copied": "Copied", "ui.message.interrupted": "Interrupted", "ui.message.queued": "Queued", diff --git a/packages/util/src/session-transcript.ts b/packages/util/src/session-transcript.ts new file mode 100644 index 00000000000..1c4677b597b --- /dev/null +++ b/packages/util/src/session-transcript.ts @@ -0,0 +1,89 @@ +type Session = { + id: string + title: string + time: { + created: number + updated: number + } +} + +type Message = { + role: "user" | "assistant" + agent?: string + modelID?: string + time: { + created?: number + completed?: number + } +} + +type Part = { + type: string + synthetic?: boolean + text?: string + tool?: string + state?: { + input?: unknown + output?: string + error?: string + status: "pending" | "running" | "completed" | "error" + } +} + +const titlecase = (value: string) => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value) + +const formatAssistant = (msg: Message) => { + const duration = + msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" + const agent = msg.agent ? titlecase(msg.agent) : "Assistant" + const model = msg.modelID ?? "" + return `## Assistant (${agent}${model ? ` · ${model}` : ""}${duration ? ` · ${duration}` : ""})\n\n` +} + +const formatPart = (part: Part) => { + if (part.type === "text" && part.text && !part.synthetic) return `${part.text}\n\n` + if (part.type === "reasoning" && part.text) return `_Thinking:_\n\n${part.text}\n\n` + if (part.type !== "tool" || !part.tool || !part.state) return "" + + const input = part.state.input + ? `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n` + : "" + const output = + part.state.status === "completed" && part.state.output + ? `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n` + : "" + const error = + part.state.status === "error" && part.state.error ? `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n` : "" + return `**Tool: ${part.tool}**\n${input}${output}${error}\n` +} + +const formatMessage = (msg: Message, parts: Part[]) => { + const header = msg.role === "assistant" ? formatAssistant(msg) : "## User\n\n" + return `${header}${parts.map(formatPart).join("")}` +} + +export const formatSessionTranscript = ( + session: Session, + rows: Array<{ + info: Message + parts: Part[] + }>, +) => { + const header = [ + `# ${session.title}`, + "", + `**Session ID:** ${session.id}`, + `**Created:** ${new Date(session.time.created).toLocaleString()}`, + `**Updated:** ${new Date(session.time.updated).toLocaleString()}`, + "", + "---", + "", + ].join("\n") + + const body = rows + .map((row) => `${formatMessage(row.info, row.parts)}---\n`) + .join("\n") + .trimEnd() + + return `${header}${body ? `\n${body}` : ""}` +} diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 54c15e8621b..8ca10dfdd77 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -153,6 +153,17 @@ The OpenCode desktop app prompt input supports common Readline/Emacs-style short --- +## Desktop chat navigation shortcuts + +These shortcuts are available in the desktop app command system. + +| Shortcut | Action | +| --------------------- | -------------------------- | +| `mod+shift+arrowup` | Jump to top of the chat | +| `mod+shift+arrowdown` | Jump to bottom of the chat | + +--- + ## Shift+Enter Some terminals don't send modifier keys with Enter by default. You may need to configure your terminal to send `Shift+Enter` as an escape sequence. diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 34e3626499c..7f993205193 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1890,7 +1890,7 @@ You can use any OpenAI-compatible provider with opencode. Most modern AI provide ``` Here are the configuration options: - - **npm**: AI SDK package to use, `@ai-sdk/openai-compatible` for OpenAI-compatible providers + - **npm**: AI SDK package to use, `@ai-sdk/openai-compatible` for OpenAI-compatible providers (for `/v1/chat/completions`). If your provider/model uses `/v1/responses`, use `@ai-sdk/openai`. - **name**: Display name in UI. - **models**: Available models. - **options.baseURL**: API endpoint URL. @@ -1957,5 +1957,5 @@ If you are having trouble with configuring a provider, check the following: 2. For custom providers, check the opencode config and: - Make sure the provider ID used in the `/connect` command matches the ID in your opencode config. - - The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible`. + - The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible` (for `/v1/chat/completions`); if a model uses `/v1/responses`, use `@ai-sdk/openai`. For mixed setups under one provider, you can override per model via `provider.npm`. - Check correct API endpoint is used in the `options.baseURL` field. diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 9c1616876d7..571b730242c 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -1845,7 +1845,7 @@ Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、G ``` 以下是配置选项说明: - - **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible` + - **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible`(适用于 `/v1/chat/completions`)。如果你的提供商/模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。 - **name**:在 UI 中显示的名称。 - **models**:可用模型。 - **options.baseURL**:API 端点 URL。 @@ -1911,5 +1911,5 @@ Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、G 2. 对于自定义提供商,请检查 OpenCode 配置并确认: - `/connect` 命令中使用的提供商 ID 与 OpenCode 配置中的 ID 一致。 - - 使用了正确的 npm 包。例如,Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible`。 + - 使用了正确的 npm 包。例如,Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible`(`/v1/chat/completions`);如果模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。同一 provider 混用时,可在模型下设置 `provider.npm` 覆盖默认值。 - `options.baseURL` 字段中的 API 端点地址正确。 diff --git a/script/check-preload.ts b/script/check-preload.ts new file mode 100644 index 00000000000..7e2fd6828c2 --- /dev/null +++ b/script/check-preload.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env bun + +import path from "path" + +const missing: string[] = [] + +for await (const file of new Bun.Glob("**/bunfig.toml").scan(".")) { + if (file.includes("/node_modules/")) continue + const text = await Bun.file(file).text() + const preload = [...text.matchAll(/preload\s*=\s*\[([\s\S]*?)\]/g)].flatMap((x) => { + if (!x[1]) return [] + return [...x[1].matchAll(/"([^"]+)"/g)].flatMap((y) => (y[1] ? [y[1]] : [])) + }) + + for (const item of preload) { + const target = path.resolve(path.dirname(file), item) + if (await Bun.file(target).exists()) continue + missing.push(`${file}: ${item}`) + } +} + +if (missing.length === 0) { + console.log("All Bun preload files exist") + process.exit(0) +} + +console.error("Missing Bun preload files:") +for (const item of missing) console.error(`- ${item}`) +process.exit(1)