From 802416639b9ef55220340db2a82dde4ddf7ff837 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 11 Mar 2026 16:09:17 +0800 Subject: [PATCH 01/10] ci: setup node in tauri build --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) 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 From 968dc993209031f20f814b37d1fa582c4c96acec Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 11 Mar 2026 08:35:06 +0000 Subject: [PATCH 02/10] chore: squash all-work branch changes --- .github/pull_request_template.md | 50 ++- bun.lock | 18 + package.json | 2 +- packages/app/e2e/actions.ts | 14 + .../app/e2e/projects/projects-switch.spec.ts | 17 +- packages/app/e2e/projects/workspaces.spec.ts | 280 +++++++++++++- packages/app/e2e/selectors.ts | 5 + packages/app/src/app.tsx | 1 + .../src/components/dialog-edit-project.tsx | 5 +- .../dialog-open-project.helpers.test.ts | 56 +++ .../components/dialog-open-project.helpers.ts | 73 ++++ .../src/components/dialog-open-project.tsx | 299 +++++++++++++++ .../components/dialog-select-model-unpaid.tsx | 45 ++- .../src/components/dialog-select-model.tsx | 50 ++- .../src/components/dialog-select-session.tsx | 219 +++++++++++ .../app/src/components/dialog-settings.tsx | 11 +- .../src/components/session/session-header.tsx | 44 +++ .../app/src/components/settings-general.tsx | 142 ++++++- .../app/src/components/settings-keybinds.tsx | 2 +- .../app/src/components/settings-models.tsx | 89 ++++- packages/app/src/context/command.tsx | 2 +- .../app/src/context/global-sync/bootstrap.ts | 6 +- packages/app/src/context/layout.test.ts | 28 +- packages/app/src/context/layout.tsx | 72 +--- packages/app/src/context/local.tsx | 76 +++- packages/app/src/context/models.tsx | 48 +++ packages/app/src/context/platform.tsx | 17 +- packages/app/src/context/server.test.ts | 19 + packages/app/src/context/server.tsx | 12 +- packages/app/src/context/settings.tsx | 11 + packages/app/src/i18n/en.ts | 75 +++- packages/app/src/index.css | 15 + packages/app/src/pages/directory-layout.tsx | 30 ++ packages/app/src/pages/layout.tsx | 358 ++++++++++++++++-- packages/app/src/pages/layout/deep-links.ts | 37 +- packages/app/src/pages/layout/helpers.test.ts | 90 ++++- packages/app/src/pages/layout/helpers.ts | 117 +++++- .../app/src/pages/layout/sidebar-items.tsx | 4 +- .../app/src/pages/layout/sidebar-project.tsx | 29 ++ .../app/src/pages/layout/sidebar-shell.tsx | 24 +- .../src/pages/layout/sidebar-workspace.tsx | 19 + packages/app/src/pages/session.tsx | 22 ++ .../app/src/pages/session/helpers.test.ts | 29 +- packages/app/src/pages/session/helpers.ts | 14 +- .../src/pages/session/message-timeline.tsx | 36 +- .../app/src/pages/session/terminal-panel.tsx | 17 + .../pages/session/use-session-commands.tsx | 192 ++++++++-- packages/desktop/package.json | 2 + packages/desktop/scripts/predev.ts | 28 +- .../src-tauri/capabilities/default.json | 3 +- packages/desktop/src-tauri/src/constants.rs | 1 + packages/desktop/src-tauri/src/lib.rs | 206 +++++++++- packages/desktop/src-tauri/src/server.rs | 82 +++- .../src-tauri/tauri.session-modal.conf.json | 14 + packages/desktop/src/bindings.ts | 3 + packages/desktop/src/i18n/en.ts | 5 + packages/desktop/src/index.tsx | 50 ++- packages/desktop/src/menu.ts | 39 ++ packages/opencode/script/preload.js | 1 + packages/opencode/src/cli/cmd/desktop.ts | 27 ++ .../cmd/tui/component/prompt/autocomplete.tsx | 3 +- packages/opencode/src/config/config.ts | 217 ++++++++++- packages/opencode/src/index.ts | 2 + packages/opencode/src/project/project.ts | 134 ++++++- .../opencode/src/server/routes/session.ts | 11 +- packages/opencode/src/session/index.ts | 14 +- packages/opencode/src/util/which.ts | 9 +- packages/opencode/test/config/config.test.ts | 13 + .../opencode/test/project/project.test.ts | 62 +++ packages/script/src/index.ts | 8 +- packages/script/src/version.test.ts | 29 ++ packages/script/src/version.ts | 19 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 3 +- packages/sdk/js/src/v2/gen/types.gen.ts | 3 +- packages/session-modal/.gitignore | 2 + packages/session-modal/README.md | 28 ++ packages/session-modal/index.html | 12 + packages/session-modal/package.json | 25 ++ .../session-modal/src/desktop-bindings.ts | 10 + packages/session-modal/src/main.tsx | 292 ++++++++++++++ packages/session-modal/src/styles.css | 215 +++++++++++ packages/session-modal/tsconfig.json | 22 ++ packages/session-modal/vite.config.ts | 14 + .../ui/src/components/markdown-copy.test.ts | 93 +++++ packages/ui/src/components/markdown-copy.ts | 55 +++ .../src/components/markdown-file-ref.test.ts | 49 +++ .../ui/src/components/markdown-file-ref.ts | 58 +++ packages/ui/src/components/markdown.css | 19 + packages/ui/src/components/markdown.tsx | 132 +++++-- packages/ui/src/components/message-part.css | 21 + packages/ui/src/components/message-part.tsx | 183 +++++++-- packages/ui/src/components/session-turn.tsx | 287 +++++++------- packages/ui/src/context/data.tsx | 4 + packages/ui/src/context/dialog.tsx | 13 +- packages/ui/src/hooks/filter-search.test.ts | 27 ++ packages/ui/src/hooks/filter-search.ts | 44 +++ packages/ui/src/hooks/use-filtered-list.tsx | 23 +- packages/ui/src/i18n/en.ts | 3 + packages/util/src/session-transcript.ts | 89 +++++ packages/web/src/content/docs/keybinds.mdx | 11 + script/check-preload.ts | 29 ++ 101 files changed, 5091 insertions(+), 478 deletions(-) create mode 100644 packages/app/src/components/dialog-open-project.helpers.test.ts create mode 100644 packages/app/src/components/dialog-open-project.helpers.ts create mode 100644 packages/app/src/components/dialog-open-project.tsx create mode 100644 packages/app/src/components/dialog-select-session.tsx create mode 100644 packages/app/src/context/server.test.ts create mode 100644 packages/desktop/src-tauri/tauri.session-modal.conf.json create mode 100644 packages/opencode/script/preload.js create mode 100644 packages/opencode/src/cli/cmd/desktop.ts create mode 100644 packages/script/src/version.test.ts create mode 100644 packages/script/src/version.ts create mode 100644 packages/session-modal/.gitignore create mode 100644 packages/session-modal/README.md create mode 100644 packages/session-modal/index.html create mode 100644 packages/session-modal/package.json create mode 100644 packages/session-modal/src/desktop-bindings.ts create mode 100644 packages/session-modal/src/main.tsx create mode 100644 packages/session-modal/src/styles.css create mode 100644 packages/session-modal/tsconfig.json create mode 100644 packages/session-modal/vite.config.ts create mode 100644 packages/ui/src/components/markdown-copy.test.ts create mode 100644 packages/ui/src/components/markdown-copy.ts create mode 100644 packages/ui/src/components/markdown-file-ref.test.ts create mode 100644 packages/ui/src/components/markdown-file-ref.ts create mode 100644 packages/ui/src/hooks/filter-search.test.ts create mode 100644 packages/ui/src/hooks/filter-search.ts create mode 100644 packages/util/src/session-transcript.ts create mode 100644 script/check-preload.ts 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/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-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..2676a5ba5c5 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 = { @@ -465,6 +480,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 @@ -537,15 +553,20 @@ export function AssistantParts(props: { return ( - - - + {(msg) => ( + + {(p) => ( + + )} + + )} ) })()} @@ -680,6 +701,7 @@ export function Message(props: MessageProps) { message={assistantMessage() as AssistantMessage} parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} + assistantCopyMode={props.assistantCopyMode} showReasoningSummaries={props.showReasoningSummaries} /> )} @@ -692,6 +714,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage parts: PartType[] showAssistantCopyPartID?: string | null + assistantCopyMode?: MarkdownCopyMode showReasoningSummaries?: boolean }) { const emptyTools: ToolPart[] = [] @@ -747,11 +770,14 @@ export function AssistantMessageDisplay(props: { return ( - + {(p) => ( + + )} ) })()} @@ -1078,6 +1104,7 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} + assistantCopyMode={props.assistantCopyMode} turnDurationMs={props.turnDurationMs} /> @@ -1120,7 +1147,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 +1172,21 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre {`\u202A${getDirectory(props.path)}\u202C`} - {getFilename(props.path)} + {getFilename(props.path)}} + > + +
@@ -1287,38 +1333,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 +1781,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 +1822,7 @@ ToolRegistry.register({ openProjectFile(path(), data.directory, data.openFilePath)} actions={ @@ -1761,6 +1855,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 +1890,10 @@ ToolRegistry.register({ } > - + openProjectFile(path(), data.directory, data.openFilePath)} + >
(props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1908,7 +2007,16 @@ ToolRegistry.register({ {`\u202A${getDirectory(file.relativePath)}\u202C`} - {getFilename(file.relativePath)} +
@@ -1991,6 +2099,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/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/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) From db57fe6193322941f71b11c5b0ccb8f03d085804 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:22:33 +0100 Subject: [PATCH 03/10] fix(app): make error tool card respect settings (#17005) --- packages/ui/src/components/message-part.tsx | 2 +- packages/ui/src/components/tool-error-card.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 0815c333367..afe4109e150 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1190,7 +1190,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
) } - return + return }} 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", From 9d3b080cfc65df69c431ac3f4c914d74af192d01 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 11 Mar 2026 11:37:07 +0000 Subject: [PATCH 04/10] feat(app): add command palette project close action --- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + packages/app/src/pages/layout.tsx | 12 ++++++++++++ 18 files changed, 29 insertions(+) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index c9b92db501d..00041f08e17 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "داكن", "command.sidebar.toggle": "تبديل الشريط الجانبي", "command.project.open": "فتح مشروع", + "command.project.close": "إغلاق مشروع", "command.provider.connect": "اتصال بموفر", "command.server.switch": "تبديل الخادم", "command.settings.open": "فتح الإعدادات", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 951edf0a5c0..48fcd6ca542 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "Escuro", "command.sidebar.toggle": "Alternar barra lateral", "command.project.open": "Abrir projeto", + "command.project.close": "Fechar projeto", "command.provider.connect": "Conectar provedor", "command.server.switch": "Trocar servidor", "command.settings.open": "Abrir configurações", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e8bdcde596e..a2ed2c4526c 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Prikaži/sakrij bočnu traku", "command.project.open": "Otvori projekat", + "command.project.close": "Zatvori projekat", "command.provider.connect": "Poveži provajdera", "command.server.switch": "Promijeni server", "command.settings.open": "Otvori postavke", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 5ea52a5c92c..da14fb8fc9b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Skift sidebjælke", "command.project.open": "Åbn projekt", + "command.project.close": "Luk projekt", "command.provider.connect": "Tilslut udbyder", "command.server.switch": "Skift server", "command.settings.open": "Åbn indstillinger", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a6cf8045c09..74aec54b1e2 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -25,6 +25,7 @@ export const dict = { "theme.scheme.dark": "Dunkel", "command.sidebar.toggle": "Seitenleiste umschalten", "command.project.open": "Projekt öffnen", + "command.project.close": "Projekt schließen", "command.provider.connect": "Anbieter verbinden", "command.server.switch": "Server wechseln", "command.settings.open": "Einstellungen öffnen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 97a572f1cf2..f58eec57c0c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Toggle sidebar", "command.project.open": "Open project", + "command.project.close": "Close project", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 77ef7970c43..793b7640911 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Alternar barra lateral", "command.project.open": "Abrir proyecto", + "command.project.close": "Cerrar proyecto", "command.provider.connect": "Conectar proveedor", "command.server.switch": "Cambiar servidor", "command.settings.open": "Abrir ajustes", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index c887f9ee8b2..9f05596fc22 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "Sombre", "command.sidebar.toggle": "Basculer la barre latérale", "command.project.open": "Ouvrir un projet", + "command.project.close": "Fermer le projet", "command.provider.connect": "Connecter un fournisseur", "command.server.switch": "Changer de serveur", "command.settings.open": "Ouvrir les paramètres", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 9ddb6baf4a7..1f997cbb177 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "ダーク", "command.sidebar.toggle": "サイドバーの切り替え", "command.project.open": "プロジェクトを開く", + "command.project.close": "プロジェクトを閉じる", "command.provider.connect": "プロバイダーに接続", "command.server.switch": "サーバーの切り替え", "command.settings.open": "設定を開く", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1e35106d1bc..559c00a550b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -25,6 +25,7 @@ export const dict = { "theme.scheme.dark": "다크", "command.sidebar.toggle": "사이드바 토글", "command.project.open": "프로젝트 열기", + "command.project.close": "프로젝트 닫기", "command.provider.connect": "공급자 연결", "command.server.switch": "서버 전환", "command.settings.open": "설정 열기", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index d9dac8ee550..d36fa304986 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -26,6 +26,7 @@ export const dict = { "command.sidebar.toggle": "Veksle sidepanel", "command.project.open": "Åpne prosjekt", + "command.project.close": "Lukk prosjekt", "command.provider.connect": "Koble til leverandør", "command.server.switch": "Bytt server", "command.settings.open": "Åpne innstillinger", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index b63fe5ee409..901fa943ba6 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -21,6 +21,7 @@ export const dict = { "theme.scheme.dark": "Ciemny", "command.sidebar.toggle": "Przełącz pasek boczny", "command.project.open": "Otwórz projekt", + "command.project.close": "Zamknij projekt", "command.provider.connect": "Połącz dostawcę", "command.server.switch": "Przełącz serwer", "command.settings.open": "Otwórz ustawienia", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index aadb926d270..72d057df1ec 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "Переключить боковую панель", "command.project.open": "Открыть проект", + "command.project.close": "Закрыть проект", "command.provider.connect": "Подключить провайдера", "command.server.switch": "Переключить сервер", "command.settings.open": "Открыть настройки", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 6a25a356a96..23cfebcdafe 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -23,6 +23,7 @@ export const dict = { "command.sidebar.toggle": "สลับแถบข้าง", "command.project.open": "เปิดโปรเจกต์", + "command.project.close": "ปิดโปรเจกต์", "command.provider.connect": "เชื่อมต่อผู้ให้บริการ", "command.server.switch": "สลับเซิร์ฟเวอร์", "command.settings.open": "เปิดการตั้งค่า", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 50e55983247..afc8abbe682 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -27,6 +27,7 @@ export const dict = { "command.sidebar.toggle": "Kenar çubuğunu aç/kapat", "command.project.open": "Proje aç", + "command.project.close": "Projeyi kapat", "command.provider.connect": "Sağlayıcı bağla", "command.server.switch": "Sunucu değiştir", "command.settings.open": "Ayarları aç", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 1f88a822235..cd9fd60f468 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -28,6 +28,7 @@ export const dict = { "command.sidebar.toggle": "切换侧边栏", "command.project.open": "打开项目", + "command.project.close": "关闭项目", "command.provider.connect": "连接提供商", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index a75e8ef47a6..c22b3937ce5 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -27,6 +27,7 @@ export const dict = { "command.sidebar.toggle": "切換側邊欄", "command.project.open": "開啟專案", + "command.project.close": "關閉專案", "command.provider.connect": "連接提供者", "command.server.switch": "切換伺服器", "command.settings.open": "開啟設定", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 70114623e33..ee24a27d2bc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -935,6 +935,18 @@ export default function Layout(props: ParentProps) { keybind: "mod+o", onSelect: () => chooseProject(), }, + { + id: "project.close", + title: language.t("command.project.close"), + description: currentProject()?.worktree, + category: language.t("command.category.project"), + disabled: !currentProject(), + onSelect: () => { + const project = currentProject() + if (!project) return + closeProject(project.worktree) + }, + }, { id: "provider.connect", title: language.t("command.provider.connect"), From 7291e282738a3745330e20180371bd7c6fb11e5b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 11 Mar 2026 18:19:17 +0530 Subject: [PATCH 05/10] perf(app): trim session render work (#16987) --- .../src/pages/session/message-timeline.tsx | 1 + packages/ui/src/components/message-part.tsx | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 6463e7cbbe3..223539092f5 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -764,6 +764,7 @@ export function MessageTimeline(props: { "min-w-0 w-full max-w-full": true, "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} + style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }} > 0}>
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index afe4109e150..65b6229d6ff 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -437,8 +437,8 @@ function groupParts(parts: { messageID: string; part: PartType }[]) { return result } -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) { @@ -474,6 +474,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 +514,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 +534,23 @@ 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 ( - + @@ -695,6 +702,7 @@ export function AssistantMessageDisplay(props: { showReasoningSummaries?: boolean }) { const emptyTools: ToolPart[] = [] + const part = createMemo(() => index(props.parts)) const grouped = createMemo( () => groupParts( @@ -723,7 +731,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,16 +747,16 @@ 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 ( - + From ca0e3c2251444a34445437585ff9f946d964e954 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 11 Mar 2026 14:45:34 +0000 Subject: [PATCH 06/10] fix(app): confirm project close actions --- .../app/src/components/dialog-select-file.tsx | 4 +- packages/app/src/context/command.tsx | 1 + packages/app/src/i18n/en.ts | 6 + packages/app/src/pages/layout.tsx | 49 ++++---- .../src/pages/layout/project-close.test.ts | 58 +++++++++ .../app/src/pages/layout/project-close.tsx | 118 ++++++++++++++++++ 6 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 packages/app/src/pages/layout/project-close.test.ts create mode 100644 packages/app/src/pages/layout/project-close.tsx 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/context/command.tsx b/packages/app/src/context/command.tsx index 03bd6318dab..8a5568e2cf5 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -53,6 +53,7 @@ export interface CommandOption { id: string title: string description?: string + keywords?: string category?: string keybind?: KeybindConfig slash?: string diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f58eec57c0c..9ddd9bbdea6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -24,6 +24,7 @@ export const dict = { "command.sidebar.toggle": "Toggle sidebar", "command.project.open": "Open project", "command.project.close": "Close project", + "command.project.close.description": "Close the current project", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", @@ -348,6 +349,11 @@ export const dict = { "dialog.project.edit.worktree.startup": "Workspace startup script", "dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).", "dialog.project.edit.worktree.startup.placeholder": "e.g. bun install", + "dialog.project.close.title": "Close project", + "dialog.project.close.confirm": 'Close project "{{name}}"?', + "dialog.project.close.sessions.one": "This project has 1 active session.", + "dialog.project.close.sessions.many": "This project has {{count}} active sessions.", + "dialog.project.close.note": "You can reopen the project later from the command palette or sidebar.", "dialog.releaseNotes.action.getStarted": "Get started", "dialog.releaseNotes.action.next": "Next", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4039d384a93..21c4c56686f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -83,6 +83,7 @@ import { import { workspaceOpenState } from "./layout/sidebar-workspace-helpers" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" +import { askProjectClose, closeProject as runProjectClose } from "./layout/project-close" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -962,13 +963,14 @@ export default function Layout(props: ParentProps) { { id: "project.close", title: language.t("command.project.close"), - description: currentProject()?.worktree, + description: language.t("command.project.close.description"), + keywords: "remove workspace", category: language.t("command.category.project"), disabled: !currentProject(), onSelect: () => { const project = currentProject() if (!project) return - closeProject(project.worktree) + void askClose(project) }, }, { @@ -1329,30 +1331,25 @@ export default function Layout(props: ParentProps) { setWorkspaceName(directory, next, projectId, branch) } - function closeProject(directory: string) { - const list = layout.projects.list() - const index = list.findIndex((x) => x.worktree === directory) - const active = currentProject()?.worktree === directory - if (index === -1) return - const next = list[index + 1] - - if (!active) { - layout.projects.close(directory) - return - } - - if (!next) { - layout.projects.close(directory) - navigate("/") - return - } + const close = (directory: string) => + runProjectClose({ + directory, + list: layout.projects.list(), + current: currentProject()?.worktree, + close: layout.projects.close, + navigate, + open: navigateToProject, + }) - navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`) - layout.projects.close(directory) - queueMicrotask(() => { - void navigateToProject(next.worktree) + const askClose = (project: LocalProject) => + askProjectClose({ + project, + t: language.t, + show: dialog.show, + dismiss: dialog.close, + onClose: close, + list: globalSDK.client.session.list, }) - } function toggleProjectWorkspaces(project: LocalProject) { const enabled = layout.sidebar.workspaces(project.worktree)() @@ -1894,7 +1891,7 @@ export default function Layout(props: ParentProps) { onProjectFocus: (worktree) => aim.activate(worktree), navigateToProject, openSidebar: () => layout.sidebar.open(), - closeProject, + closeProject: close, showEditProjectDialog, toggleProjectWorkspaces, workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), @@ -2028,7 +2025,7 @@ export default function Layout(props: ParentProps) { closeProject(p().worktree)} + onSelect={() => void askClose(p())} > {language.t("common.close")} diff --git a/packages/app/src/pages/layout/project-close.test.ts b/packages/app/src/pages/layout/project-close.test.ts new file mode 100644 index 00000000000..646b91c2617 --- /dev/null +++ b/packages/app/src/pages/layout/project-close.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { closeProject, projectCloseBody } from "./project-close" + +const t = (key: string, vars?: Record) => { + if (key === "dialog.project.close.note") return "Reopen later." + if (key === "dialog.project.close.sessions.one") return "1 active session." + if (key === "dialog.project.close.sessions.many") return `${vars?.count} active sessions.` + return key +} + +describe("project close copy", () => { + test("shows note when there are no active sessions", () => { + expect(projectCloseBody(0, t)).toBe("Reopen later.") + }) + + test("shows singular session warning with note", () => { + expect(projectCloseBody(1, t)).toBe("1 active session. Reopen later.") + }) + + test("shows plural session warning with note", () => { + expect(projectCloseBody(3, t)).toBe("3 active sessions. Reopen later.") + }) +}) + +describe("closeProject", () => { + test("closes inactive project without navigation", () => { + const calls = { close: [] as string[], nav: [] as string[], open: [] as string[] } + closeProject({ + directory: "/b", + list: [ + { worktree: "/a", expanded: false }, + { worktree: "/b", expanded: false }, + ], + current: "/a", + close: (directory) => calls.close.push(directory), + navigate: (href) => calls.nav.push(href), + open: (directory) => { + calls.open.push(directory) + }, + }) + expect(calls).toEqual({ close: ["/b"], nav: [], open: [] }) + }) + + test("navigates home when closing the active last project", () => { + const calls = { close: [] as string[], nav: [] as string[], open: [] as string[] } + closeProject({ + directory: "/a", + list: [{ worktree: "/a", expanded: false }], + current: "/a", + close: (directory) => calls.close.push(directory), + navigate: (href) => calls.nav.push(href), + open: (directory) => { + calls.open.push(directory) + }, + }) + expect(calls).toEqual({ close: ["/a"], nav: ["/"], open: [] }) + }) +}) diff --git a/packages/app/src/pages/layout/project-close.tsx b/packages/app/src/pages/layout/project-close.tsx new file mode 100644 index 00000000000..ad48a28cd6a --- /dev/null +++ b/packages/app/src/pages/layout/project-close.tsx @@ -0,0 +1,118 @@ +import { base64Encode } from "@opencode-ai/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { Dialog } from "@opencode-ai/ui/dialog" +import { createMemo, Show, type JSX } from "solid-js" +import type { Session } from "@opencode-ai/sdk/v2/client" +import type { LocalProject } from "@/context/layout" +import { displayName } from "./helpers" + +type T = (key: string, vars?: Record) => string + +type Nav = { + directory: string + list: LocalProject[] + current?: string + close: (directory: string) => void + navigate: (href: string) => void + open: (directory: string) => Promise | void +} + +type Ask = { + project: LocalProject + t: T + show: (cb: () => JSX.Element) => void + dismiss: () => void + onClose: (directory: string) => void + list: (input: { directory: string }) => Promise<{ data?: Session[] | null }> +} + +type DialogProps = { + count: number + project: LocalProject + t: T + onCancel: () => void + onClose: () => void +} + +export function projectCloseBody(count: number, t: T) { + if (count === 0) return t("dialog.project.close.note") + if (count === 1) return `${t("dialog.project.close.sessions.one")} ${t("dialog.project.close.note")}` + return `${t("dialog.project.close.sessions.many", { count })} ${t("dialog.project.close.note")}` +} + +export function closeProject(input: Nav) { + const index = input.list.findIndex((x) => x.worktree === input.directory) + const active = input.current === input.directory + if (index === -1) return + const next = input.list[index + 1] + + if (!active) { + input.close(input.directory) + return + } + + if (!next) { + input.close(input.directory) + input.navigate("/") + return + } + + input.navigate(`/${base64Encode(next.worktree)}/session`) + input.close(input.directory) + queueMicrotask(() => { + void input.open(next.worktree) + }) +} + +async function count(project: LocalProject, list: Ask["list"]) { + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const all = await Promise.all( + dirs.map((directory) => + list({ directory }) + .then((x) => x.data ?? []) + .catch(() => []), + ), + ) + return all.flat().filter((session) => session.time.archived === undefined).length +} + +export async function askProjectClose(input: Ask) { + const total = await count(input.project, input.list) + input.show(() => ( + { + input.dismiss() + input.onClose(input.project.worktree) + }} + /> + )) +} + +export function DialogCloseProject(props: DialogProps) { + const name = createMemo(() => displayName(props.project)) + + return ( + +
+
+ + {props.t("dialog.project.close.confirm", { name: name() })} + + {projectCloseBody(props.count, props.t)} +
+
+ + +
+
+
+ ) +} From 0f6bc8ae71d18645212cb954bd210f047b5416d8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:24:55 -0500 Subject: [PATCH 07/10] tweak: adjust way skills are presented to agent to increase likelyhood of skill invocations. (#17053) --- packages/opencode/src/session/prompt.ts | 7 ++++++- packages/opencode/src/session/system.ts | 16 ++++++++++++++ packages/opencode/src/skill/skill.ts | 23 ++++++++++++++++++++ packages/opencode/src/tool/skill.ts | 28 +++++-------------------- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 655afd2b14d..54adf1104a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -650,7 +650,12 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) // Build system prompt, adding structured output instruction if needed - const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())] + const skills = await SystemPrompt.skills(agent) + const system = [ + ...(await SystemPrompt.environment(model)), + ...(skills ? [skills] : []), + ...(await InstructionPrompt.system()), + ] const format = lastUser.format ?? { type: "text" } if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a61dd8cba55..0f0f6b51b3c 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -10,6 +10,9 @@ import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_CODEX from "./prompt/codex_header.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" import type { Provider } from "@/provider/provider" +import type { Agent } from "@/agent/agent" +import { PermissionNext } from "@/permission/next" +import { Skill } from "@/skill" export namespace SystemPrompt { export function instructions() { @@ -34,6 +37,7 @@ export namespace SystemPrompt { `Here is some useful information about the environment you are running in:`, ``, ` Working directory: ${Instance.directory}`, + ` Workspace root folder: ${Instance.worktree}`, ` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, @@ -51,4 +55,16 @@ export namespace SystemPrompt { ].join("\n"), ] } + + export async function skills(agent: Agent.Info) { + if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return + + const list = await Skill.available(agent) + + return [ + "Skills provide specialized instructions and workflows for specific tasks.", + "Use the skill tool to load a skill when a task matches its description.", + list.length === 0 ? "No skills are currently available." : "\n" + Skill.fmt(list), + ].join("\n") + } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index c474c94dd74..09cc787c802 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -13,6 +13,9 @@ import { Bus } from "@/bus" import { Session } from "@/session" import { Discovery } from "./discovery" import { Glob } from "../util/glob" +import { pathToFileURL } from "url" +import type { Agent } from "@/agent/agent" +import { PermissionNext } from "@/permission/next" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -186,4 +189,24 @@ export namespace Skill { export async function dirs() { return state().then((x) => x.dirs) } + + export async function available(agent?: Agent.Info) { + const list = await all() + if (!agent) return list + return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") + } + + export function fmt(list: Info[]) { + return [ + "", + ...list.flatMap((skill) => [ + ` `, + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + ` `, + ]), + "", + ].join("\n") + } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 8fcfb592dee..6d2a48b0ed2 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -3,24 +3,14 @@ import { pathToFileURL } from "url" import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" -import { PermissionNext } from "../permission/next" import { Ripgrep } from "../file/ripgrep" import { iife } from "@/util/iife" export const SkillTool = Tool.define("skill", async (ctx) => { - const skills = await Skill.all() - - // Filter skills by agent permissions if agent provided - const agent = ctx?.agent - const accessibleSkills = agent - ? skills.filter((skill) => { - const rule = PermissionNext.evaluate("skill", skill.name, agent.permission) - return rule.action !== "deny" - }) - : skills + const list = await Skill.available(ctx?.agent) const description = - accessibleSkills.length === 0 + list.length === 0 ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available." : [ "Load a specialized skill that provides domain-specific instructions and workflows.", @@ -34,18 +24,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { "The following skills provide specialized sets of instructions for particular tasks", "Invoke this tool to load a skill when a task matches one of the available skills listed below:", "", - "", - ...accessibleSkills.flatMap((skill) => [ - ` `, - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - ` `, - ]), - "", + Skill.fmt(list), ].join("\n") - const examples = accessibleSkills + const examples = list .map((skill) => `'${skill.name}'`) .slice(0, 3) .join(", ") @@ -62,7 +44,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { const skill = await Skill.get(params.name) if (!skill) { - const available = await Skill.all().then((x) => Object.keys(x).join(", ")) + const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", ")) throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) } From 9c585bb58ba98826cd5f7bf596cb65f411d378a4 Mon Sep 17 00:00:00 2001 From: xinxin <45682184+andyWang1688@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:35:16 +0800 Subject: [PATCH 08/10] docs(providers): clarify npm choice for chat vs responses APIs (#16974) Co-authored-by: wangxinxin --- packages/web/src/content/docs/providers.mdx | 4 ++-- packages/web/src/content/docs/zh-cn/providers.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 端点地址正确。 From 52e049ce62b19e1900ef73091e35101edeb6b726 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 11 Mar 2026 16:18:57 +0000 Subject: [PATCH 09/10] fix(app): align project close command behavior --- packages/app/src/pages/layout.tsx | 4 ++-- packages/app/src/pages/layout/project-close.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 21c4c56686f..e02e0814afe 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -970,7 +970,7 @@ export default function Layout(props: ParentProps) { onSelect: () => { const project = currentProject() if (!project) return - void askClose(project) + close(project.worktree) }, }, { @@ -1337,7 +1337,7 @@ export default function Layout(props: ParentProps) { list: layout.projects.list(), current: currentProject()?.worktree, close: layout.projects.close, - navigate, + go: navigateWithSidebarReset, open: navigateToProject, }) diff --git a/packages/app/src/pages/layout/project-close.tsx b/packages/app/src/pages/layout/project-close.tsx index ad48a28cd6a..6849a961ac3 100644 --- a/packages/app/src/pages/layout/project-close.tsx +++ b/packages/app/src/pages/layout/project-close.tsx @@ -13,7 +13,8 @@ type Nav = { list: LocalProject[] current?: string close: (directory: string) => void - navigate: (href: string) => void + go?: (href: string) => void + navigate?: (href: string) => void open: (directory: string) => Promise | void } @@ -41,6 +42,7 @@ export function projectCloseBody(count: number, t: T) { } export function closeProject(input: Nav) { + const go = input.go ?? input.navigate const index = input.list.findIndex((x) => x.worktree === input.directory) const active = input.current === input.directory if (index === -1) return @@ -53,11 +55,11 @@ export function closeProject(input: Nav) { if (!next) { input.close(input.directory) - input.navigate("/") + go?.("/") return } - input.navigate(`/${base64Encode(next.worktree)}/session`) + go?.(`/${base64Encode(next.worktree)}/session`) input.close(input.directory) queueMicrotask(() => { void input.open(next.worktree) From 6189341581c129e38d6a640f98631e5131858c5e Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 11 Mar 2026 16:21:32 +0000 Subject: [PATCH 10/10] refactor(app): unify project close actions --- packages/app/src/pages/layout.tsx | 14 +--- .../src/pages/layout/project-close.test.ts | 23 +---- .../app/src/pages/layout/project-close.tsx | 83 ------------------- 3 files changed, 3 insertions(+), 117 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e02e0814afe..0518dc99bff 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -83,7 +83,7 @@ import { import { workspaceOpenState } from "./layout/sidebar-workspace-helpers" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" -import { askProjectClose, closeProject as runProjectClose } from "./layout/project-close" +import { closeProject as runProjectClose } from "./layout/project-close" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -1341,16 +1341,6 @@ export default function Layout(props: ParentProps) { open: navigateToProject, }) - const askClose = (project: LocalProject) => - askProjectClose({ - project, - t: language.t, - show: dialog.show, - dismiss: dialog.close, - onClose: close, - list: globalSDK.client.session.list, - }) - function toggleProjectWorkspaces(project: LocalProject) { const enabled = layout.sidebar.workspaces(project.worktree)() if (enabled) { @@ -2025,7 +2015,7 @@ export default function Layout(props: ParentProps) { void askClose(p())} + onSelect={() => close(p().worktree)} > {language.t("common.close")} diff --git a/packages/app/src/pages/layout/project-close.test.ts b/packages/app/src/pages/layout/project-close.test.ts index 646b91c2617..4e140514c7d 100644 --- a/packages/app/src/pages/layout/project-close.test.ts +++ b/packages/app/src/pages/layout/project-close.test.ts @@ -1,26 +1,5 @@ import { describe, expect, test } from "bun:test" -import { closeProject, projectCloseBody } from "./project-close" - -const t = (key: string, vars?: Record) => { - if (key === "dialog.project.close.note") return "Reopen later." - if (key === "dialog.project.close.sessions.one") return "1 active session." - if (key === "dialog.project.close.sessions.many") return `${vars?.count} active sessions.` - return key -} - -describe("project close copy", () => { - test("shows note when there are no active sessions", () => { - expect(projectCloseBody(0, t)).toBe("Reopen later.") - }) - - test("shows singular session warning with note", () => { - expect(projectCloseBody(1, t)).toBe("1 active session. Reopen later.") - }) - - test("shows plural session warning with note", () => { - expect(projectCloseBody(3, t)).toBe("3 active sessions. Reopen later.") - }) -}) +import { closeProject } from "./project-close" describe("closeProject", () => { test("closes inactive project without navigation", () => { diff --git a/packages/app/src/pages/layout/project-close.tsx b/packages/app/src/pages/layout/project-close.tsx index 6849a961ac3..8c7e0606604 100644 --- a/packages/app/src/pages/layout/project-close.tsx +++ b/packages/app/src/pages/layout/project-close.tsx @@ -1,12 +1,5 @@ import { base64Encode } from "@opencode-ai/util/encode" -import { Button } from "@opencode-ai/ui/button" -import { Dialog } from "@opencode-ai/ui/dialog" -import { createMemo, Show, type JSX } from "solid-js" -import type { Session } from "@opencode-ai/sdk/v2/client" import type { LocalProject } from "@/context/layout" -import { displayName } from "./helpers" - -type T = (key: string, vars?: Record) => string type Nav = { directory: string @@ -18,29 +11,6 @@ type Nav = { open: (directory: string) => Promise | void } -type Ask = { - project: LocalProject - t: T - show: (cb: () => JSX.Element) => void - dismiss: () => void - onClose: (directory: string) => void - list: (input: { directory: string }) => Promise<{ data?: Session[] | null }> -} - -type DialogProps = { - count: number - project: LocalProject - t: T - onCancel: () => void - onClose: () => void -} - -export function projectCloseBody(count: number, t: T) { - if (count === 0) return t("dialog.project.close.note") - if (count === 1) return `${t("dialog.project.close.sessions.one")} ${t("dialog.project.close.note")}` - return `${t("dialog.project.close.sessions.many", { count })} ${t("dialog.project.close.note")}` -} - export function closeProject(input: Nav) { const go = input.go ?? input.navigate const index = input.list.findIndex((x) => x.worktree === input.directory) @@ -65,56 +35,3 @@ export function closeProject(input: Nav) { void input.open(next.worktree) }) } - -async function count(project: LocalProject, list: Ask["list"]) { - const dirs = [project.worktree, ...(project.sandboxes ?? [])] - const all = await Promise.all( - dirs.map((directory) => - list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []), - ), - ) - return all.flat().filter((session) => session.time.archived === undefined).length -} - -export async function askProjectClose(input: Ask) { - const total = await count(input.project, input.list) - input.show(() => ( - { - input.dismiss() - input.onClose(input.project.worktree) - }} - /> - )) -} - -export function DialogCloseProject(props: DialogProps) { - const name = createMemo(() => displayName(props.project)) - - return ( - -
-
- - {props.t("dialog.project.close.confirm", { name: name() })} - - {projectCloseBody(props.count, props.t)} -
-
- - -
-
-
- ) -}