diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a888adbb2..3aeef82d62 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,5 @@ # web + desktop packages packages/app/ @adamdotdevin packages/tauri/ @adamdotdevin +packages/desktop/src-tauri/ @brendonovich packages/desktop/ @adamdotdevin diff --git a/bun.lock b/bun.lock index 9cbd90e446..9b0d0eccc6 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.49", + "version": "1.1.51", "bin": { "opencode": "./bin/opencode", }, @@ -309,7 +309,7 @@ "ai": "catalog:", "ai-gateway-provider": "2.3.1", "bonjour-service": "1.3.0", - "bun-pty": "0.4.4", + "bun-pty": "0.4.8", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", @@ -363,7 +363,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -383,7 +383,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.49", + "version": "1.1.51", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -394,7 +394,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -407,7 +407,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -449,7 +449,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "zod": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.49", + "version": "1.1.51", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -2108,7 +2108,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], + "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], diff --git a/nix/hashes.json b/nix/hashes.json index 32f6950042..fc964a8788 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=", - "aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=", - "aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=", - "x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU=" + "x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=", + "aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=", + "aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=", + "x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU=" } } diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 5f80d67c24..3467effa6b 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -21,7 +21,12 @@ import { import type { createSdk } from "./utils" export async function defocus(page: Page) { - await page.mouse.click(5, 5) + await page + .evaluate(() => { + const el = document.activeElement + if (el instanceof HTMLElement) el.blur() + }) + .catch(() => undefined) } export async function openPalette(page: Page) { @@ -68,14 +73,50 @@ export async function toggleSidebar(page: Page) { export async function openSidebar(page: Page) { if (!(await isSidebarClosed(page))) return + + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const visible = await button + .isVisible() + .then((x) => x) + .catch(() => false) + + if (visible) await button.click() + if (!visible) await toggleSidebar(page) + + const main = page.locator("main") + const opened = await expect(main) + .not.toHaveClass(/xl:border-l/, { timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (opened) return + await toggleSidebar(page) - await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) + await expect(main).not.toHaveClass(/xl:border-l/) } export async function closeSidebar(page: Page) { if (await isSidebarClosed(page)) return + + const button = page.getByRole("button", { name: /toggle sidebar/i }).first() + const visible = await button + .isVisible() + .then((x) => x) + .catch(() => false) + + if (visible) await button.click() + if (!visible) await toggleSidebar(page) + + const main = page.locator("main") + const closed = await expect(main) + .toHaveClass(/xl:border-l/, { timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (closed) return + await toggleSidebar(page) - await expect(page.locator("main")).toHaveClass(/xl:border-l/) + await expect(main).toHaveClass(/xl:border-l/) } export async function openSettings(page: Page) { @@ -182,13 +223,30 @@ export async function hoverSessionItem(page: Page, sessionID: string) { } export async function openSessionMoreMenu(page: Page, sessionID: string) { - const sessionEl = await hoverSessionItem(page, sessionID) + await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) - const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first() + const scroller = page.locator(".session-scroller").first() + await expect(scroller).toBeVisible() + await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + + const menu = page + .locator(dropdownMenuContentSelector) + .filter({ has: page.getByRole("menuitem", { name: /rename/i }) }) + .filter({ has: page.getByRole("menuitem", { name: /archive/i }) }) + .filter({ has: page.getByRole("menuitem", { name: /delete/i }) }) + .first() + + const opened = await menu + .isVisible() + .then((x) => x) + .catch(() => false) + + if (opened) return menu + + const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await menuTrigger.click() - const menu = page.locator(dropdownMenuContentSelector).first() await expect(menu).toBeVisible() return menu } diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index bace8cd591..ea41ed8516 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ -import { test as base, expect } from "@playwright/test" -import { seedProjects } from "./actions" +import { test as base, expect, type Page } from "@playwright/test" +import { cleanupTestProject, createTestProject, seedProjects } from "./actions" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" @@ -8,6 +8,14 @@ export const settingsKey = "settings.v3" type TestFixtures = { sdk: ReturnType gotoSession: (sessionID?: string) => Promise + withProject: ( + callback: (project: { + directory: string + slug: string + gotoSession: (sessionID?: string) => Promise + }) => Promise, + options?: { extra?: string[] }, + ) => Promise } type WorkerFixtures = { @@ -33,17 +41,7 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { - await seedProjects(page, { directory }) - await page.addInitScript(() => { - localStorage.setItem( - "opencode.global.dat:model", - JSON.stringify({ - recent: [{ providerID: "opencode", modelID: "big-pickle" }], - user: [], - variant: {}, - }), - ) - }) + await seedStorage(page, { directory }) const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) @@ -51,6 +49,39 @@ export const test = base.extend({ } await use(gotoSession) }, + withProject: async ({ page }, use) => { + await use(async (callback, options) => { + const directory = await createTestProject() + const slug = dirSlug(directory) + await seedStorage(page, { directory, extra: options?.extra }) + + const gotoSession = async (sessionID?: string) => { + await page.goto(sessionPath(directory, sessionID)) + await expect(page.locator(promptSelector)).toBeVisible() + } + + try { + await gotoSession() + return await callback({ directory, slug, gotoSession }) + } finally { + await cleanupTestProject(directory) + } + }) + }, }) +async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) { + await seedProjects(page, input) + await page.addInitScript(() => { + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ + recent: [{ providerID: "opencode", modelID: "big-pickle" }], + user: [], + variant: {}, + }), + ) + }) +} + export { expect } diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index 772c259517..4a286fea75 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,52 +1,53 @@ import { test, expect } from "../fixtures" import { openSidebar } from "../actions" -test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => { - await gotoSession() +test("dialog edit project updates name and startup script", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await openSidebar(page) + await withProject(async () => { + await openSidebar(page) + + const open = async () => { + const header = page.locator(".group\\/project").first() + await header.hover() + const trigger = header.getByRole("button", { name: "More options" }).first() + await expect(trigger).toBeVisible() + await trigger.click({ force: true }) + + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible() + + const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() + await expect(editItem).toBeVisible() + await editItem.click({ force: true }) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") + return dialog + } + + const name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` + + const dialog = await open() + + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) + + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) + + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).toHaveCount(0) - const open = async () => { const header = page.locator(".group\\/project").first() - await header.hover() - const trigger = header.getByRole("button", { name: "More options" }).first() - await expect(trigger).toBeVisible() - await trigger.click({ force: true }) + await expect(header).toContainText(name) - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible() - - const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() - await expect(editItem).toBeVisible() - await editItem.click({ force: true }) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") - return dialog - } - - const name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` - - const dialog = await open() - - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) - - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) - - await dialog.getByRole("button", { name: "Save" }).click() - await expect(dialog).toHaveCount(0) - - const header = page.locator(".group\\/project").first() - await expect(header).toContainText(name) - - const reopened = await open() - await expect(reopened.getByLabel("Name")).toHaveValue(name) - await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) - await reopened.getByRole("button", { name: "Cancel" }).click() - await expect(reopened).toHaveCount(0) + const reopened = await open() + await expect(reopened.getByLabel("Name")).toHaveValue(name) + await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) + await reopened.getByRole("button", { name: "Cancel" }).click() + await expect(reopened).toHaveCount(0) + }) }) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index bd323b90c6..95768d21e9 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -1,69 +1,73 @@ import { test, expect } from "../fixtures" -import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" +import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => { +test("can close a project via hover card close button", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) try { - await gotoSession() + await withProject( + async () => { + await openSidebar(page) - await openSidebar(page) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.hover() - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.hover() + const close = page.locator(projectCloseHoverSelector(otherSlug)).first() + await expect(close).toBeVisible() + await close.click() - const close = page.locator(projectCloseHoverSelector(otherSlug)).first() - await expect(close).toBeVisible() - await close.click() - - await expect(otherButton).toHaveCount(0) + await expect(otherButton).toHaveCount(0) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } }) -test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => { +test("can close a project via project header more options menu", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherName = other.split("/").pop() ?? other const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) try { - await gotoSession() + await withProject( + async () => { + await openSidebar(page) - await openSidebar(page) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + const header = page + .locator(".group\\/project") + .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) + .first() + await expect(header).toContainText(otherName) - const header = page - .locator(".group\\/project") - .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) - .first() - await expect(header).toContainText(otherName) + const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() + await expect(trigger).toHaveCount(1) + await trigger.focus() + await page.keyboard.press("Enter") - const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() - await expect(trigger).toHaveCount(1) - await trigger.focus() - await page.keyboard.press("Enter") + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible({ timeout: 10_000 }) - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible({ timeout: 10_000 }) - - await clickMenuItem(menu, /^Close$/i, { force: true }) - await expect(otherButton).toHaveCount(0) + await clickMenuItem(menu, /^Close$/i, { force: true }) + await expect(otherButton).toHaveCount(0) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 829ed8e57d..a817412cde 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,33 +1,34 @@ import { test, expect } from "../fixtures" -import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions" +import { defocus, createTestProject, cleanupTestProject } from "../actions" import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => { +test("can switch between projects from sidebar", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) - try { - await gotoSession() + await withProject( + async ({ directory }) => { + await defocus(page) - await defocus(page) + const currentSlug = dirSlug(directory) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - const currentSlug = dirSlug(directory) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() - const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() - await expect(currentButton).toBeVisible() - await currentButton.click() - - await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 80cd63aa2a..41a28e3e38 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -10,33 +10,20 @@ import { cleanupTestProject, clickMenuItem, confirmDialog, - createTestProject, openSidebar, openWorkspaceMenu, - seedProjects, setWorkspacesEnabled, } from "../actions" -import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors" -import { dirSlug } from "../utils" +import { inlineInputSelector, workspaceItemSelector } from "../selectors" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } -async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise) { - const project = await createTestProject() - const rootSlug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - await gotoSession() +async function setupWorkspaceTest(page: Page, project: { slug: string }) { + const rootSlug = project.slug await openSidebar(page) - const target = page.locator(projectSwitchSelector(rootSlug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - - await openSidebar(page) await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() @@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () ) .toBe(true) - return { project, rootSlug, slug, directory: dir } + return { rootSlug, slug, directory: dir } } -test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => { +test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const project = await createTestProject() - const slug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(slug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - + await withProject(async ({ slug }) => { await openSidebar(page) await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() @@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc await setWorkspacesEnabled(page, slug, false) await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can create a workspace", async ({ page, directory, gotoSession }) => { +test("can create a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const project = await createTestProject() - const slug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(slug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - + await withProject(async ({ slug }) => { await openSidebar(page) await setWorkspacesEnabled(page, slug, true) @@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => { await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await cleanupTestProject(workspaceDir) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can rename a workspace", async ({ page, directory, gotoSession }) => { +test("can rename a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { slug } = await setupWorkspaceTest(page, project) - try { const rename = `e2e workspace ${Date.now()}` const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Rename$/i, { force: true }) @@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => { await input.fill(rename) await input.press("Enter") await expect(item).toContainText(rename) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { +test("can reset a workspace", async ({ page, sdk, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) - try { const readme = path.join(createdDir, "README.md") const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) const original = await fs.readFile(readme, "utf8") @@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { .catch(() => false) }) .toBe(false) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can delete a workspace", async ({ page, directory, gotoSession }) => { +test("can delete a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { rootSlug, slug } = await setupWorkspaceTest(page, project) - try { const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) @@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => { await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() - } finally { - await cleanupTestProject(project) - } + }) }) -test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => { +test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) + await withProject(async ({ slug: rootSlug }) => { + const workspaces = [] as { directory: string; slug: string }[] - const project = await createTestProject() - const rootSlug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) + const listSlugs = 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) + }) + return slugs + } - const workspaces = [] as { directory: string; slug: string }[] - - const listSlugs = 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) - }) - return slugs - } - - const waitReady = async (slug: string) => { - await expect - .poll( - async () => { - const item = page.locator(workspaceItemSelector(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) - } - - const drag = async (from: string, to: string) => { - const src = page.locator(workspaceItemSelector(from)).first() - const dst = page.locator(workspaceItemSelector(to)).first() - - await src.scrollIntoViewIfNeeded() - await dst.scrollIntoViewIfNeeded() - - const a = await src.boundingBox() - const b = await dst.boundingBox() - if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") - - await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) - await page.mouse.down() - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) - await page.mouse.up() - } - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(rootSlug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - - await openSidebar(page) - await setWorkspacesEnabled(page, rootSlug, true) - - for (const _ of [0, 1]) { - const prev = slugFromUrl(page.url()) - await page.getByRole("button", { name: "New workspace" }).first().click() + const waitReady = async (slug: string) => { await expect .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug && slug !== prev + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } }, - { timeout: 45_000 }, + { timeout: 60_000 }, ) .toBe(true) + } - const slug = slugFromUrl(page.url()) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() + await src.scrollIntoViewIfNeeded() + await dst.scrollIntoViewIfNeeded() + + const a = await src.boundingBox() + const b = await dst.boundingBox() + if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } + + try { await openSidebar(page) + + await setWorkspacesEnabled(page, rootSlug, true) + + 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) + + const slug = slugFromUrl(page.url()) + const dir = base64Decode(slug) + workspaces.push({ slug, directory: dir }) + + await openSidebar(page) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) + } finally { + await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) } - - if (workspaces.length !== 2) throw new Error("Expected two created workspaces") - - const a = workspaces[0].slug - const b = workspaces[1].slug - - await waitReady(a) - await waitReady(b) - - const list = async () => { - const slugs = await listSlugs() - return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) - } - - await expect - .poll(async () => { - const slugs = await list() - return slugs.length === 2 - }) - .toBe(true) - - const before = await list() - const from = before[1] - const to = before[0] - if (!from || !to) throw new Error("Failed to resolve initial workspace order") - - await drag(from, to) - - await expect.poll(async () => await list()).toEqual([from, to]) - } finally { - await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) - await cleanupTestProject(project) - } + }) }) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 05984bbeee..4610fb3315 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -11,57 +11,98 @@ import { sessionItemSelector, inlineInputSelector } from "../selectors" const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" -test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => { +type Sdk = Parameters[0] + +async function seedMessage(sdk: Sdk, sessionID: string) { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: "e2e seed" }], + }) + + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0) +} + +test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => { const stamp = Date.now() const originalTitle = `e2e rename test ${stamp}` const newTitle = `e2e renamed ${stamp}` await withSession(sdk, originalTitle, async (session) => { + await seedMessage(sdk, session.id) await gotoSession(session.id) - await openSidebar(page) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) - const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first() + const input = page.locator(".session-scroller").locator(inlineInputSelector).first() await expect(input).toBeVisible() await input.fill(newTitle) await input.press("Enter") - await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle) + await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle) }) }) -test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => { +test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => { const stamp = Date.now() const title = `e2e archive test ${stamp}` await withSession(sdk, title, async (session) => { + await seedMessage(sdk, session.id) await gotoSession(session.id) - await openSidebar(page) - - const sessionEl = page.locator(sessionItemSelector(session.id)) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /archive/i) - await expect(sessionEl).not.toBeVisible() + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.time?.archived + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() + + await openSidebar(page) + await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) }) }) -test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => { +test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => { const stamp = Date.now() const title = `e2e delete test ${stamp}` await withSession(sdk, title, async (session) => { + await seedMessage(sdk, session.id) await gotoSession(session.id) - await openSidebar(page) - - const sessionEl = page.locator(sessionItemSelector(session.id)) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /delete/i) await confirmDialog(page, /delete/i) - await expect(sessionEl).not.toBeVisible() + await expect + .poll( + async () => { + const data = await sdk.session + .get({ sessionID: session.id }) + .then((r) => r.data) + .catch(() => undefined) + return data?.id + }, + { timeout: 30_000 }, + ) + .toBeUndefined() + + await openSidebar(page) + await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) }) }) @@ -72,6 +113,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk, const title = `e2e share test ${stamp}` await withSession(sdk, title, async (session) => { + await seedMessage(sdk, session.id) await gotoSession(session.id) const { rightSection, popoverBody } = await openSharePopover(page) diff --git a/packages/app/e2e/status/status-popover.spec.ts b/packages/app/e2e/status/status-popover.spec.ts index 4334cecb44..d53578a491 100644 --- a/packages/app/e2e/status/status-popover.spec.ts +++ b/packages/app/e2e/status/status-popover.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openStatusPopover, defocus } from "../actions" +import { openStatusPopover } from "../actions" test("status popover opens and shows tabs", async ({ page, gotoSession }) => { await gotoSession() @@ -88,7 +88,7 @@ test("status popover closes when clicking outside", async ({ page, gotoSession } const { popoverBody } = await openStatusPopover(page) await expect(popoverBody).toBeVisible() - await defocus(page) + await page.getByRole("main").click({ position: { x: 5, y: 5 } }) await expect(popoverBody).toHaveCount(0) }) diff --git a/packages/app/package.json b/packages/app/package.json index 9808c36173..3a492ed919 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.49", + "version": "1.1.51", "description": "", "type": "module", "exports": { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 57bf86b5a8..10819e69ff 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI -const win = process.platform === "win32" export default defineConfig({ testDir: "./e2e", @@ -15,8 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: !win, - workers: win ? 1 : undefined, + fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6b568e9160..b897e394aa 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1220,7 +1220,10 @@ export const PromptInput: Component = (props) => { }) return undefined }) - if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + if (session) { + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } } if (!session) return diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d388448024..4ad79ee82b 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,5 +1,6 @@ import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { monoFontFamily, useSettings } from "@/context/settings" import { SerializeAddon } from "@/addons/serialize" @@ -52,6 +53,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { } export const Terminal = (props: TerminalProps) => { + const platform = usePlatform() const sdk = useSDK() const settings = useSettings() const theme = useTheme() @@ -135,6 +137,22 @@ export const Terminal = (props: TerminalProps) => { focusTerminal() } + const handleLinkClick = (event: MouseEvent) => { + if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return + if (event.altKey) return + if (event.button !== 0) return + + const t = term + if (!t) return + + const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink + if (!link?.text) return + + event.preventDefault() + event.stopImmediatePropagation() + platform.openLink(link.text) + } + onMount(() => { const run = async () => { const loaded = await loadGhostty() @@ -146,6 +164,7 @@ export const Terminal = (props: TerminalProps) => { const once = { value: false } const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" if (window.__OPENCODE__?.serverPassword) { url.username = "opencode" url.password = window.__OPENCODE__?.serverPassword @@ -239,6 +258,9 @@ export const Terminal = (props: TerminalProps) => { container.addEventListener("pointerdown", handlePointerDown) cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown)) + container.addEventListener("click", handleLinkClick, { capture: true }) + cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true })) + handleTextareaFocus = () => { t.options.cursorBlink = true } diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index f555145874..d51c163524 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -70,6 +70,14 @@ function createCommentSession(dir: string, id: string | undefined) { setFocus((current) => (current?.id === id ? null : current)) } + const clear = () => { + batch(() => { + setStore("comments", {}) + setFocus(null) + setActive(null) + }) + } + const all = createMemo(() => { const files = Object.keys(store.comments) const items = files.flatMap((file) => store.comments[file] ?? []) @@ -82,6 +90,7 @@ function createCommentSession(dir: string, id: string | undefined) { all, add, remove, + clear, focus: createMemo(() => state.focus), setFocus, clearFocus: () => setFocus(null), @@ -144,6 +153,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont all: () => session().all(), add: (input: Omit) => session().add(input), remove: (file: string, id: string) => session().remove(file, id), + clear: () => session().clear(), focus: () => session().focus(), setFocus: (focus: CommentFocus | null) => session().setFocus(focus), clearFocus: () => session().clearFocus(), diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 28fe628a80..e2fd0a7f45 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -33,6 +33,14 @@ type SessionTabs = { type SessionView = { scroll: Record reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number +} + +type TabHandoff = { + dir: string + id: string + at: number } export type LocalProject = Partial & { worktree: string; expanded: boolean } @@ -115,10 +123,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, sessionTabs: {} as Record, sessionView: {} as Record, + handoff: { + tabs: undefined as TabHandoff | undefined, + }, }), ) const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() @@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return { ready, + handoff: { + tabs: createMemo(() => store.handoff?.tabs), + setTabs(dir: string, id: string) { + setStore("handoff", "tabs", { dir, id, at: Date.now() }) + }, + clearTabs() { + if (!store.handoff?.tabs) return + setStore("handoff", "tabs", undefined) + }, + }, projects: { list, open(directory: string) { @@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("mobileSidebar", "opened", (x) => !x) }, }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(meta.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, view(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 46c9c9154f..4ee54d3065 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" +import { createAim } from "@/utils/aim" import { Worktree as WorktreeState } from "@/utils/worktree" import { agentColor } from "@/utils/agent" @@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) { const navLeave = { current: undefined as number | undefined } + const aim = createAim({ + enabled: () => !layout.sidebar.opened(), + active: () => state.hoverProject, + el: () => state.nav, + onActivate: (directory) => { + globalSync.child(directory) + setState("hoverProject", directory) + setState("hoverSession", undefined) + }, + }) + onCleanup(() => { - if (navLeave.current === undefined) return - clearTimeout(navLeave.current) + if (navLeave.current !== undefined) clearTimeout(navLeave.current) + aim.reset() }) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) @@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) { createEffect(() => { if (!layout.sidebar.opened()) return + aim.reset() setState("hoverProject", undefined) }) + createEffect(() => { + if (state.hoverProject !== undefined) return + aim.reset() + }) + createEffect( on( () => ({ dir: params.dir, id: params.id }), () => { if (layout.sidebar.opened()) return if (!state.hoverProject) return + aim.reset() setState("hoverSession", undefined) setState("hoverProject", undefined) }, @@ -1000,69 +1019,6 @@ export default function Layout(props: ParentProps) { } } - async function deleteSession(session: Session) { - const [store, setStore] = globalSync.child(session.directory) - const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === session.id) - const nextSession = sessions[index + 1] ?? sessions[index - 1] - - const result = await globalSDK.client.session - .delete({ directory: session.directory, sessionID: session.id }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return - - setStore( - produce((draft) => { - const removed = new Set([session.id]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [session.id] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - if (session.id === params.id) { - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - } else { - navigate(`/${params.dir}/session`) - } - } - } - command.register(() => { const commands: CommandOption[] = [ { @@ -1316,15 +1272,6 @@ export default function Layout(props: ParentProps) { globalSync.project.meta(project.worktree, { name }) } - async function renameSession(session: Session, next: string) { - if (next === session.title) return - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - title: next, - }) - } - const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) if (current === next) return @@ -1475,33 +1422,6 @@ export default function Layout(props: ParentProps) { }) } - function DialogDeleteSession(props: { session: Session }) { - const handleDelete = async () => { - await deleteSession(props.session) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: props.session.title })} - -
-
- - -
-
-
- ) - } - function DialogDeleteWorkspace(props: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ @@ -1855,10 +1775,6 @@ export default function Layout(props: ParentProps) { const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const [menu, setMenu] = createStore({ - open: false, - pendingRename: false, - }) const hoverPrefetch = { current: undefined as ReturnType | undefined } const cancelHoverPrefetch = () => { @@ -1885,7 +1801,7 @@ export default function Layout(props: ParentProps) { const item = ( - props.session.title} - onSave={(next) => renameSession(props.session, next)} - class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation - /> + + {props.session.title} + {(summary) => (
@@ -1972,7 +1883,10 @@ export default function Layout(props: ParentProps) { getLabel={messageLabel} onMessageSelect={(message) => { if (!isActive()) { - sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) navigate(`${props.slug}/session/${props.session.id}`) return } @@ -1989,49 +1903,25 @@ export default function Layout(props: ParentProps) {
- setMenu("open", open)}> - - - - - { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - openEditor(`session:${props.session.id}`, props.session.title) - }} - > - { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - {language.t("common.rename")} - - archiveSession(props.session)}> - {language.t("common.archive")} - - - dialog.show(() => )}> - {language.t("common.delete")} - - - - + + { + event.preventDefault() + event.stopPropagation() + void archiveSession(props.session) + }} + /> +
) @@ -2440,17 +2330,17 @@ export default function Layout(props: ParentProps) { !selected() && !active(), "bg-surface-base-hover border border-border-weak-base": !selected() && active(), }} - onMouseEnter={() => { + onMouseEnter={(event: MouseEvent) => { if (!overlay()) return - globalSync.child(props.project.worktree) - setState("hoverProject", props.project.worktree) - setState("hoverSession", undefined) + aim.enter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (!overlay()) return + aim.leave(props.project.worktree) }} onFocus={() => { if (!overlay()) return - globalSync.child(props.project.worktree) - setState("hoverProject", props.project.worktree) - setState("hoverSession", undefined) + aim.activate(props.project.worktree) }} onClick={() => navigateToProject(props.project.worktree)} onBlur={() => setOpen(false)} @@ -2935,7 +2825,7 @@ export default function Layout(props: ParentProps) { return (
-
+
{ + aim.reset() if (!sidebarHovering()) return if (navLeave.current !== undefined) clearTimeout(navLeave.current) @@ -3045,7 +2936,7 @@ export default function Layout(props: ParentProps) {
{(project) => ( -
+
)} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 29fed369e3..7f005c56e5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -16,15 +16,19 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" +import { Select } from "@opencode-ai/ui/select" import { useCodeComponent } from "@opencode-ai/ui/context/code" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { SessionTurn } from "@opencode-ai/ui/session-turn" @@ -51,7 +55,7 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" -import type { FileDiff } from "@opencode-ai/sdk/v2/client" +import type { FileDiff } from "@opencode-ai/sdk/v2" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { useComments, type LineComment } from "@/context/comments" @@ -73,13 +77,36 @@ import { same } from "@/utils/same" type DiffStyle = "unified" | "split" +type HandoffSession = { + prompt: string + files: Record +} + +const HANDOFF_MAX = 40 + const handoff = { - prompt: "", - terminals: [] as string[], - files: {} as Record, + session: new Map(), + terminal: new Map(), +} + +const touch = (map: Map, key: K, value: V) => { + map.delete(key) + map.set(key, value) + while (map.size > HANDOFF_MAX) { + const first = map.keys().next().value + if (first === undefined) return + map.delete(first) + } +} + +const setSessionHandoff = (key: string, patch: Partial) => { + const prev = handoff.session.get(key) ?? { prompt: "", files: {} } + touch(handoff.session, key, { ...prev, ...patch }) } interface SessionReviewTabProps { + title?: JSX.Element + empty?: JSX.Element diffs: () => FileDiff[] view: () => ReturnType["view"]> diffStyle: DiffStyle @@ -196,6 +223,8 @@ function SessionReviewTab(props: SessionReviewTabProps) { return ( { scroll = el props.onScrollRef?.(el) @@ -255,6 +284,10 @@ export default function Page() { pendingMessage: undefined as string | undefined, scrollGesture: 0, autoCreated: false, + scroll: { + overflow: false, + bottom: true, + }, }) createEffect( @@ -280,9 +313,47 @@ export default function Page() { .finally(() => setUi("responding", false)) } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const workspaceKey = createMemo(() => params.dir ?? "") + const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) + createEffect( + on( + () => params.id, + (id, prev) => { + if (!id) return + if (prev) return + + const pending = layout.handoff.tabs() + if (!pending) return + if (Date.now() - pending.at > 60_000) { + layout.handoff.clearTabs() + return + } + + if (pending.id !== id) return + layout.handoff.clearTabs() + if (pending.dir !== (params.dir ?? "")) return + + const from = workspaceTabs().tabs() + if (from.all.length === 0 && !from.active) return + + const current = tabs().tabs() + if (current.all.length > 0 || current.active) return + + const all = normalizeTabs(from.all) + const active = from.active ? normalizeTab(from.active) : undefined + tabs().setAll(all) + tabs().setActive(active && all.includes(active) ? active : all[0]) + + workspaceTabs().setAll([]) + workspaceTabs().setActive(undefined) + }, + { defer: true }, + ), + ) + if (import.meta.env.DEV) { createEffect( on( @@ -398,6 +469,213 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + createEffect( + on( + () => params.id, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!params.id) return + setTitle({ editing: true, draft: info()?.title ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const sessionID = params.id + if (!sessionID) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (info()?.title ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + async function archiveSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + + if (params.id !== sessionID) return + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return + } + navigate(`/${params.dir}/session`) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + async function deleteSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + + const byParent = new Map() + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + if (params.id !== sessionID) return true + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return true + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return true + } + navigate(`/${params.dir}/session`) + return true + } + + function DialogDeleteSession(props: { sessionID: string }) { + const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: title() })} + +
+
+ + +
+
+
+ ) + } + const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -436,10 +714,14 @@ export default function Page() { messageId: undefined as string | undefined, turnStart: 0, mobileTab: "session" as "session" | "changes", + changes: "session" as "session" | "turn", newSessionWorktree: "main", promptHeight: 0, }) + const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) + const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const renderedUserMessages = createMemo( () => { const msgs = visibleUserMessages() @@ -526,6 +808,7 @@ export default function Page() { let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined + let content: HTMLDivElement | undefined const scrollGestureWindowMs = 250 @@ -545,8 +828,10 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) + sdk.directory + const id = params.id + if (!id) return + sync.session.sync(id) }) createEffect(() => { @@ -614,10 +899,23 @@ export default function Page() { createEffect( on( - () => params.id, + sessionKey, () => { setStore("messageId", undefined) setStore("expanded", {}) + setStore("changes", "session") + setUi("autoCreated", false) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => params.dir, + (dir) => { + if (!dir) return + setStore("newSessionWorktree", "main") }, { defer: true }, ), @@ -1125,34 +1423,84 @@ export default function Page() { activeDiff: undefined as string | undefined, }) - const reviewScroll = () => tree.reviewScroll - const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) - const pendingDiff = () => tree.pendingDiff - const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) - const activeDiff = () => tree.activeDiff - const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) + createEffect( + on( + sessionKey, + () => { + setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) + }, + { defer: true }, + ), + ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return setFileTreeTab("all") } + const changesOptions = ["session", "turn"] as const + const changesOptionsList = [...changesOptions] + + const changesTitle = () => ( +