Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton
3704dbcee1 fix(ui): stabilize shell rolling output transitions 2026-03-06 16:34:18 -06:00
Kit Langton
b47ab35ddf fix(ui): fix useRowWipe stuck blur and useCollapsible race conditions
- Remove anim.stop() from useRowWipe cleanup — stopping mid-animation
  leaves WAAPI fill-forward that overrides cleared inline styles. Let
  animations run to completion; cancelAnimationFrame prevents starts.
- Add generation counter to useCollapsible to guard against stale
  microtask and promise callbacks on rapid open/close toggling.
- Use .then(ok, err) instead of .catch().then() to prevent callbacks
  firing after animation cancellation.
- Remove redundant fade constant in ShellExpanded.
- Clean up unused imports in context-tool-results.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:18 -06:00
Kit Langton
0ac8f06521 refactor(ui): extract shared collapsible, scroll mask, and hold utilities
- Replace manual autoOpen signal+effect with hold(pending, 2000)
- Extract updateScrollMask() shared by ShellExpanded and ContextToolExpandedList
- Extract useCollapsible() hook for the shared expand/collapse animation pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:17 -06:00
Kit Langton
4e46d98156 fix(ui): shell-rolling-results formatting cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:17 -06:00
Kit Langton
4795806b13 feat(ui): tool call abstraction, turn diff summary, and composer improvements
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:10 -06:00
190 changed files with 13299 additions and 6170 deletions

View File

@@ -1,6 +1,6 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)

View File

@@ -506,6 +506,7 @@
"virtua": "catalog:",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -2962,7 +2963,7 @@
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
}
}

View File

@@ -71,9 +71,6 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
@@ -112,7 +109,7 @@ import { test, expect } from "@playwright/test"
### Error Handling
Tests should clean up after themselves. Prefer fixture-managed cleanup:
Tests should clean up after themselves:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -123,11 +120,6 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
})
```
- Prefer `withSession(...)` for temp sessions
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:

View File

@@ -3,12 +3,13 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { modKey, serverUrl } from "./utils"
import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
@@ -18,6 +19,7 @@ import {
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
export async function defocus(page: Page) {
await page
@@ -60,9 +62,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
}
export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true"
const main = page.locator("main")
const classes = (await main.getAttribute("class")) ?? ""
return classes.includes("xl:border-l")
}
export async function toggleSidebar(page: Page) {
@@ -74,34 +76,48 @@ export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const opened = await expect(button)
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
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(button).toHaveAttribute("aria-expanded", "true")
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()
await button.click()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const closed = await expect(button)
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
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(button).toHaveAttribute("aria-expanded", "false")
await expect(main).toHaveClass(/xl:border-l/)
}
export async function openSettings(page: Page) {
@@ -182,21 +198,17 @@ export async function createTestProject() {
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,
stdio: "ignore",
})
return resolveDirectory(root)
return root
}
export async function cleanupTestProject(directory: string) {
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
}
export function sessionIDFromUrl(url: string) {
@@ -205,7 +217,7 @@ export function sessionIDFromUrl(url: string) {
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
@@ -214,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const header = page.locator(sessionHeaderSelector).first()
await expect(header).toBeVisible()
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -232,7 +244,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
@@ -306,57 +318,6 @@ export async function clickListItem(
return item
}
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>(
sdk: ReturnType<typeof createSdk>,
title: string,
@@ -368,7 +329,7 @@ export async function withSession<T>(
try {
return await callback(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
}
}
@@ -606,42 +567,32 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
}
throw new Error(`Failed to open project menu: ${projectSlug}`)
await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
@@ -654,18 +605,11 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
if (current === enabled) return
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout })
}
await openProjectMenu(page, projectSlug)
const flipped = await flip(1500)
.then(() => true)
.catch(() => false)
if (!flipped) await flip()
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await toggle.click({ force: true })
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()

View File

@@ -16,6 +16,7 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -55,6 +56,7 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -74,6 +76,7 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -99,6 +102,7 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))

View File

@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -13,8 +13,6 @@ type TestFixtures = {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
@@ -53,36 +51,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const directory = await createTestProject()
const slug = dirSlug(directory)
await seedStorage(page, { directory, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await gotoSession()
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
return await callback({ directory, slug, gotoSession })
} finally {
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
await cleanupTestProject(directory)
}
})
},

View File

@@ -1,15 +1,25 @@
import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
import { openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await withProject(async () => {
await openSidebar(page)
const open = async () => {
const menu = await openProjectMenu(page, slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
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()

View File

@@ -1,45 +1,20 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug } from "../utils"
import { createSdk, dirSlug, sessionPath } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -76,16 +51,17 @@ test("switching back to a project opens the latest workspace session", async ({
const other = await createTestProject()
const otherSlug = dirSlug(other)
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
try {
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
async ({ directory, slug }) => {
rootDir = directory
await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
@@ -104,7 +80,6 @@ test("switching back to a project opens the latest workspace session", async ({
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
trackDirectory(workspaceDir)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
@@ -128,7 +103,7 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, workspaceDir)
sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
@@ -149,6 +124,20 @@ test("switching back to a project opens the latest workspace session", async ({
{ extra: [other] },
)
} finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other)
}
})

View File

@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
@@ -9,26 +9,6 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
@@ -51,7 +31,20 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen])
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (slug === root) return ""
if (seen.includes(slug)) return ""
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
const slug = slugFromUrl(page.url())
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
@@ -67,13 +60,12 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
const next = await openWorkspaceNewSession(page, slug)
await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
@@ -84,13 +76,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return sessionID
}
async function sessionDirectory(directory: string, sessionID: string) {
@@ -105,29 +97,48 @@ async function sessionDirectory(directory: string, sessionID: string) {
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
await withProject(async ({ directory, slug: root }) => {
const workspaces = [] as { slug: string; directory: string }[]
const sessions = [] as string[]
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first.slug)
try {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second.slug)
const first = await createWorkspace(page, root, [])
workspaces.push(first)
await waitWorkspaceReady(page, first.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const second = await createWorkspace(page, root, [first.slug])
workspaces.push(second)
await waitWorkspaceReady(page, second.slug)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
sessions.push(firstSession)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
sessions.push(secondSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
sessions.push(thirdSession)
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
sessions.map((sessionID) =>
Promise.all(
dirs.map((dir) =>
createSdk(dir)
.session.delete({ sessionID })
.catch(() => undefined),
),
),
),
)
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
}
})
})

View File

@@ -22,26 +22,6 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
return ""
}
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return slugFromUrl(page.url())
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
@@ -49,7 +29,17 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [rootSlug])
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
await openSidebar(page)
@@ -101,7 +91,18 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const workspaceSlug = await waitSlug(page, [slug])
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
@@ -278,7 +279,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect
.poll(
@@ -335,6 +336,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
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")

View File

@@ -1,8 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
import { sessionIDFromUrl } from "../actions"
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
@@ -40,37 +38,6 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
)
.toContain(token)
} finally {
await cleanupSession({ sdk, sessionID })
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
const value = `restore ${Date.now()}`
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "e2e prompt failure" }),
}),
)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type(value)
await page.keyboard.press("Enter")
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 15_000 },
)
.toBe(0)
})
})

View File

@@ -1,181 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
}
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
const part = messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
},
{ timeout: 90_000 },
)
.toContain(token)
}
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, firstToken)
await prompt.click()
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, secondToken)
await prompt.click()
await page.keyboard.type(draft)
await wait(page, draft)
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, draft)
})
})
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
await gotoSession(session.id)
const prompt = page.locator(promptSelector)
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, session.id, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, session.id, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
})

View File

@@ -1,62 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
if (!("type" in part) || part.type !== "tool") return false
if (!("tool" in part) || part.tool !== "bash") return false
return "state" in part
}
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls"
await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
trackSession(id, directory)
await expect
.poll(
async () => {
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
)
if (!msg) return
const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
if (!part || part.state.status !== "completed") return
const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
return { cwd: directory, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
})
})

View File

@@ -1,64 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e share 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("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector)
await seed(sdk, session.id)
await gotoSession(session.id)
await prompt.click()
await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await prompt.click()
await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
})
})

View File

@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
import { sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
.toContain(token)
} finally {
page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
if (pageErrors.length > 0) {

View File

@@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
@@ -26,7 +26,7 @@ async function withDockSession<T>(
try {
return await fn(session)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
}
}
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
await expect(page.locator(promptSelector)).toBeVisible()
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
}
})
})
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
},
)
} finally {
await cleanupSession({ sdk, sessionID: child.id })
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
}
})
})

View File

@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)

View File

@@ -32,19 +32,22 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await closeDialog(page, dialog)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
const main = page.locator("main")
const initialClasses = (await main.getAttribute("class")) ?? ""
const initiallyClosed = initialClasses.includes("xl:border-l")
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
await page.waitForTimeout(100)
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
await page.waitForTimeout(100)
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
const finalClasses = (await main.getAttribute("class")) ?? ""
const finalClosed = finalClasses.includes("xl:border-l")
expect(finalClosed).toBe(initiallyClosed)
})

View File

@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@@ -15,15 +15,12 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await gotoSession(one.id)
await closeSidebar(page)
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(oneItem).toBeVisible()
await expect(twoItem).toBeVisible()
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
@@ -31,9 +28,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
.first()
.click()
await expect(twoItem).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { cleanupSession, openSidebar, withSession } from "../actions"
import { openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
@@ -18,13 +18,14 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
await target.scrollIntoViewIfNeeded()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
} finally {
await cleanupSession({ sdk, sessionID: one.id })
await cleanupSession({ sdk, sessionID: two.id })
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})

View File

@@ -5,14 +5,12 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toHaveAttribute("aria-expanded", "true")
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
@@ -21,15 +19,14 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
await gotoSession(session1.id)
await openSidebar(page)
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await toggleSidebar(page)
await expect(button).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await gotoSession(session2.id)
await expect(button).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await page.reload()
await expect(button).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,

View File

@@ -1,120 +0,0 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
active?: string
all: Array<{
id: string
title: string
titleNumber: number
buffer?: string
}>
}
async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
}
async function store(page: Page, key: string) {
return page.evaluate((key) => {
const raw = localStorage.getItem(key)
if (raw) return JSON.parse(raw) as State
for (let i = 0; i < localStorage.length; i++) {
const next = localStorage.key(i)
if (!next?.endsWith(":workspace:terminal")) continue
const value = localStorage.getItem(next)
if (!value) continue
return JSON.parse(value) as State
}
}, key)
}
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await run(page, `echo ${two}`)
await tabs
.filter({ hasText: /Terminal 1/ })
.first()
.click()
await expect
.poll(
async () => {
const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return first.includes(one) && second.includes(two)
},
{ timeout: 30_000 },
)
.toBe(true)
})
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click()
await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover()
await page
.getByRole("button", { name: /close terminal/i })
.nth(1)
.click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
const state = await store(page, key)
return {
count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
}
},
{ timeout: 15_000 },
)
.toEqual({ count: 1, first: true })
})
})

View File

@@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -14,12 +14,6 @@ export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
export async function resolveDirectory(directory: string) {
return createSdk(directory)
.path.get()
.then((x) => x.data?.directory ?? directory)
}
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
@@ -39,9 +33,3 @@ export function dirPath(directory: string) {
export function sessionPath(directory: string, sessionID?: string) {
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
}
export function workspacePersistKey(directory: string, key: string) {
const head = directory.slice(0, 12) || "workspace"
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}

View File

@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
applyingHistory: boolean
pendingAutoAccept: boolean
}>({
popover: null,
historyIndex: -1,
@@ -252,9 +253,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draggingType: null,
mode: "normal",
applyingHistory: false,
pendingAutoAccept: false,
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const springFade = (t: number): Record<string, string> => ({
opacity: `${t}`,
transform: `scale(${0.95 + t * 0.05})`,
filter: `blur(${(1 - t) * 2}px)`,
"pointer-events": t > 0.5 ? "auto" : "none",
})
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
@@ -304,6 +316,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
createEffect(
on(sessionKey, () => {
setStore("pendingAutoAccept", false)
}),
)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
@@ -953,7 +971,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
if (!id) return store.pendingAutoAccept
return permission.isAutoAccepting(id, sdk.directory)
})
@@ -1246,9 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
>
<TooltipKeybind
placement="top"
@@ -1260,11 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
style={springFade(buttonsSpring())}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1302,11 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
style={springFade(buttonsSpring())}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1328,7 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
setStore("pendingAutoAccept", (value) => !value)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
@@ -1362,13 +1370,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{
padding: "0 4px 0 8px",
opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}}
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
@@ -1387,13 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
variant="ghost"
/>
</TooltipKeybind>
@@ -1411,13 +1407,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
style={{ height: "28px", ...springFade(buttonsSpring()) }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1446,13 +1436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: {
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
style: { height: "28px", ...springFade(buttonsSpring()) },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1484,13 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
variant="ghost"
/>
</TooltipKeybind>

View File

@@ -1,8 +1,9 @@
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { errorMessage } from "@/pages/layout/helpers"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
@@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const language = useLanguage()
const params = useParams()
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")
}
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
const abort = async () => {
const sessionID = params.id
@@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
return undefined
})
@@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
return undefined
})
@@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
restoreInput()
})
@@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
messageID,
})
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
batch(() => {
removeCommentItems(commentItems)
clearInput()
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
addOptimisticMessage()
})
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
description: toastError(err),
})
removeOptimisticMessage()
restoreCommentItems(commentItems)

View File

@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { findLast, same } from "@opencode-ai/util/array"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"

View File

@@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { isEditableTarget } from "@/utils/dom"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {

View File

@@ -353,7 +353,6 @@ function createGlobalSync() {
.update({ config })
.then(bootstrap)
.then(() => {
queue.refresh()
setGlobalStore("reload", undefined)
queue.refresh()
})

View File

@@ -146,7 +146,6 @@ const DICT: Record<Locale, Dictionary> = {
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
@@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()])

View File

@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { same } from "@opencode-ai/util/array"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
import { autoRespondsPermission } from "./permission-auto-respond"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("falls back to directory-level auto-accept", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
})
test("session-level override takes precedence over directory-level", () => {
const directory = "/tmp/project"
const sessions = [session({ id: "root" })]
const autoAccept = {
[`${base64Encode(directory)}/*`]: true,
[`${base64Encode(directory)}/root`]: false,
}
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
})
})
describe("isDirectoryAutoAccepting", () => {
test("returns true when directory key is set", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
})
test("returns false when directory key is not set", () => {
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
})
test("returns false when directory key is explicitly false", () => {
const directory = "/tmp/project"
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
})
})

View File

@@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) {
return `${base64Encode(directory)}/${sessionID}`
}
export function directoryAcceptKey(directory: string) {
return `${base64Encode(directory)}/*`
}
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
const key = directoryAcceptKey(directory)
return autoAccept[key] ?? false
return autoAccept[key] ?? autoAccept[sessionID]
}
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import {
acceptKey,
directoryAcceptKey,
isDirectoryAutoAccepting,
autoRespondsPermission,
} from "./permission-auto-respond"
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
type PermissionRespondFn = (input: {
sessionID: string
@@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
// When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => {
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] === undefined) {
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
}
}
})
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
@@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
function isAutoAcceptingDirectory(directory: string) {
return isDirectoryAutoAccepting(store.autoAccept, directory)
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
@@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
onCleanup(unsubscribe)
function enableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = true
}),
)
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
}
})
.catch(() => undefined)
}
function disableDirectory(directory: string) {
const key = directoryAcceptKey(directory)
setStore(
produce((draft) => {
draft.autoAccept[key] = false
}),
)
}
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
@@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return shouldAutoRespond(permission, directory)
},
isAutoAccepting,
isAutoAcceptingDirectory,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
@@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
enable(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
if (isAutoAcceptingDirectory(directory)) {
disableDirectory(directory)
return
}
enableDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
@@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
disable(sessionID, directory)
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
}
},
})

View File

@@ -23,8 +23,6 @@ export interface Settings {
autoSave: boolean
releaseNotes: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
}
updates: {
startup: boolean
@@ -46,8 +44,6 @@ const defaultSettings: Settings = {
autoSave: true,
releaseNotes: true,
showReasoningSummaries: false,
shellToolPartsExpanded: true,
editToolPartsExpanded: false,
},
updates: {
startup: true,
@@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowReasoningSummaries(value: boolean) {
setStore("general", "showReasoningSummaries", value)
},
shellToolPartsExpanded: withFallback(
() => store.general?.shellToolPartsExpanded,
defaultSettings.general.shellToolPartsExpanded,
),
setShellToolPartsExpanded(value: boolean) {
setStore("general", "shellToolPartsExpanded", value)
},
editToolPartsExpanded: withFallback(
() => store.general?.editToolPartsExpanded,
defaultSettings.general.editToolPartsExpanded,
),
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

View File

@@ -541,12 +541,6 @@ export const dict = {
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "الخط",
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
"settings.general.row.shellToolPartsExpanded.description":
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":

View File

@@ -547,12 +547,6 @@ export const dict = {
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte",
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip":

View File

@@ -613,12 +613,6 @@ export const dict = {
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
"settings.general.row.shellToolPartsExpanded.description":
"Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci",
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
"settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip":

View File

@@ -608,11 +608,6 @@ export const dict = {
"settings.general.row.font.title": "Skrifttype",
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
"settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip":

View File

@@ -556,12 +556,6 @@ export const dict = {
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
"settings.general.row.shellToolPartsExpanded.description":
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
"settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip":

View File

@@ -511,13 +511,11 @@ export const dict = {
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",
"session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
"session.review.noChanges": "No changes",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
"session.files.empty": "No files",
"session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages",
@@ -638,13 +636,6 @@ export const dict = {
"settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
"settings.general.row.shellToolPartsExpanded.description":
"Show shell tool parts expanded by default in the timeline",
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
"settings.general.row.wayland.tooltip":

View File

@@ -616,12 +616,6 @@ export const dict = {
"settings.general.row.font.title": "Fuente",
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip":

View File

@@ -553,12 +553,6 @@ export const dict = {
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police",
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
"settings.general.row.shellToolPartsExpanded.description":
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
"settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip":

View File

@@ -545,12 +545,6 @@ export const dict = {
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
"settings.general.row.font.title": "フォント",
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
"settings.general.row.shellToolPartsExpanded.description":
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip":

View File

@@ -546,12 +546,6 @@ export const dict = {
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
"settings.general.row.font.title": "글꼴",
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
"settings.general.row.shellToolPartsExpanded.description":
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
"settings.general.row.editToolPartsExpanded.description":
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
"settings.general.row.wayland.tooltip":

View File

@@ -616,11 +616,6 @@ export const dict = {
"settings.general.row.font.title": "Skrift",
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
"settings.general.row.wayland.tooltip":

View File

@@ -546,12 +546,6 @@ export const dict = {
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka",
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
"settings.general.row.shellToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip":

View File

@@ -614,12 +614,6 @@ export const dict = {
"settings.general.row.font.title": "Шрифт",
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
"settings.general.row.shellToolPartsExpanded.description":
"Показывать элементы инструмента shell в ленте развернутыми по умолчанию",
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
"settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip":

View File

@@ -608,11 +608,6 @@ export const dict = {
"settings.general.row.font.title": "ฟอนต์",
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",

View File

@@ -623,13 +623,6 @@ export const dict = {
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
"settings.general.row.shellToolPartsExpanded.description":
"Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
"settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
"settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description":
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",

View File

@@ -607,10 +607,6 @@ export const dict = {
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
"settings.general.row.font.title": "字体",
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",

View File

@@ -603,10 +603,6 @@ export const dict = {
"settings.general.row.font.title": "字型",
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",

View File

@@ -1,29 +1 @@
@import "@opencode-ai/ui/styles/tailwind";
@layer components {
[data-component="getting-started"] {
container-type: inline-size;
container-name: getting-started;
}
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;
gap: 0.75rem; /* gap-3 */
}
[data-component="getting-started-actions"] > [data-component="button"] {
width: 100%;
}
@container getting-started (min-width: 17rem) {
[data-component="getting-started-actions"] {
flex-direction: row;
align-items: center;
}
[data-component="getting-started-actions"] > [data-component="button"] {
width: auto;
}
}
}

View File

@@ -1,27 +1,26 @@
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
return (
<DataProvider
data={sync.data}
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
@@ -31,63 +30,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const directory = createMemo(() => decode64(params.dir) ?? "")
const [state, setState] = createStore({ invalid: "", resolved: "" })
const [store, setStore] = createStore({ invalid: "" })
const directory = createMemo(() => {
return decode64(params.dir) ?? ""
})
createEffect(() => {
if (!params.dir) return
const raw = directory()
if (!raw) {
if (state.invalid === params.dir) return
setState("invalid", params.dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
const current = params.dir
globalSDK
.createClient({
directory: raw,
throwOnError: true,
})
.path.get()
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
})
if (next === raw) return
const path = location.pathname.slice(current.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
if (params.dir !== current) return
batch(() => {
setState("invalid", "")
setState("resolved", raw)
})
})
if (directory()) return
if (store.invalid === params.dir) return
setStore("invalid", params.dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
})
return (
<Show when={state.resolved}>
{(resolved) => (
<SDKProvider directory={resolved}>
<SyncProvider>
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
)}
<Show when={directory()}>
<SDKProvider directory={directory}>
<SyncProvider>
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
</SyncProvider>
</SDKProvider>
</Show>
)
}

View File

@@ -10,8 +10,9 @@ import {
ParentProps,
Show,
untrack,
type JSX,
} from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -19,8 +20,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
@@ -57,6 +59,7 @@ import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import {
childMapByParent,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -93,7 +96,6 @@ export default function Layout(props: ParentProps) {
workspaceName: {} as Record<string, string>,
workspaceBranchName: {} as Record<string, Record<string, string>>,
workspaceExpanded: {} as Record<string, boolean>,
gettingStartedDismissed: false,
}),
)
@@ -155,8 +157,6 @@ export default function Layout(props: ParentProps) {
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined }
const [sortNow, setSortNow] = createSignal(Date.now())
const [sizing, setSizing] = createSignal(false)
let sizet: number | undefined
let sortNowInterval: ReturnType<typeof setInterval> | undefined
const sortNowTimeout = setTimeout(
() => {
@@ -169,7 +169,7 @@ export default function Layout(props: ParentProps) {
const aim = createAim({
enabled: () => !layout.sidebar.opened(),
active: () => state.hoverProject,
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
el: () => state.nav,
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
@@ -181,23 +181,9 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
if (sizet !== undefined) clearTimeout(sizet)
if (peekt !== undefined) clearTimeout(peekt)
aim.reset()
})
onMount(() => {
const stop = () => setSizing(false)
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const setHoverProject = (value: string | undefined) => {
@@ -208,54 +194,12 @@ export default function Layout(props: ParentProps) {
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => {
if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
navLeave.current = undefined
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
disarm()
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
const [peeked, setPeeked] = createSignal(false)
let peekt: number | undefined
const hoverProjectData = createMemo(() => {
const id = state.hoverProject
if (!id) return
return layout.projects.list().find((project) => project.worktree === id)
})
createEffect(() => {
const p = hoverProjectData()
if (p) {
if (peekt !== undefined) {
clearTimeout(peekt)
peekt = undefined
}
setPeek(p)
setPeeked(true)
return
}
setPeeked(false)
if (peek() === undefined) return
if (peekt !== undefined) clearTimeout(peekt)
peekt = window.setTimeout(() => {
peekt = undefined
setPeek(undefined)
}, 180)
})
createEffect(() => {
if (!layout.sidebar.opened()) return
setHoverProject(undefined)
@@ -1181,12 +1125,6 @@ export default function Layout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = globalSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await globalSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
@@ -1877,8 +1815,7 @@ export default function Layout(props: ParentProps) {
setHoverSession,
}
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
const projectName = createMemo(() => {
const project = panelProps.project
if (!project) return ""
@@ -1904,19 +1841,12 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
"border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged(),
"bg-background-stronger": !merged(),
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
"flex-1 min-w-0": panelProps.mobile,
"max-w-full overflow-hidden": panelProps.mobile,
}}
style={{
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project}>
<Show when={panelProps.project} keyed>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -1925,7 +1855,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p(), next)}
onSave={(next) => renameProject(p, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
@@ -1934,7 +1864,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
value={p().worktree}
value={p.worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@@ -1942,7 +1872,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p().worktree.replace(homedir(), "~")}
{p.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@@ -1953,33 +1883,33 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p().worktree)}
data-project={base64Encode(p.worktree)}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => toggleProjectWorkspaces(p())}
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => toggleProjectWorkspaces(p)}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p().worktree)()
{layout.sidebar.workspaces(p.worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
data-project={base64Encode(p.worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
@@ -1990,8 +1920,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
data-project={base64Encode(p.worktree)}
onSelect={() => closeProject(p.worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -2007,19 +1937,25 @@ export default function Layout(props: ParentProps) {
fallback={
<>
<div class="shrink-0 py-4 px-3">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
{language.t("command.session.new")}
</Button>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={p()}
project={p}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2029,9 +1965,15 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="shrink-0 py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
@@ -2054,7 +1996,7 @@ export default function Layout(props: ParentProps) {
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
project={p}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
@@ -2079,31 +2021,25 @@ export default function Layout(props: ParentProps) {
</Show>
<div
class="shrink-0 px-3 py-3"
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
classList={{
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
<div class="p-3 flex flex-col gap-6">
<div class="flex flex-col gap-2">
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.gettingStarted.line1")}
</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.gettingStarted.line2")}
</div>
</div>
<div data-component="getting-started-actions">
<Button size="large" icon="plus-small" onClick={connectProvider}>
{language.t("command.provider.connect")}
</Button>
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
Not yet
</Button>
</div>
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
</div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
</div>
</div>
</div>
@@ -2113,27 +2049,33 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
<div class="flex-1 min-h-0 relative overflow-x-hidden">
<div class="flex-1 min-h-0 flex">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
"relative shrink-0": true,
}}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
if (navLeave.current === undefined) return
clearTimeout(navLeave.current)
navLeave.current = undefined
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}}
>
<div class="@container w-full h-full contain-strict">
@@ -2160,36 +2102,30 @@ export default function Layout(props: ParentProps) {
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} merged />}
{(project) => <SidebarPanel project={project} />}
</Show>
)}
/>
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
{(worktree) => (
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
<SidebarPanel project={hoverProjectData()} />
</div>
)}
</Show>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setSizing(true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setSizing(true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setSizing(false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
</nav>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
@@ -2205,7 +2141,7 @@ export default function Layout(props: ParentProps) {
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
@@ -2238,66 +2174,16 @@ export default function Layout(props: ParentProps) {
</nav>
</div>
<div
<main
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!sizing(),
}}
style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peek()} keyed>
{(project) => <SidebarPanel project={project} merged={false} />}
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</main>
</div>
<Toast.Region />
</div>

View File

@@ -163,6 +163,7 @@ const SessionHoverPreview = (props: {
gutter={16}
shift={-2}
trigger={props.trigger}
mount={!props.mobile ? props.nav() : undefined}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>

View File

@@ -137,7 +137,7 @@ const ProjectTile = (props: {
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import {
DragDropProvider,
DragDropSensors,
@@ -35,22 +35,10 @@ export const SidebarContent = (props: {
}): JSX.Element => {
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
const placement = () => (props.mobile ? "bottom" : "right")
let panel: HTMLDivElement | undefined
createEffect(() => {
const el = panel
if (!el) return
if (expanded()) {
el.removeAttribute("inert")
return
}
el.setAttribute("inert", "")
})
return (
<div class="flex h-full w-full min-w-0 overflow-hidden">
<div class="flex h-full w-full overflow-hidden">
<div
data-component="sidebar-rail"
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
onMouseMove={props.aimMove}
>
@@ -112,15 +100,7 @@ export const SidebarContent = (props: {
</div>
</div>
<div
ref={(el) => {
panel = el
}}
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()}
>
{props.renderPanel()}
</div>
<Show when={expanded()}>{props.renderPanel()}</Show>
</div>
)
}

View File

@@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
aria-label={props.language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!props.pendingRename()) return
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
<nav class="flex flex-col gap-1 px-3">
<nav class="flex flex-col gap-1 px-2">
<Show when={props.showNew()}>
<NewSessionItem
slug={props.slug()}
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<nav class="flex flex-col gap-1 px-3">
<nav class="flex flex-col gap-1 px-2">
<Show when={loading()}>
<SessionSkeleton />
</Show>

View File

@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
onCleanup,
@@ -20,30 +20,28 @@ import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { Mark } from "@opencode-ai/ui/logo"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { same } from "@opencode-ai/util/array"
import { isEditableTarget } from "@/utils/dom"
import { createOpenReviewFile } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
@@ -121,13 +119,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
// SolidJS updates the DOM synchronously. Force reflow so the browser
// processes the new layout, then restore scrollTop before paint.
// With column-reverse + overflow-anchor:none the same scrollTop value
// keeps the same distance from the bottom — no delta math needed.
void el.scrollHeight
el.scrollTop = beforeTop
}
const backfillTurns = () => {
@@ -210,7 +208,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
@@ -255,7 +254,6 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
}
export default function Page() {
const globalSync = useGlobalSync()
const layout = useLayout()
const local = useLocal()
const file = useFile()
@@ -282,7 +280,6 @@ export default function Page() {
})
const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
scroll: {
@@ -290,7 +287,6 @@ export default function Page() {
bottom: true,
},
})
const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -435,20 +431,8 @@ export default function Page() {
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
deferRender: false,
})
createComputed((prev) => {
const key = sessionKey()
if (key !== prev) {
setStore("deferRender", true)
requestAnimationFrame(() => {
setTimeout(() => setStore("deferRender", false), 0)
})
}
return key
}, sessionKey())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
@@ -459,11 +443,6 @@ export default function Page() {
return "main"
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setStore("messageId", message?.id)
}
@@ -495,51 +474,10 @@ export default function Page() {
})
const reviewEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.empty"
if (!project || project.vcs) return "session.review.empty"
return "session.review.noVcs"
})
function upsert(next: Project) {
const list = globalSync.data.project
sync.set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
globalSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
return
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
globalSync.set("project", [...list, next])
}
function initGit() {
if (ui.git) return
setUi("git", true)
void sdk.client.project
.initGit()
.then((x) => {
if (!x.data) return
upsert(x.data)
})
.catch((err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
})
.finally(() => {
setUi("git", false)
})
}
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let dockHeight = 0
@@ -666,11 +604,6 @@ export default function Page() {
saveLabel: language.t("common.save"),
}))
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
@@ -773,28 +706,23 @@ export default function Page() {
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => {
if (!hasReview()) {
return null
}
return (
<Select
options={changesOptionsList}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
}
const changesTitle = () => (
<Select
options={changesOptionsList}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
/>
)
const emptyTurn = () => (
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
</div>
)
@@ -806,12 +734,35 @@ export default function Page() {
loadingClass: string
emptyClass: string
}) => (
<Show when={!store.deferRender}>
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<Switch>
<Match when={store.changes === "turn" && !!params.id}>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab
title={changesTitle()}
empty={emptyTurn()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
@@ -828,78 +779,39 @@ export default function Page() {
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
>
<SessionReviewTab
title={changesTitle()}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : reviewEmptyKey() === "session.review.noVcs" ? (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Create a Git repository</div>
<div
class="text-14-regular text-text-base max-w-md"
style={{ "line-height": "var(--line-height-normal)" }}
>
Track, review, and undo changes in this project
</div>
</div>
<Button size="large" disabled={ui.git} onClick={initGit}>
{ui.git ? "Creating Git repository..." : "Create Git repository"}
</Button>
</div>
) : (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
</Show>
</Show>
</Match>
<Match when={true}>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
)
const reviewPanel = () => (
@@ -1110,7 +1022,10 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || el.scrollTop >= max - 2
// If auto-scroll is tracking the bottom, always report bottom: true
// to prevent the scroll-down arrow from flashing during height animations
// With column-reverse, scrollTop=0 is at the bottom
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -1133,7 +1048,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
autoScroll.smoothScrollToBottom()
clearMessageHash()
const el = scroller
@@ -1201,9 +1116,8 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
// With column-reverse, near bottom = scrollTop near 0
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
dockHeight = next
@@ -1269,50 +1183,49 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Show>
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
/>
</Match>
<Match when={true}>
<NewSessionView
@@ -1338,7 +1251,6 @@ export default function Page() {
<SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={centered()}
inputRef={(el) => {
inputRef = el

View File

@@ -1,3 +1,29 @@
/**
* Composer Island migration tracker
*
* Goal
* - Replace the split composer stack (PromptInput + question/permission/todo docks)
* with a single morphing ComposerIsland + app runtime adapter.
*
* Current status
* - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`).
* - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`).
*
* Feature parity checklist
* - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error).
* - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers).
* - [x] Shell mode wiring parity (single mode source for tray + editor).
* - [x] Cursor scroll-into-view behavior in island editor.
* - [x] Attachment parity in island UI: image + PDF support and file picker wiring.
* - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention.
* - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation.
* - [x] Auto-accept + stop/send state parity in island UI.
* - [x] Async @mention/slash sourcing parity in island via runtime search hooks.
* - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending).
* - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending).
* - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending).
* - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item).
*/
export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
export type { SessionComposerState } from "./session-composer-state"

View File

@@ -1,7 +1,7 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Show, createMemo, createSignal, createEffect } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
export function SessionComposerRegion(props: {
state: SessionComposerState
ready: boolean
centered: boolean
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
@@ -21,23 +22,6 @@ export function SessionComposerRegion(props: {
onSubmit: () => void
onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
visualDuration?: number
bounce?: number
dockOpenVisualDuration?: number
dockOpenBounce?: number
dockCloseVisualDuration?: number
dockCloseBounce?: number
drawerExpandVisualDuration?: number
drawerExpandBounce?: number
drawerCollapseVisualDuration?: number
drawerCollapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const params = useParams()
const prompt = usePrompt()
@@ -63,73 +47,15 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [gate, setGate] = createStore({
ready: false,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
}
createEffect(() => {
sessionKey()
const ready = props.ready
const delay = 140
clear()
setGate("ready", false)
if (!ready) return
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setGate("ready", true)
timer = undefined
}, delay)
})
})
onCleanup(clear)
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
const config = createMemo(() =>
open()
? {
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
},
const open = createMemo(() => props.state.dock() && !props.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
DOCK_SPRING,
)
const progress = useSpring(() => (open() ? 1 : 0), config)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const [height, setHeight] = createSignal(320)
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
const full = createMemo(() => Math.max(78, height()))
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(() => {
const el = contentRef()
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
const height = useElementHeight(contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<div
@@ -179,10 +105,10 @@ export function SessionComposerRegion(props: {
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
"pointer-events-none": progress() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
"max-height": `${full() * progress()}px`,
}}
>
<div ref={setContentRef}>
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: {
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
visualDuration={props.visualDuration}
bounce={props.bounce}
expandVisualDuration={props.drawerExpandVisualDuration}
expandBounce={props.drawerExpandBounce}
collapseVisualDuration={props.drawerCollapseVisualDuration}
collapseBounce={props.drawerCollapseBounce}
subtitleDuration={props.subtitleDuration}
subtitleTravel={props.subtitleTravel}
subtitleEdge={props.subtitleEdge}
countDuration={props.countDuration}
countMask={props.countMask}
countMaskHeight={props.countMaskHeight}
countWidthDuration={props.countWidthDuration}
dockProgress={progress()}
/>
</div>
</div>
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: {
"relative z-10": true,
}}
style={{
"margin-top": `${-36 * value()}px`,
"margin-top": `${-36 * progress()}px`,
}}
>
<PromptInput

View File

@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
})
}
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
export function createSessionComposerState(
options?: {
closeMs?: number | (() => number)
},
) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()

View File

@@ -6,9 +6,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { useElementHeight } from "@opencode-ai/ui/hooks"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
export const COLLAPSED_HEIGHT = 78
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
@@ -40,19 +46,6 @@ export function SessionTodoDock(props: {
collapseLabel: string
expandLabel: string
dockProgress?: number
visualDuration?: number
bounce?: number
expandVisualDuration?: number
expandBounce?: number
collapseVisualDuration?: number
collapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const [store, setStore] = createStore({
collapsed: false,
@@ -73,39 +66,12 @@ export function SessionTodoDock(props: {
)
const preview = createMemo(() => active()?.content ?? "")
const config = createMemo(() =>
store.collapsed
? {
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.collapseBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.expandBounce ?? props.bounce ?? 0,
},
)
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98)
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const [height, setHeight] = createSignal(320)
const full = createMemo(() => Math.max(78, height()))
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
const hide = createMemo(() => Math.max(collapse(), shut()))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
const height = useElementHeight(() => contentRef, 320)
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
return (
<DockTray
@@ -113,7 +79,7 @@ export function SessionTodoDock(props: {
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
}}
>
<div ref={contentRef}>
@@ -133,12 +99,12 @@ export function SessionTodoDock(props: {
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
"--tool-motion-mask": `${props.countMask ?? 18}%`,
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
"--tool-motion-mask": `${COUNT.mask}%`,
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
opacity: `${1 - shut()}`,
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
}}
>
<AnimatedNumber value={done()} />
@@ -157,9 +123,9 @@ export function SessionTodoDock(props: {
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined}
duration={props.subtitleDuration ?? 600}
travel={props.subtitleTravel ?? 25}
edge={props.subtitleEdge ?? 17}
duration={SUBTITLE.duration}
travel={SUBTITLE.travel}
edge={SUBTITLE.edge}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
@@ -173,7 +139,7 @@ export function SessionTodoDock(props: {
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
style={{ transform: `rotate(${collapse() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
@@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
<div
data-slot="session-todo-list"
aria-hidden={store.collapsed || off()}
class="pb-2"
aria-hidden={store.collapsed}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
opacity: `${1 - hide()}`,
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
visibility: hide() > 0.98 ? "hidden" : "visible",
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
@@ -282,7 +249,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
opacity: todo().status === "pending" ? "0.5" : "1",
}}
>
<TextStrikethrough
@@ -292,12 +259,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
style={{
"line-height": "var(--line-height-normal)",
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
}}
/>
</Checkbox>

View File

@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
).toBe(true)
})
test("does not mark when nested scroller can consume movement", () => {
test("does not mark when scroller can consume movement", () => {
expect(
shouldMarkBoundaryGesture({
delta: 20,
scrollTop: 200,
scrollTop: 300,
scrollHeight: 1000,
clientHeight: 400,
}),

View File

@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
if (max <= 1) return true
if (!input.delta) return false
if (input.delta < 0) return input.scrollTop + input.delta <= 0
const remaining = max - input.scrollTop
return input.delta > remaining
const top = Math.max(0, Math.min(max, input.scrollTop))
if (input.delta < 0) return -input.delta > top
const bottom = max - top
return input.delta > bottom
}

View File

@@ -1,27 +1,31 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import {
For,
Index,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
Show,
startTransition,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
type MessageComment = {
path: string
@@ -33,7 +37,9 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const }
const isDefaultSessionTitle = (title?: string) =>
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
const [readySession, setReadySession] = createSignal("")
let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) {
cancelAnimationFrame(frame)
frame = undefined
}
const scheduleReady = (sessionKey: string) => {
if (input.sessionKey() !== sessionKey) return
if (readySession() === sessionKey) return
setReadySession(sessionKey)
}
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
const switched = active !== sessionKey
if (switched) {
active = sessionKey
setReadySession("")
}
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
if (staging && !switched && shouldStage && frame !== undefined) return
cancel()
const shouldStage =
isWindowed &&
total > input.config.init &&
state.completedSession !== sessionKey &&
state.activeSession !== sessionKey
if (shouldStage) setReadySession("")
if (!shouldStage) {
setState({ activeSession: "", count: total })
setState({
activeSession: "",
completedSession: isWindowed ? sessionKey : state.completedSession,
count: total,
})
if (total <= 0) {
setReadySession("")
return
}
if (readySession() !== sessionKey) scheduleReady(sessionKey)
return
}
let count = Math.min(total, input.config.init)
if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
setState("count", count)
startTransition(() => setState("count", count))
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
const ready = createMemo(() => readySession() === input.sessionKey())
onCleanup(cancel)
return { messages: stagedUserMessages, isStaging }
onCleanup(() => {
cancel()
})
return { messages: stagedUserMessages, isStaging, ready }
}
export function MessageTimeline(props: {
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -210,14 +246,19 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const trigger = (target: EventTarget | null) => {
const next =
target instanceof Element
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
: undefined
if (!(next instanceof HTMLElement)) return
return next
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
const messages = sessionMessages()
const message = pending()
if (message?.parentID) {
const result = Binary.search(messages, message.parentID, (item) => item.id)
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
if (parent?.role === "user") return parent.id
}
const status = sessionStatus()
if (status.type !== "idle") {
const messages = sessionMessages()
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
if (sessionStatus() === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
return undefined
})
const info = createMemo(() => {
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleValue = createMemo(() => {
const title = info()?.title
if (!title) return
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
return title
})
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
const headerTitle = createMemo(
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
)
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
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(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
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),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (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)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (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<string>([sessionID])
const byParent = new Map<string, string[]>()
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))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const rendered = createMemo(() => staging.messages().map((message) => message.id))
return (
<Show
@@ -498,6 +336,16 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<SessionTimelineHeader
centered={props.centered}
showHeader={showHeader}
sessionKey={sessionKey}
sessionID={sessionID}
parentID={parentID}
titleValue={titleValue}
headerTitle={headerTitle}
placeholderTitle={placeholderTitle}
/>
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
@@ -532,9 +380,18 @@ export function MessageTimeline(props: {
touchGesture = undefined
}}
onPointerDown={(e) => {
const next = trigger(e.target)
if (next) props.onPreserveScrollAnchor(next)
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return
const next = trigger(e.target)
if (!next) return
props.onPreserveScrollAnchor(next)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
@@ -543,131 +400,21 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
onClick={(e) => {
props.onAutoScrollInteraction(e)
}}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--session-title-height": showHeader() ? "72px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</Show>
<div>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
style={{ "padding-top": "var(--session-title-height)" }}
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -692,6 +439,15 @@ export function MessageTimeline(props: {
</Show>
<For each={rendered()}>
{(messageID) => {
// Capture at creation time: animate only messages added after the
// timeline finishes its initial backfill staging, plus the first
// turn while a brand new session is still using its default title.
const isNew =
staging.ready() ||
(defaultTitle() &&
sessionStatus() !== "idle" &&
props.renderedUserMessages.length === 1 &&
messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
@@ -700,7 +456,10 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
equals: (a, b) => {
if (a.length !== b.length) return false
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
},
})
const commentCount = createMemo(() => comments().length)
return (
@@ -757,10 +516,10 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
status={active() ? sessionStatus() : undefined}
animate={isNew || active()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
shellToolDefaultOpen={false}
editToolDefaultOpen={false}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",

View File

@@ -60,12 +60,6 @@ export function SessionSidePanel(props: {
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
@@ -93,21 +87,6 @@ export function SessionSidePanel(props: {
return out
})
const empty = (msg: string) => (
<div class="h-full flex flex-col">
<div class="h-12 shrink-0" aria-hidden />
<div class="flex-1 pb-30 flex items-center justify-center text-center">
<div class="text-12-regular text-text-weak">{msg}</div>
</div>
</div>
)
const nofiles = createMemo(() => {
const state = file.tree.state("")
if (!state?.loaded) return false
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
@@ -166,8 +145,17 @@ export function SessionSidePanel(props: {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
fileTreeScrolled: false,
})
let changesEl: HTMLDivElement | undefined
let allEl: HTMLDivElement | undefined
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
const next = (el?.scrollTop ?? 0) > 0
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
}
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -188,6 +176,11 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!layout.fileTree.opened()) return
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
})
createEffect(() => {
if (!file.ready()) return
@@ -214,7 +207,7 @@ export function SessionSidePanel(props: {
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weaker-base flex"
class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{
"flex-1": reviewOpen(),
"shrink-0": !reviewOpen(),
@@ -338,7 +331,9 @@ export function SessionSidePanel(props: {
const path = createMemo(() => file.pathFromTab(tab))
return (
<div data-component="tabs-drag-preview">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
<Show when={path()} keyed>
{(p) => <FileVisual active path={p} />}
</Show>
</div>
)
}}
@@ -352,7 +347,7 @@ export function SessionSidePanel(props: {
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
classList={{ "border-l border-border-weak-base": reviewOpen() }}
>
<Tabs
variant="pill"
@@ -361,7 +356,7 @@ export function SessionSidePanel(props: {
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
@@ -370,7 +365,12 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Tabs.Content
value="changes"
ref={(el: HTMLDivElement) => (changesEl = el)}
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
class="bg-background-stronger px-3 py-0"
>
<Switch>
<Match when={hasReview()}>
<Show
@@ -384,7 +384,6 @@ export function SessionSidePanel(props: {
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
@@ -393,23 +392,26 @@ export function SessionSidePanel(props: {
/>
</Show>
</Match>
<Match when={true}>{empty(language.t(reviewEmptyKey()))}</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
<div class="mt-8 text-center text-12-regular text-text-weak">
{language.t("session.review.noChanges")}
</div>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content
value="all"
ref={(el: HTMLDivElement) => (allEl = el)}
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
class="bg-background-stronger px-3 py-0"
>
<FileTree
path=""
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Tabs.Content>
</Tabs>
</div>
<ResizeHandle

View File

@@ -0,0 +1,522 @@
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
import { showToast } from "@opencode-ai/ui/toast"
import { errorMessage } from "@/pages/layout/helpers"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
export function SessionTimelineHeader(props: {
centered: boolean
showHeader: () => boolean
sessionKey: () => string
sessionID: () => string | undefined
parentID: () => string | undefined
titleValue: () => string | undefined
headerTitle: () => string | undefined
placeholderTitle: () => boolean
}) {
const navigate = useNavigate()
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const reduce = prefersReducedMotion
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
const [headerText, setHeaderText] = createStore({
session: props.sessionKey(),
value: props.headerTitle(),
prev: undefined as string | undefined,
muted: props.placeholderTitle(),
prevMuted: false,
})
let headerAnim: AnimationPlaybackControls | undefined
let enterAnim: AnimationPlaybackControls | undefined
let leaveAnim: AnimationPlaybackControls | undefined
let titleRef: HTMLInputElement | undefined
let headerRef: HTMLDivElement | undefined
let enterRef: HTMLSpanElement | undefined
let leaveRef: HTMLSpanElement | undefined
const clearHeaderAnim = () => {
headerAnim?.stop()
headerAnim = undefined
}
const animateHeader = () => {
const el = headerRef
if (!el) return
clearHeaderAnim()
if (!headerText.muted || reduce()) {
el.style.opacity = "1"
return
}
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
headerAnim.finished.then(() => {
if (headerRef !== el) return
clearFadeStyles(el)
})
}
const clearTitleAnims = () => {
enterAnim?.stop()
enterAnim = undefined
leaveAnim?.stop()
leaveAnim = undefined
}
const settleTitleEnter = () => {
if (enterRef) clearFadeStyles(enterRef)
}
const hideLeave = () => {
if (!leaveRef) return
leaveRef.style.opacity = "0"
leaveRef.style.filter = ""
leaveRef.style.transform = ""
}
const animateEnterSpan = () => {
if (!enterRef) return
if (reduce()) {
settleTitleEnter()
return
}
enterAnim = animate(
enterRef,
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
FAST_SPRING,
)
enterAnim.finished.then(() => settleTitleEnter())
}
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
setHeaderText({ value: nextTitle, muted: nextMuted })
if (reduce()) {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
settleTitleEnter()
return
}
if (leaveRef) {
leaveAnim = animate(
leaveRef,
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
FAST_SPRING,
)
leaveAnim.finished.then(() => {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
})
}
animateEnterSpan()
}
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
animateEnterSpan()
}
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
settleTitleEnter()
}
createEffect(
on(props.showHeader, (show, prev) => {
if (!show) {
clearHeaderAnim()
return
}
if (show === prev) return
animateHeader()
}),
)
createEffect(
on(
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
([nextSession, nextTitle, nextMuted]) => {
if (nextSession !== headerText.session) {
setHeaderText("session", nextSession)
if (nextTitle && nextMuted) {
fadeInTitle(nextTitle, nextMuted)
return
}
snapTitle(nextTitle, nextMuted)
return
}
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
if (!nextTitle) {
snapTitle(undefined, false)
return
}
if (!headerText.value) {
fadeInTitle(nextTitle, nextMuted)
return
}
if (title.saving || title.editing) {
snapTitle(nextTitle, nextMuted)
return
}
crossfadeTitle(nextTitle, nextMuted)
},
),
)
onCleanup(() => {
clearHeaderAnim()
clearTitleAnims()
})
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
createEffect(
on(
props.sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!props.sessionID()) return
setTitle({ editing: true, draft: props.titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = props.sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (props.titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
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: toastError(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((item) => item.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((item) => item.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.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: toastError(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
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((item) => !removed.has(item.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = props.parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(input: { sessionID: string }) {
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(input.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show when={props.showHeader()}>
<div
data-session-title
ref={(el) => {
headerRef = el
el.style.opacity = "0"
}}
class="pointer-events-none absolute inset-x-0 top-0 z-30"
>
<div
classList={{
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
"w-full": true,
"pb-10": true,
"px-4 md:px-5": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={props.parentID()}>
<div>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</div>
</Show>
<Show when={!!headerText.value || title.editing}>
<Show
when={title.editing}
fallback={
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
<span class="grid min-w-0" style={{ overflow: "clip" }}>
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
</span>
<span
ref={leaveRef}
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
style={{ opacity: "0" }}
>
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
</span>
</span>
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={props.sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</div>
</Show>
)
}

View File

@@ -154,7 +154,7 @@ export function TerminalPanel() {
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -187,12 +187,12 @@ export function TerminalPanel() {
onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10 border-b border-border-weaker-base">
<Tabs.List class="h-10">
<SortableProvider ids={ids()}>
<For each={ids()}>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
<Show when={byId().get(id)} keyed>
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
</Show>
)}
</For>
@@ -217,10 +217,10 @@ export function TerminalPanel() {
<div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
<Show when={byId().get(id)} keyed>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
</div>
)}
</Show>
@@ -229,14 +229,14 @@ export function TerminalPanel() {
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
<Show when={store.activeDraggable} keyed>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
<Show when={byId().get(draggedId)} keyed>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
title: t.title,
titleNumber: t.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>

View File

@@ -261,35 +261,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
])
const isAutoAcceptActive = () => {
const sessionID = params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
const permissionCommands = createMemo(() => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
if (sessionID) {
permission.toggleAutoAccept(sessionID, sdk.directory)
} else {
permission.toggleAutoAcceptDirectory(sdk.directory)
}
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: active
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})

View File

@@ -1,6 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onMount } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
autoScroll: { pause: () => void; snapToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: {
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
const location = useLocation()
const navigate = useNavigate()
const clearMessageHash = () => {
if (!location.hash) return
navigate(location.pathname + location.search, { replace: true })
if (!window.location.hash) return
window.history.replaceState(null, "", window.location.pathname + window.location.search)
}
const updateHash = (id: string) => {
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
replace: true,
})
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const inset = Number.isNaN(title) ? 0 : title
// With column-reverse, scrollTop is negative — don't clamp to 0
const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
console.log({ message, behavior })
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = location.hash.slice(1)
const hash = window.location.hash.slice(1)
if (!hash) {
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
return
}
input.autoScroll.forceScrollToBottom()
input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
const handler = () => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
}
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
location.hash
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
})
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
}
}
if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
})
return {
clearMessageHash,
scrollToMessage,

View File

@@ -1,3 +1,12 @@
export function isEditableTarget(target: EventTarget | null | undefined) {
if (!(target instanceof HTMLElement)) return false
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
const r = document.createRange()
r.selectNodeContents(lineElement)

View File

@@ -1,6 +0,0 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}

View File

@@ -21,7 +21,7 @@ export default function PrivacyPolicy() {
<section data-component="brand-content">
<article data-component="privacy-policy">
<h1>Privacy Policy</h1>
<p class="effective-date">Effective date: Mar 6, 2026</p>
<p class="effective-date">Effective date: Dec 16, 2025</p>
<p>
At OpenCode, we take your privacy seriously. Please read this Privacy Policy to learn how we treat your
@@ -30,10 +30,7 @@ export default function PrivacyPolicy() {
By using or accessing our Services in any manner, you acknowledge that you accept the practices and
policies outlined below, and you hereby consent that we will collect, use and disclose your
information as described in this Privacy Policy.
</strong>{" "}
For clarity, our open source software that is not provided to you on a hosted basis is subject to the
open source license and terms set forth on the applicable repository where you access such open source
software, and such license and terms will exclusively govern your use of such open source software.
</strong>
</p>
<p>
@@ -385,7 +382,9 @@ export default function PrivacyPolicy() {
</ul>
<h3>Parties You Authorize, Access or Authenticate</h3>
<p>Parties You Authorize, Access or Authenticate.</p>
<ul>
<li>Home buyers</li>
</ul>
<h3>Legal Obligations</h3>
<p>
@@ -1503,7 +1502,6 @@ export default function PrivacyPolicy() {
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Phone: +1 415 794-0209</li>
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
</ul>
</article>
</section>

View File

@@ -21,12 +21,12 @@ export default function TermsOfService() {
<section data-component="brand-content">
<article data-component="terms-of-service">
<h1>Terms of Use</h1>
<p class="effective-date">Effective date: Mar 6, 2026</p>
<p class="effective-date">Effective date: Dec 16, 2025</p>
<p>
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
OpenCode&apos;s website, inference product and hosted software offering (the "Services"). If you have
any questions, comments, or concerns regarding these terms or the Services, please contact us at:
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of OpenCode
(the "Services"). If you have any questions, comments, or concerns regarding these terms or the
Services, please contact us at:
</p>
<p>
@@ -44,10 +44,7 @@ export default function TermsOfService() {
and/or conditions ("Additional Terms"), which are incorporated herein by reference, and you understand
and agree that by using or participating in any such Services, you agree to also comply with these
Additional Terms.
</strong>{" "}
For clarity, our open source software that is not provided to you on a hosted basis is subject to the
open source license and terms set forth on the applicable repository where you access such open source
software, and such license and terms will exclusively govern your use of such open source software.
</strong>
</p>
<p>
@@ -463,10 +460,10 @@ export default function TermsOfService() {
<h4>Opt-out</h4>
<p>
You have the right to opt out of the provisions of this Section by sending written notice of your
decision to opt out to the following address: 2443 Fillmore St #380-6343, San Francisco, CA 94115,
United States postmarked within thirty (30) days of first accepting these Terms. You must include (i)
your name and residence address, (ii) the email address and/or telephone number associated with your
account, and (iii) a clear statement that you want to opt out of these Terms' arbitration agreement.
decision to opt out to the following address: [ADDRESS], [CITY], Canada [ZIP CODE] postmarked within
thirty (30) days of first accepting these Terms. You must include (i) your name and residence address,
(ii) the email address and/or telephone number associated with your account, and (iii) a clear statement
that you want to opt out of these Terms' arbitration agreement.
</p>
<h4>Exclusive Venue</h4>

View File

@@ -51,7 +51,7 @@ const migrations = await Promise.all(
Number(match[6]),
)
: 0
return { sql, timestamp, name }
return { sql, timestamp }
}),
)
console.log(`Loaded ${migrations.length} migrations`)

View File

@@ -111,10 +111,8 @@ export const TuiThreadCommand = cmd({
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const cwd = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: root
const root = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
const file = await target()
try {
process.chdir(cwd)

View File

@@ -56,7 +56,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
setStore("active", order[nextIndex])
evt.preventDefault()
}
if (evt.name === "space" || evt.name === " ") {
if (evt.name === "space") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)

View File

@@ -62,14 +62,13 @@ function track(directory: string, next: Promise<Context>) {
export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
let existing = cache.get(directory)
let existing = cache.get(input.directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
Log.Default.info("creating instance", { directory: input.directory })
existing = track(
directory,
input.directory,
boot({
directory,
directory: input.directory,
init: input.init,
}),
)
@@ -104,12 +103,11 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await State.dispose(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
Log.Default.info("reloading instance", { directory: input.directory })
await State.dispose(input.directory)
cache.delete(input.directory)
const next = track(input.directory, boot(input))
emit(input.directory)
return await next
},
async dispose() {

View File

@@ -1231,42 +1231,6 @@ export namespace Provider {
}
}
async function pick(providerID: string, query: string[]) {
const provider = await state().then((state) => state.providers[providerID])
if (!provider) return
const models = Object.keys(provider.models)
for (const item of query) {
if (providerID === "amazon-bedrock") {
const prefixes = ["global.", "us.", "eu."]
const candidates = models.filter((model) => model.toLowerCase().includes(item.toLowerCase()))
// Model selection priority:
// 1. global. prefix (works everywhere)
// 2. User's region prefix (us., eu.)
// 3. Unprefixed model
const best = candidates.find((model) => model.startsWith("global."))
if (best) return getModel(providerID, best)
const region = provider.options?.region
if (region) {
const prefix = region.split("-")[0]
if (prefix === "us" || prefix === "eu") {
const hit = candidates.find((model) => model.startsWith(`${prefix}.`))
if (hit) return getModel(providerID, hit)
}
}
const bare = candidates.find((model) => !prefixes.some((prefix) => model.startsWith(prefix)))
if (bare) return getModel(providerID, bare)
continue
}
const hit = models.find((model) => model.toLowerCase().includes(item.toLowerCase()))
if (hit) return getModel(providerID, hit)
}
}
export async function getSmallModel(providerID: string) {
const cfg = await Config.get()
@@ -1275,25 +1239,54 @@ export namespace Provider {
return getModel(parsed.providerID, parsed.modelID)
}
let query = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
query = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
// prioritize free models for github copilot
query = ["gpt-5-mini", "claude-haiku-4.5", ...query]
}
const provider = await state().then((state) => state.providers[providerID])
if (provider) {
let priority = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
// prioritize free models for github copilot
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
if (providerID === "amazon-bedrock") {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const model = await pick(providerID, query)
if (model) return model
// Model selection priority:
// 1. global. prefix (works everywhere)
// 2. User's region prefix (us., eu.)
// 3. Unprefixed model
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return getModel(providerID, globalMatch)
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return getModel(providerID, regionalMatch)
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return getModel(providerID, unprefixed)
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return getModel(providerID, model)
}
}
}
}
// Check if opencode provider is available before using it
const opencodeProvider = await state().then((state) => state.providers["opencode"])
@@ -1304,22 +1297,6 @@ export namespace Provider {
return undefined
}
export async function getExploreModel(providerID: string) {
const model = await pick(providerID, [
"gpt-5.3-codex-spark",
"claude-haiku-4-5",
"claude-haiku-4.5",
"gemini-3-flash",
"minimax-m2.5",
"minimax-m2-5",
"glm-5",
"kimi-k2.5",
"kimi-k2-5",
])
if (model) return model
return undefined
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
export function sort(models: Model[]) {
return sortBy(

View File

@@ -195,11 +195,18 @@ export namespace Pty {
session.bufferCursor += excess
})
ptyProcess.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const [key, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
remove(id)
state().delete(id)
})
Bus.publish(Event.Created, { info })
return info
@@ -221,7 +228,6 @@ export namespace Pty {
export async function remove(id: string) {
const session = state().get(id)
if (!session) return
state().delete(id)
log.info("removing session", { id })
try {
session.process.kill()
@@ -234,6 +240,7 @@ export namespace Pty {
}
}
session.subscribers.clear()
state().delete(id)
Bus.publish(Event.Deleted, { id })
}

View File

@@ -38,7 +38,6 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -199,15 +198,13 @@ export namespace Server {
if (c.req.path === "/log") return next()
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = Filesystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
const directory = (() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
return WorkspaceContext.provide({
workspaceID,

View File

@@ -27,14 +27,12 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export namespace Database {
export function file(channel: string) {
if (channel === "latest" || Flag.OPENCODE_DISABLE_CHANNEL_DB) return "opencode.db"
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
return `opencode-${safe}.db`
}
export const Path = (() => {
return path.join(Global.Path.data, file(Installation.CHANNEL))
const name =
Installation.CHANNEL !== "latest" && !Flag.OPENCODE_DISABLE_CHANNEL_DB
? `opencode-${Installation.CHANNEL}.db`
: "opencode.db"
return path.join(Global.Path.data, name)
})()
type Schema = typeof schema

View File

@@ -5,7 +5,6 @@ import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
@@ -103,30 +102,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const info = msg.info
const model = await iife(async () => {
if (agent.model) return agent.model
if (agent.name !== "explore") {
return {
modelID: info.modelID,
providerID: info.providerID,
}
}
const pick = await Provider.getExploreModel(info.providerID)
if (pick) {
return {
modelID: pick.id,
providerID: pick.providerID,
}
}
return {
modelID: info.modelID,
providerID: info.providerID,
}
})
const model = agent.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.metadata({
title: params.description,

View File

@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { dirname, join, relative } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "./glob"
@@ -113,22 +113,16 @@ export namespace Filesystem {
}
}
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
export function resolve(p: string): string {
return normalizePath(pathResolve(windowsPath(p)))
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (
p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically /<drive>/...
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/...
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
)
}
export function overlaps(a: string, b: string) {

View File

@@ -2,14 +2,7 @@ import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
let parsed
try {
parsed = schema.parse(input)
} catch (e) {
console.trace("schema validation failure stack trace:")
throw e
}
const parsed = schema.parse(input)
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)

View File

@@ -3,8 +3,8 @@ import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
path: env?.PATH,
pathExt: env?.PATHEXT,
})
return typeof result === "string" ? result : null
}

View File

@@ -25,34 +25,6 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
}
async function check(map: (dir: string) => string) {
if (process.platform !== "win32") return
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
Config.global.reset()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
snapshot: false,
})
await Instance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await Config.get()
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe("global")
},
})
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
Config.global.reset()
}
}
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -84,23 +56,6 @@ test("loads JSON config file", async () => {
})
})
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/${drive}${rest}`
})
})
test("loads project config from Cygwin paths on Windows", async () => {
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/cygdrive/${drive}${rest}`
})
})
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -964,129 +964,6 @@ test("getSmallModel respects config small_model override", async () => {
})
})
test("getExploreModel matches fallback models case-insensitively", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"MiniMax-M2-5": {
name: "MiniMax M2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"GLM-5": {
name: "GLM-5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"Kimi-K2-5": {
name: "Kimi K2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeDefined()
expect(model?.id).toBe("MiniMax-M2-5")
},
})
})
test("getExploreModel matches kimi separator variant", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"Kimi-K2-5": {
name: "Kimi K2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeDefined()
expect(model?.id).toBe("Kimi-K2-5")
},
})
})
test("getExploreModel returns undefined when no explore model matches", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"custom-model": {
name: "Custom Model",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeUndefined()
},
})
})
test("provider.sort prioritizes preferred models", () => {
const models = [
{ id: "random-model", name: "Random" },

View File

@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
const wait = async (fn: () => boolean, ms = 2000) => {
const end = Date.now() + ms
while (Date.now() < end) {
if (fn()) return
await sleep(25)
}
throw new Error("timeout waiting for pty events")
}
const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => {
return log.filter((evt) => evt.id === id).map((evt) => evt.type)
}
describe("pty", () => {
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id = ""
try {
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
id = info.id
await wait(() => pick(log, id).includes("exited"))
await Pty.remove(id)
await wait(() => pick(log, id).length >= 3)
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
test("publishes created, exited, deleted in order for /bin/sh + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id = ""
try {
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
id = info.id
await sleep(100)
await Pty.remove(id)
await wait(() => pick(log, id).length >= 3)
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
})

View File

@@ -1,12 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Database } from "../../src/storage/db"
describe("Database.file", () => {
test("uses the shared database for latest", () => {
expect(Database.file("latest")).toBe("opencode.db")
})
test("sanitizes preview channels for filenames", () => {
expect(Database.file("fix/windows-modified-files-tracking")).toBe("opencode-fix-windows-modified-files-tracking.db")
})
})

View File

@@ -440,67 +440,4 @@ describe("filesystem", () => {
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
describe("resolve()", () => {
test("resolves slash-prefixed drive paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const forward = tmp.path.replaceAll("\\", "/")
expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves slash-prefixed drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toUpperCase()
expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
})
test("resolves Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
// Git Bash and MSYS2 both use /<drive> paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves Cygwin paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Cygwin drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves WSL mount paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves WSL mount roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
})
})

View File

@@ -22,13 +22,6 @@ function env(PATH: string): NodeJS.ProcessEnv {
}
}
function envPath(Path: string): NodeJS.ProcessEnv {
return {
Path,
PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
@@ -86,15 +79,4 @@ describe("util.which", () => {
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
test("uses Windows Path casing fallback", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "mixed")
same(which("mixed", envPath(bin)), file)
})
})

View File

@@ -4,8 +4,8 @@ const keybinds: Record<string, string> = {
"prompt.mode.normal": "mod+shift+e",
"permissions.autoaccept": "mod+shift+a",
"agent.cycle": "mod+.",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
"model.choose": "mod+'",
"model.variant.cycle": "shift+mod+d",
}
export function useCommand() {

3
packages/ui/happydom.ts Normal file
View File

@@ -0,0 +1,3 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
GlobalRegistrator.register()

View File

@@ -25,10 +25,14 @@
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun run test:unit",
"test:unit": "bun test --preload ./happydom.ts ./src/components",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src/components",
"dev": "vite",
"generate:tailwind": "bun run script/tailwind.ts"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",

View File

@@ -114,7 +114,6 @@
--border-weak-selected: var(--cobalt-light-alpha-5);
--border-weak-disabled: var(--smoke-light-alpha-6);
--border-weak-focus: var(--smoke-light-alpha-7);
--border-weaker-base: var(--smoke-light-alpha-3);
--border-interactive-base: var(--cobalt-light-7);
--border-interactive-hover: var(--cobalt-light-8);
--border-interactive-active: var(--cobalt-light-9);
@@ -225,5 +224,11 @@
--markdown-image-text: #318795;
--markdown-code-block: #1A1A1A;
--border-color: #FFFFFF;
--border-weaker-base: var(--smoke-light-alpha-3);
--border-weaker-hover: var(--smoke-light-alpha-4);
--border-weaker-active: var(--smoke-light-alpha-6);
--border-weaker-selected: var(--cobalt-light-alpha-4);
--border-weaker-disabled: var(--smoke-light-alpha-2);
--border-weaker-focus: var(--smoke-light-alpha-6);
--button-ghost-hover: var(--smoke-light-alpha-2);
--button-ghost-hover2: var(--smoke-light-alpha-3);

Some files were not shown because too many files have changed in this diff Show More