Compare commits

..
Author SHA1 Message Date
Dax Raad 6c80e2662c core: make server runtime-agnostic by migrating from Bun to Node.js HTTP/WebSocket APIs
This enables running the opencode server on standard Node.js runtimes without requiring Bun-specific APIs. Users can now deploy the server in more environments including standard Node.js containers and cloud platforms that don't support Bun.
2026-03-09 16:36:39 -04:00
404 changed files with 7653 additions and 13525 deletions
+8 -17
View File
@@ -3,6 +3,14 @@ description: "Setup Bun with caching and install dependencies"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Get baseline download URL - name: Get baseline download URL
id: bun-url id: bun-url
shell: bash shell: bash
@@ -23,23 +31,6 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }} bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
shell: bash shell: bash
-11
View File
@@ -115,9 +115,6 @@ jobs:
target: x86_64-apple-darwin target: x86_64-apple-darwin
- host: macos-latest - host: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025 - host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404 - host: blacksmith-4vcpu-ubuntu-2404
@@ -152,10 +149,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages - name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4 uses: actions/cache@v4
@@ -261,10 +254,6 @@ jobs:
- host: macos-latest - host: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
platform_flag: --mac --arm64 platform_flag: --mac --arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
- host: "blacksmith-4vcpu-windows-2025" - host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
platform_flag: --win platform_flag: --win
+15 -8
View File
@@ -6,14 +6,6 @@ on:
- dev - dev
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (${{ matrix.settings.name }})
@@ -94,3 +86,18 @@ jobs:
path: | path: |
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report
required:
name: test (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"
-1
View File
@@ -1,4 +1,3 @@
plans/ plans/
bun.lock bun.lock
package.json package.json
package-lock.json
+13 -7
View File
@@ -5,8 +5,16 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"], zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"], tui: [
core: ["thdxr", "rekram1-node", "jlongster"], "thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
docs: ["R44VC0RP"], docs: ["R44VC0RP"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
@@ -42,10 +50,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({ export default tool({
description: DESCRIPTION, description: DESCRIPTION,
args: { args: {
assignee: tool.schema assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue") .describe("The labels(s) to add to the issue")
@@ -68,7 +73,8 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)") results.push("Dropped label: nix (issue does not mention nix)")
} }
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee // const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) { if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'") throw new Error("Only add the zen label when issue title/body contains 'zen'")
+2
View File
@@ -4,3 +4,5 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner. Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random. If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)
-4
View File
@@ -122,7 +122,3 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
## Type Checking
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
--- ---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) **加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
--- ---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) **加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+775 -653
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -103,12 +103,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
const zenLiteProduct = new stripe.Product("ZenLite", { const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go", name: "OpenCode Go",
}) })
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", { const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id, product: zenLiteProduct.id,
currency: "usd", currency: "usd",
@@ -122,7 +116,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: { properties: {
product: zenLiteProduct.id, product: zenLiteProduct.id,
price: zenLitePrice.id, price: zenLitePrice.id,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
}, },
}) })
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=", "x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=", "aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=", "aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI=" "x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
} }
} }
-1
View File
@@ -43,7 +43,6 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.31",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
-9
View File
@@ -70,8 +70,6 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog - `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog - `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session - `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace - `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL - `sessionIDFromUrl(url)` - Read session ID from URL
@@ -169,13 +167,6 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings await page.keyboard.press(`${modKey}+Comma`) // Open settings
``` ```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
## Writing New Tests ## Writing New Tests
1. Choose appropriate folder or create new one 1. Choose appropriate folder or create new one
-49
View File
@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
@@ -16,7 +15,6 @@ import {
listItemSelector, listItemSelector,
listItemKeySelector, listItemKeySelector,
listItemKeyStartsWithSelector, listItemKeyStartsWithSelector,
terminalSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceMenuTriggerSelector, workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
@@ -30,53 +28,6 @@ export async function defocus(page: Page) {
.catch(() => undefined) .catch(() => undefined)
} }
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) { export async function openPalette(page: Page) {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+P`) await page.keyboard.press(`${modKey}+P`)
-9
View File
@@ -1,5 +1,4 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions" import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors" import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -92,14 +91,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) { async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input) await seedProjects(page, input)
await page.addInitScript(() => { await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:model", "opencode.global.dat:model",
JSON.stringify({ JSON.stringify({
@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -7,29 +6,18 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector) const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal") await prompt.click()
await expect(slash).toBeVisible() await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal }) await expect(terminal).toBeVisible()
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening, await prompt.click()
// which can steal focus from the prompt and prevent fill() from triggering await page.keyboard.type("/terminal")
// the slash popover. Re-attempt click+fill until all retries are exhausted await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
}) })
+1 -2
View File
@@ -1,6 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]' export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]' export const terminalSelector = '[data-component="terminal"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]' export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
@@ -1,217 +0,0 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, file)
}
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions" import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors" import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await waitTerminalReady(page, { term: terminal }) await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`) await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible() await expect(terminal).not.toBeVisible()
@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors" import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,7 +13,8 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
} }
await waitTerminalReady(page, { term: terminals.first() }) await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back // Ghostty captures a lot of keybinds when focused; move focus back
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1) await expect(terminals).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() }) await expect(terminals.first().locator("textarea")).toHaveCount(1)
}) })
@@ -1,5 +1,4 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors" import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils" import { terminalToggleKey, workspacePersistKey } from "../utils"
@@ -18,7 +17,16 @@ async function open(page: Page) {
const terminal = page.locator(terminalSelector) const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false) const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey) if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal }) 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) { async function store(page: Page, key: string) {
@@ -48,16 +56,15 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
await gotoSession() await gotoSession()
await open(page) await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one }) await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click() await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two }) await run(page, `echo ${two}`)
await first.click() await first.click()
await expect(first).toHaveAttribute("aria-selected", "true") await expect(first).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -69,7 +76,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 30_000 },
) )
.toEqual({ first: false, second: true }) .toEqual({ first: false, second: true })
@@ -86,7 +93,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 30_000 },
) )
.toEqual({ first: true, second: false }) .toEqual({ first: true, second: false })
}) })
+1 -2
View File
@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors" import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils" import { terminalToggleKey } from "../utils"
@@ -14,5 +13,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
} }
await page.keyboard.press(terminalToggleKey) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal }) await expect(terminal).toBeVisible()
}) })
+1 -2
View File
@@ -45,8 +45,8 @@
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2", "@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
@@ -56,7 +56,6 @@
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:", "luxon": "catalog:",
+11 -126
View File
@@ -1,29 +1,14 @@
import "@/index.css" import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type Duration, Effect } from "effect" import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import {
type Component,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
@@ -37,13 +22,13 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
@@ -77,9 +62,6 @@ declare global {
deepLinks?: string[] deepLinks?: string[]
wsl?: boolean wsl?: boolean
} }
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
} }
} }
@@ -133,11 +115,7 @@ export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
<ThemeProvider <ThemeProvider>
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider> <LanguageProvider>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
@@ -154,108 +132,15 @@ export function AppBaseProviders(props: ParentProps) {
) )
} }
const effectMinDuration = function ServerKey(props: ParentProps) {
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps) {
const server = useServer() const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
return ( return (
<Show <Show when={server.key} keyed>
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"} {props.children}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show> </Show>
) )
} }
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
</p>
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">Other servers</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
export function AppInterface(props: { export function AppInterface(props: {
children?: JSX.Element children?: JSX.Element
defaultServer: ServerConnection.Key defaultServer: ServerConnection.Key
@@ -264,7 +149,7 @@ export function AppInterface(props: {
}) { }) {
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Dynamic <Dynamic
@@ -279,7 +164,7 @@ export function AppInterface(props: {
</Dynamic> </Dynamic>
</GlobalSyncProvider> </GlobalSyncProvider>
</GlobalSDKProvider> </GlobalSDKProvider>
</ConnectionGate> </ServerKey>
</ServerProvider> </ServerProvider>
) )
} }
-441
View File
@@ -1,441 +0,0 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip"
type Mem = Performance & {
memory?: {
usedJSHeapSize: number
jsHeapSizeLimit: number
}
}
type Evt = PerformanceEntry & {
interactionId?: number
processingStart?: number
}
type Shift = PerformanceEntry & {
hadRecentInput: boolean
value: number
}
type Obs = PerformanceObserverInit & {
durationThreshold?: number
}
const span = 5000
const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${n.toFixed(d)}ms`
}
const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${Math.round(n)}`
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return "n/a"
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
}
const session = (path: string) => path.includes("/session")
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
return (
<Tooltip value={props.tip} placement="top">
<div
classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide,
}}
>
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
<div
classList={{
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
</Tooltip>
)
}
export function DebugBar() {
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
cls: undefined as number | undefined,
delay: undefined as number | undefined,
fps: undefined as number | undefined,
gap: undefined as number | undefined,
heap: {
limit: undefined as number | undefined,
used: undefined as number | undefined,
},
inp: undefined as number | undefined,
jank: undefined as number | undefined,
long: {
block: undefined as number | undefined,
count: undefined as number | undefined,
max: undefined as number | undefined,
},
nav: {
dur: undefined as number | undefined,
pending: false,
},
})
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
const value = heap()
if (value === undefined) return "n/a"
return `${Math.round(value * 100)}%`
}
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
let prev = ""
let start = 0
let init = false
let one = 0
let two = 0
createEffect(() => {
const busy = routing()
const next = `${location.pathname}${location.search}`
if (!init) {
init = true
prev = next
return
}
if (busy) {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = 0
two = 0
if (start !== 0) return
start = performance.now()
if (session(prev)) setState("nav", { dur: undefined, pending: true })
return
}
if (start === 0) {
prev = next
return
}
const at = start
const from = prev
start = 0
prev = next
if (!(session(from) || session(next))) return
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
one = requestAnimationFrame(() => {
one = 0
two = requestAnimationFrame(() => {
two = 0
setState("nav", { dur: performance.now() - at, pending: false })
})
})
})
onMount(() => {
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
let hasLong = false
let poll: number | undefined
let raf = 0
let last = 0
let snap = 0
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
while (list[0] && at - list[0].at > span) list.shift()
}
const syncFrame = (at: number) => {
trim(fps, span, at)
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
const jank = fps.filter((entry) => entry.dur > 32).length
batch(() => {
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
setState("gap", gap > 0 ? gap : undefined)
setState("jank", jank)
})
}
const syncLong = (at = performance.now()) => {
if (!hasLong) return
trim(long, span, at)
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
setState("long", { block, count: long.length, max })
}
const syncInp = (at = performance.now()) => {
for (const [key, entry] of seen) {
if (at - entry.at > span) seen.delete(key)
}
let delay = 0
let inp = 0
for (const entry of seen.values()) {
delay = Math.max(delay, entry.delay)
inp = Math.max(inp, entry.dur)
}
batch(() => {
setState("delay", delay > 0 ? delay : undefined)
setState("inp", inp > 0 ? inp : undefined)
})
}
const syncHeap = () => {
const mem = (performance as Mem).memory
if (!mem) return
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
}
const reset = () => {
fps.length = 0
long.length = 0
seen.clear()
last = 0
snap = 0
batch(() => {
setState("fps", undefined)
setState("gap", undefined)
setState("jank", undefined)
setState("delay", undefined)
setState("inp", undefined)
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
})
}
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
if (typeof PerformanceObserver === "undefined") return false
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
try {
ob.observe(init)
obs.push(ob)
return true
} catch {
ob.disconnect()
return false
}
}
if (
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
const add = entries.reduce((sum, entry) => {
const item = entry as Shift
if (item.hadRecentInput) return sum
return sum + item.value
}, 0)
if (add === 0) return
setState("cls", (value) => (value ?? 0) + add)
})
) {
setState("cls", 0)
}
if (
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
const at = performance.now()
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
syncLong(at)
})
) {
hasLong = true
setState("long", { block: 0, count: 0, max: 0 })
}
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
for (const raw of entries) {
const entry = raw as Evt
if (entry.duration < 16) continue
const key =
entry.interactionId && entry.interactionId > 0
? entry.interactionId
: `${entry.name}:${Math.round(entry.startTime)}`
const prev = seen.get(key)
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
seen.set(key, {
at: entry.startTime,
delay: Math.max(prev?.delay ?? 0, delay),
dur: Math.max(prev?.dur ?? 0, entry.duration),
})
if (seen.size <= 200) continue
const first = seen.keys().next().value
if (first !== undefined) seen.delete(first)
}
syncInp()
})
const loop = (at: number) => {
if (document.visibilityState !== "visible") {
raf = 0
return
}
if (last === 0) {
last = at
raf = requestAnimationFrame(loop)
return
}
fps.push({ at, dur: at - last })
last = at
if (at - snap >= 250) {
snap = at
syncFrame(at)
}
raf = requestAnimationFrame(loop)
}
const stop = () => {
if (raf !== 0) cancelAnimationFrame(raf)
raf = 0
if (poll === undefined) return
clearInterval(poll)
poll = undefined
}
const start = () => {
if (document.visibilityState !== "visible") return
if (poll === undefined) {
poll = window.setInterval(() => {
syncLong()
syncInp()
syncHeap()
}, 1000)
}
if (raf !== 0) return
raf = requestAnimationFrame(loop)
}
const vis = () => {
if (document.visibilityState !== "visible") {
stop()
return
}
reset()
start()
}
syncHeap()
start()
document.addEventListener("visibilitychange", vis)
onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect()
})
})
return (
<aside
aria-label="Development performance diagnostics"
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label="NAV"
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
/>
<Cell
label="FPS"
tip="Rolling frames per second over the last 5 seconds."
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
/>
<Cell
label="FRAME"
tip="Worst frame time over the last 5 seconds."
value={time(state.gap)}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
/>
<Cell
label="JANK"
tip="Frames over 32ms in the last 5 seconds."
value={state.jank === undefined ? "n/a" : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
/>
<Cell
label="LONG"
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
/>
<Cell
label="DELAY"
tip="Worst observed input delay in the last 5 seconds."
value={time(state.delay)}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
/>
<Cell
label="INP"
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
value={time(state.inp)}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
/>
<Cell
label="CLS"
tip="Cumulative layout shift for the current app lifetime."
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
/>
<Cell
label="MEM"
tip={
state.heap.used === undefined
? "Used JS heap vs heap limit. Chromium only."
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
wide
/>
</div>
</aside>
)
}
@@ -1,159 +0,0 @@
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
export type ModelErr = {
id?: string
name?: string
}
export type HeaderErr = {
key?: string
value?: string
}
export type ModelRow = {
row: string
id: string
name: string
err: ModelErr
}
export type HeaderRow = {
row: string
key: string
value: string
err: HeaderErr
}
export type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
err: {
providerID?: string
name?: string
baseURL?: string
}
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
export function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const models = input.form.models.map((m) => {
const id = m.id.trim()
const idError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: idError, name: nameError }
})
const modelsValid = models.every((m) => !m.id && !m.name)
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headers = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headers.every((h) => !h.key && !h.value)
const headerConfig = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const err = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { err, models, headers }
return {
err,
models,
headers,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
},
models: modelConfig,
},
},
}
}
let row = 0
const nextRow = () => `row-${row++}`
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
@@ -1,82 +0,0 @@
import { describe, expect, test } from "bun:test"
import { validateCustomProvider } from "./dialog-custom-provider-form"
const t = (key: string) => key
describe("validateCustomProvider", () => {
test("builds trimmed config payload", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: " Custom Provider ",
baseURL: "https://api.example.com ",
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
headers: [
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
],
saving: false,
err: {},
},
t,
disabledProviders: [],
existingProviderIDs: new Set(),
})
expect(result.result).toEqual({
providerID: "custom-provider",
name: "Custom Provider",
key: undefined,
config: {
npm: "@ai-sdk/openai-compatible",
name: "Custom Provider",
env: ["CUSTOM_PROVIDER_KEY"],
options: {
baseURL: "https://api.example.com",
headers: {
"X-Test": "enabled",
},
},
models: {
"model-a": { name: "Model A" },
},
},
})
})
test("flags duplicate rows and allows reconnecting disabled providers", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: "Provider",
baseURL: "https://api.example.com",
apiKey: "secret",
models: [
{ row: "m0", id: "model-a", name: "Model A", err: {} },
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
],
headers: [
{ row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} },
],
saving: false,
err: {},
},
t,
disabledProviders: ["custom-provider"],
existingProviderIDs: new Set(["custom-provider"]),
})
expect(result.result).toBeUndefined()
expect(result.err.providerID).toBeUndefined()
expect(result.models[1]).toEqual({
id: "provider.custom.error.duplicate",
name: undefined,
})
expect(result.headers[1]).toEqual({
key: "provider.custom.error.duplicate",
value: undefined,
})
})
})
@@ -5,15 +5,158 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js" import { For } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
} }
@@ -29,10 +172,17 @@ export function DialogCustomProvider(props: Props) {
name: "", name: "",
baseURL: "", baseURL: "",
apiKey: "", apiKey: "",
models: [modelRow()], models: [{ id: "", name: "" }],
headers: [headerRow()], headers: [{ key: "", value: "" }],
saving: false, saving: false,
err: {}, })
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
}) })
const goBack = () => { const goBack = () => {
@@ -44,61 +194,25 @@ export function DialogCustomProvider(props: Props) {
} }
const addModel = () => { const addModel = () => {
setForm( setForm("models", (v) => [...v, { id: "", name: "" }])
"models", setErrors("models", (v) => [...v, {}])
produce((rows) => {
rows.push(modelRow())
}),
)
} }
const removeModel = (index: number) => { const removeModel = (index: number) => {
if (form.models.length <= 1) return if (form.models.length <= 1) return
setForm( setForm("models", (v) => v.filter((_, i) => i !== index))
"models", setErrors("models", (v) => v.filter((_, i) => i !== index))
produce((rows) => {
rows.splice(index, 1)
}),
)
} }
const addHeader = () => { const addHeader = () => {
setForm( setForm("headers", (v) => [...v, { key: "", value: "" }])
"headers", setErrors("headers", (v) => [...v, {}])
produce((rows) => {
rows.push(headerRow())
}),
)
} }
const removeHeader = (index: number) => { const removeHeader = (index: number) => {
if (form.headers.length <= 1) return if (form.headers.length <= 1) return
setForm( setForm("headers", (v) => v.filter((_, i) => i !== index))
"headers", setErrors("headers", (v) => v.filter((_, i) => i !== index))
produce((rows) => {
rows.splice(index, 1)
}),
)
}
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
setForm(key, value)
if (key === "apiKey") return
setForm("err", key, undefined)
}
const setModel = (index: number, key: "id" | "name", value: string) => {
batch(() => {
setForm("models", index, key, value)
setForm("models", index, "err", key, undefined)
})
}
const setHeader = (index: number, key: "key" | "value", value: string) => {
batch(() => {
setForm("headers", index, key, value)
setForm("headers", index, "err", key, undefined)
})
} }
const validate = () => { const validate = () => {
@@ -108,11 +222,7 @@ export function DialogCustomProvider(props: Props) {
disabledProviders: globalSync.data.config.disabled_providers ?? [], disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
}) })
batch(() => { setErrors(output.errors)
setForm("err", output.err)
output.models.forEach((err, index) => setForm("models", index, "err", err))
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
})
return output.result return output.result
} }
@@ -195,32 +305,32 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}
value={form.providerID} value={form.providerID}
onChange={(v) => setField("providerID", v)} onChange={(v) => setForm("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined} validationState={errors.providerID ? "invalid" : undefined}
error={form.err.providerID} error={errors.providerID}
/> />
<TextField <TextField
label={language.t("provider.custom.field.name.label")} label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")} placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name} value={form.name}
onChange={(v) => setField("name", v)} onChange={(v) => setForm("name", v)}
validationState={form.err.name ? "invalid" : undefined} validationState={errors.name ? "invalid" : undefined}
error={form.err.name} error={errors.name}
/> />
<TextField <TextField
label={language.t("provider.custom.field.baseURL.label")} label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")} placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL} value={form.baseURL}
onChange={(v) => setField("baseURL", v)} onChange={(v) => setForm("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined} validationState={errors.baseURL ? "invalid" : undefined}
error={form.err.baseURL} error={errors.baseURL}
/> />
<TextField <TextField
label={language.t("provider.custom.field.apiKey.label")} label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")} placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")} description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey} value={form.apiKey}
onChange={(v) => setField("apiKey", v)} onChange={(v) => setForm("apiKey", v)}
/> />
</div> </div>
@@ -228,16 +338,16 @@ export function DialogCustomProvider(props: Props) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label> <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}> <For each={form.models}>
{(m, i) => ( {(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}> <div class="flex gap-2 items-start">
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label={language.t("provider.custom.models.id.label")} label={language.t("provider.custom.models.id.label")}
hideLabel hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")} placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id} value={m.id}
onChange={(v) => setModel(i(), "id", v)} onChange={(v) => setForm("models", i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined} validationState={errors.models[i()]?.id ? "invalid" : undefined}
error={m.err.id} error={errors.models[i()]?.id}
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -246,9 +356,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")} placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name} value={m.name}
onChange={(v) => setModel(i(), "name", v)} onChange={(v) => setForm("models", i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined} validationState={errors.models[i()]?.name ? "invalid" : undefined}
error={m.err.name} error={errors.models[i()]?.name}
/> />
</div> </div>
<IconButton <IconButton
@@ -272,16 +382,16 @@ export function DialogCustomProvider(props: Props) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label> <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}> <For each={form.headers}>
{(h, i) => ( {(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}> <div class="flex gap-2 items-start">
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label={language.t("provider.custom.headers.key.label")} label={language.t("provider.custom.headers.key.label")}
hideLabel hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")} placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key} value={h.key}
onChange={(v) => setHeader(i(), "key", v)} onChange={(v) => setForm("headers", i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined} validationState={errors.headers[i()]?.key ? "invalid" : undefined}
error={h.err.key} error={errors.headers[i()]?.key}
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -290,9 +400,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")} placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value} value={h.value}
onChange={(v) => setHeader(i(), "value", v)} onChange={(v) => setForm("headers", i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined} validationState={errors.headers[i()]?.value ? "invalid" : undefined}
error={h.err.value} error={errors.headers[i()]?.value}
/> />
</div> </div>
<IconButton <IconButton
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useNavigate } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command" import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,8 +14,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
@@ -134,14 +132,9 @@ function createFileEntries(props: {
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => { const recent = createMemo(() => {
const all = tabState.openedTabs() const all = props.tabs().all()
const active = tabState.activeFileTab() const active = props.tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>() const seen = new Set<string>()
const category = props.language.t("palette.group.files") const category = props.language.t("palette.group.files")
@@ -266,11 +259,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const layout = useLayout() const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files" const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false) const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language }) const commandEntries = createCommandEntries({ filesOnly, command, language })
@@ -14,9 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -43,15 +41,13 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
}) })
} }
function useDefaultServer() { function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const language = useLanguage() const [defaultUrl, defaultUrlActions] = createResource(
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => { async () => {
try { try {
const key = await platform.getDefaultServer?.() const url = await platform.getDefaultServerUrl?.()
if (!key) return null if (!url) return null
return key return normalizeServerUrl(url) ?? null
} catch (err) { } catch (err) {
showRequestError(language, err) showRequestError(language, err)
return null return null
@@ -60,22 +56,20 @@ function useDefaultServer() {
{ initialValue: null }, { initialValue: null },
) )
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (key: ServerConnection.Key | null) => { const setDefault = async (url: string | null) => {
try { try {
await platform.setDefaultServer?.(key) await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(key) defaultUrlActions.mutate(url)
} catch (err) { } catch (err) {
showRequestError(language, err) showRequestError(language, err)
} }
} }
return { defaultKey, canDefault, setDefault } return { defaultUrl, canDefault, setDefault }
} }
function useServerPreview() { function useServerPreview(fetcher: typeof fetch) {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => { const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return false if (!normalized) return false
@@ -98,7 +92,7 @@ function useServerPreview() {
const http: ServerConnection.HttpBase = { url: normalized } const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username if (username) http.username = username
if (password) http.password = password if (password) http.password = password
const result = await checkServerHealth(http) const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -176,15 +170,15 @@ export function DialogSelectServer() {
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer() const fetcher = platform.fetch ?? globalThis.fetch
const { previewStatus } = useServerPreview() const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const checkServerHealth = useCheckServerHealth() const { previewStatus } = useServerPreview(fetcher)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>, status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
name: "", name: "",
username: DEFAULT_USERNAME, username: "",
password: "", password: "",
adding: false, adding: false,
error: "", error: "",
@@ -207,7 +201,7 @@ export function DialogSelectServer() {
setStore("addServer", { setStore("addServer", {
url: "", url: "",
name: "", name: "",
username: DEFAULT_USERNAME, username: "",
password: "", password: "",
adding: false, adding: false,
error: "", error: "",
@@ -270,7 +264,7 @@ export function DialogSelectServer() {
const results: Record<ServerConnection.Key, ServerHealth> = {} const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (conn) => { items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -368,9 +362,9 @@ export function DialogSelectServer() {
http: { url: normalized }, http: { url: normalized },
} }
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username const result = await checkServerHealth(conn.http, fetcher)
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") }) setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -410,7 +404,7 @@ export function DialogSelectServer() {
displayName: name, displayName: name,
http: { url: normalized, username, password }, http: { url: normalized, username, password },
} }
const result = await checkServerHealth(conn.http) const result = await checkServerHealth(conn.http, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") }) setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -447,7 +441,7 @@ export function DialogSelectServer() {
showForm: true, showForm: true,
url: "", url: "",
name: "", name: "",
username: DEFAULT_USERNAME, username: "",
password: "", password: "",
error: "", error: "",
status: undefined, status: undefined,
@@ -500,8 +494,8 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(url: ServerConnection.Key) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServer?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServer?.(null) platform.setDefaultServerUrl?.(null)
} }
} }
@@ -557,7 +551,7 @@ export function DialogSelectServer() {
status={store.status[key]} status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={defaultKey() === ServerConnection.key(i)}> <Show when={defaultUrl() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -590,14 +584,14 @@ export function DialogSelectServer() {
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}> <Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(key)}> <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={canDefault() && defaultKey() === key}> <Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}> <DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
+65 -57
View File
@@ -17,6 +17,7 @@ import {
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -36,8 +37,6 @@ import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { import {
@@ -103,13 +102,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const comments = useComments() const comments = useComments()
const params = useParams()
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
const command = useCommand() const command = useCommand()
const permission = usePermission() const permission = usePermission()
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
@@ -155,11 +154,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
requestAnimationFrame(scrollCursorIntoView) requestAnimationFrame(scrollCursorIntoView)
} }
const activeFileTab = createSessionTabs({ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
tabs, const tabs = createMemo(() => layout.tabs(sessionKey))
pathFromTab: files.pathFromTab, const view = createMemo(() => layout.view(sessionKey))
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const commentInReview = (path: string) => { const commentInReview = (path: string) => {
const sessionID = params.id const sessionID = params.id
@@ -212,7 +209,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const recent = createMemo(() => { const recent = createMemo(() => {
const all = tabs().all() const all = tabs().all()
const active = activeFileTab() const active = tabs().active()
const order = active ? [active, ...all.filter((x) => x !== active)] : all const order = active ? [active, ...all.filter((x) => x !== active)] : all
const seen = new Set<string>() const seen = new Set<string>()
const paths: string[] = [] const paths: string[] = []
@@ -258,15 +255,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
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 motion = (value: number) => ({
opacity: value,
transform: `scale(${0.95 + value * 0.05})`,
filter: `blur(${(1 - value) * 2}px)`,
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
})
const buttons = createMemo(() => motion(buttonsSpring()))
const shell = createMemo(() => motion(1 - buttonsSpring()))
const control = createMemo(() => ({ height: "28px", ...buttons() }))
const commentCount = createMemo(() => { const commentCount = createMemo(() => {
if (store.mode === "shell") return 0 if (store.mode === "shell") return 0
@@ -502,18 +490,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setComposing(false) setComposing(false)
} }
const handleCompositionStart = () => {
setComposing(true)
}
const handleCompositionEnd = () => {
setComposing(false)
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
})
}
const agentList = createMemo(() => const agentList = createMemo(() =>
sync.data.agent sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -704,27 +680,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const reconcile = (input: Prompt) => {
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(input)
return
}
const dom = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
renderEditorWithCursor(input)
}
createEffect( createEffect(
on( on(
() => prompt.current(), () => prompt.current(),
(parts) => { (currentParts) => {
if (composing()) return const inputParts = currentParts.filter((part) => part.type !== "image")
reconcile(parts.filter((part) => part.type !== "image"))
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
renderEditorWithCursor(inputParts)
}, },
), ),
) )
@@ -1235,8 +1208,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
spellcheck={store.mode === "normal"} spellcheck={store.mode === "normal"}
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={() => setComposing(true)}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
@@ -1287,7 +1260,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
variant="ghost" variant="ghost"
class="size-8 p-0" class="size-8 p-0"
style={buttons()} style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
onClick={pick} onClick={pick}
disabled={store.mode !== "normal"} disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1325,7 +1302,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="size-8" class="size-8"
style={buttons()} style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>
@@ -1383,7 +1364,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0" class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{ style={{
padding: "0 4px 0 8px", padding: "0 4px 0 8px",
...shell(), opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}} }}
> >
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
@@ -1403,7 +1387,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize max-w-[160px]" class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular" valueClass="truncate text-13-regular"
triggerStyle={control()} 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",
}}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
@@ -1421,7 +1411,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[320px] text-13-regular group" class="min-w-0 max-w-[320px] text-13-regular group"
style={control()} 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",
}}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
@@ -1450,7 +1446,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
size: "normal", size: "normal",
style: control(), 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",
},
class: "min-w-0 max-w-[320px] text-13-regular group", class: "min-w-0 max-w-[320px] text-13-regular group",
}} }}
> >
@@ -1482,7 +1484,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]" class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular" valueClass="truncate text-13-regular"
triggerStyle={control()} 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",
}}
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </TooltipKeybind>
@@ -2,14 +2,12 @@ import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
@@ -29,17 +27,14 @@ function openSessionContext(args: {
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const sync = useSync()
const file = useFile() const params = useParams()
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const tabState = createSessionTabs({ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
tabs, const tabs = createMemo(() => layout.tabs(sessionKey))
pathFromTab: file.pathFromTab, const view = createMemo(() => layout.view(sessionKey))
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo( const usd = createMemo(
@@ -59,7 +54,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
if (tabState.activeTab() === "context") { if (tabs().active() === "context") {
tabs().close("context") tabs().close("context")
return return
} }
@@ -1,6 +1,8 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
@@ -12,7 +14,6 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@@ -90,10 +91,13 @@ const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() { export function SessionContextTab() {
const params = useParams()
const sync = useSync() const sync = useSync()
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const { params, view } = useSessionLayout()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo( const messages = createMemo(
@@ -10,6 +10,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
@@ -20,9 +21,6 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
@@ -225,13 +223,12 @@ function useSessionShare(args: {
export function SessionHeader() { export function SessionHeader() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const layout = useLayout() const layout = useLayout()
const params = useParams()
const command = useCommand() const command = useCommand()
const server = useServer() const server = useServer()
const sync = useSync() const sync = useSync()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => { const project = createMemo(() => {
@@ -249,6 +246,8 @@ export function SessionHeader() {
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !!params.id) const showShare = createMemo(() => shareEnabled() && !!params.id)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform)) const os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
@@ -280,7 +279,10 @@ export function SessionHeader() {
Promise.resolve(platform.checkAppExists?.(app.openWith)) Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value)) .then((value) => Boolean(value))
.catch(() => false) .catch(() => false)
.then((ok) => [app.id, ok] as const), .then((ok) => {
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
return [app.id, ok] as const
}),
), ),
).then((entries) => { ).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>) setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
@@ -294,16 +296,6 @@ export function SessionHeader() {
] as const ] as const
}) })
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false }) const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({ const [openRequest, setOpenRequest] = createStore({
@@ -625,39 +617,39 @@ export function SessionHeader() {
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0"> <div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<TooltipKeybind <TooltipKeybind
title={language.t("command.review.toggle")} title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")} keybind={command.keybind("review.toggle")}
@@ -13,6 +13,7 @@ const ROOT_CLASS = "size-full flex flex-col"
interface NewSessionViewProps { interface NewSessionViewProps {
worktree: string worktree: string
onWorktreeChange: (value: string) => void
} }
export function NewSessionView(props: NewSessionViewProps) { export function NewSessionView(props: NewSessionViewProps) {
@@ -8,7 +8,6 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { useTerminal, type LocalPTY } from "@/context/terminal" import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element { export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
const terminal = useTerminal() const terminal = useTerminal()
@@ -54,8 +53,21 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const focus = () => { const focus = () => {
if (store.editing) return if (store.editing) return
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id) if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
} }
const edit = (e?: Event) => { const edit = (e?: Event) => {
@@ -0,0 +1,16 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
</div>
</div>
)
}
@@ -0,0 +1,16 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
</div>
</div>
)
}
@@ -0,0 +1,16 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
</div>
</div>
)
}
@@ -0,0 +1,230 @@
import { Select } from "@opencode-ai/ui/select"
import { showToast } from "@opencode-ai/ui/toast"
import { Component, For, createMemo, type JSX } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
type PermissionAction = "allow" | "ask" | "deny"
type PermissionObject = Record<string, PermissionAction>
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
type PermissionMap = Record<string, PermissionValue>
type PermissionItem = {
id: string
title: string
description: string
}
const ACTIONS = [
{ value: "allow", label: "settings.permissions.action.allow" },
{ value: "ask", label: "settings.permissions.action.ask" },
{ value: "deny", label: "settings.permissions.action.deny" },
] as const
const ITEMS = [
{
id: "read",
title: "settings.permissions.tool.read.title",
description: "settings.permissions.tool.read.description",
},
{
id: "edit",
title: "settings.permissions.tool.edit.title",
description: "settings.permissions.tool.edit.description",
},
{
id: "glob",
title: "settings.permissions.tool.glob.title",
description: "settings.permissions.tool.glob.description",
},
{
id: "grep",
title: "settings.permissions.tool.grep.title",
description: "settings.permissions.tool.grep.description",
},
{
id: "list",
title: "settings.permissions.tool.list.title",
description: "settings.permissions.tool.list.description",
},
{
id: "bash",
title: "settings.permissions.tool.bash.title",
description: "settings.permissions.tool.bash.description",
},
{
id: "task",
title: "settings.permissions.tool.task.title",
description: "settings.permissions.tool.task.description",
},
{
id: "skill",
title: "settings.permissions.tool.skill.title",
description: "settings.permissions.tool.skill.description",
},
{
id: "lsp",
title: "settings.permissions.tool.lsp.title",
description: "settings.permissions.tool.lsp.description",
},
{
id: "todoread",
title: "settings.permissions.tool.todoread.title",
description: "settings.permissions.tool.todoread.description",
},
{
id: "todowrite",
title: "settings.permissions.tool.todowrite.title",
description: "settings.permissions.tool.todowrite.description",
},
{
id: "webfetch",
title: "settings.permissions.tool.webfetch.title",
description: "settings.permissions.tool.webfetch.description",
},
{
id: "websearch",
title: "settings.permissions.tool.websearch.title",
description: "settings.permissions.tool.websearch.description",
},
{
id: "codesearch",
title: "settings.permissions.tool.codesearch.title",
description: "settings.permissions.tool.codesearch.description",
},
{
id: "external_directory",
title: "settings.permissions.tool.external_directory.title",
description: "settings.permissions.tool.external_directory.description",
},
{
id: "doom_loop",
title: "settings.permissions.tool.doom_loop.title",
description: "settings.permissions.tool.doom_loop.description",
},
] as const
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
function toMap(value: unknown): PermissionMap {
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
const action = getAction(value)
if (action) return { "*": action }
return {}
}
function getAction(value: unknown): PermissionAction | undefined {
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
return
}
function getRuleDefault(value: unknown): PermissionAction | undefined {
const action = getAction(value)
if (action) return action
if (!value || typeof value !== "object" || Array.isArray(value)) return
return getAction((value as Record<string, unknown>)["*"])
}
export const SettingsPermissions: Component = () => {
const globalSync = useGlobalSync()
const language = useLanguage()
const actions = createMemo(
(): Array<{ value: PermissionAction; label: string }> =>
ACTIONS.map((action) => ({
value: action.value,
label: language.t(action.label),
})),
)
const permission = createMemo(() => {
return toMap(globalSync.data.config.permission)
})
const actionFor = (id: string): PermissionAction => {
const value = permission()[id]
const direct = getRuleDefault(value)
if (direct) return direct
const wildcard = getRuleDefault(permission()["*"])
if (wildcard) return wildcard
return "allow"
}
const setPermission = async (id: string, action: PermissionAction) => {
const before = globalSync.data.config.permission
const map = toMap(before)
const existing = map[id]
const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
const rollback = (err: unknown) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
}
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
</div>
</div>
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
<div class="flex flex-col gap-2">
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
<div class="border border-border-weak-base rounded-lg overflow-hidden">
<For each={ITEMS}>
{(item) => (
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
<Select
options={actions()}
current={actions().find((o) => o.value === actionFor(item.id))}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && setPermission(item.id, option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
)}
</For>
</div>
</div>
</div>
</div>
)
}
interface SettingsRowProps {
title: string
description: string
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}
@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server" import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000 const pollMs = 10_000
@@ -53,8 +53,7 @@ const listServersByHealth = (
}) })
} }
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => { const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
@@ -65,7 +64,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const results: Record<string, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
list.map(async (conn) => { list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
if (dead) return if (dead) return
@@ -169,6 +168,7 @@ export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.current const current = server.current
const list = server.list const list = server.list
@@ -176,10 +176,10 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
+8 -18
View File
@@ -10,7 +10,6 @@ import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings" import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { terminalAttr, terminalProbe } from "@/testing/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
@@ -18,7 +17,6 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
autoFocus?: boolean
onSubmit?: () => void onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void onConnect?: () => void
@@ -159,9 +157,8 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
const id = local.pty.id const id = local.pty.id
const probe = terminalProbe(id)
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
restore && restore &&
@@ -328,9 +325,6 @@ export const Terminal = (props: TerminalProps) => {
} }
onMount(() => { onMount(() => {
probe.init()
cleanups.push(() => probe.drop())
const run = async () => { const run = async () => {
const loaded = await loadGhostty() const loaded = await loadGhostty()
if (disposed) return if (disposed) return
@@ -358,13 +352,7 @@ export const Terminal = (props: TerminalProps) => {
} }
ghostty = g ghostty = g
term = t term = t
output = terminalWriter((data, done) => output = terminalWriter((data, done) => t.write(data, done))
t.write(data, () => {
probe.render(data)
probe.settle()
done?.()
}),
)
t.attachCustomKeyEventHandler((event) => { t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
@@ -398,7 +386,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick, handleLinkClick,
}) })
if (local.autoFocus !== false) focusTerminal() focusTerminal()
if (typeof document !== "undefined" && document.fonts) { if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit) document.fonts.ready.then(scheduleFit)
@@ -452,6 +440,10 @@ export const Terminal = (props: TerminalProps) => {
startResize() startResize()
} }
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const once = { value: false } const once = { value: false }
let closing = false let closing = false
@@ -459,7 +451,7 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory) url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0)) url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode" url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? "" url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url) const socket = new WebSocket(url)
@@ -467,7 +459,6 @@ export const Terminal = (props: TerminalProps) => {
ws = socket ws = socket
const handleOpen = () => { const handleOpen = () => {
probe.connect()
local.onConnect?.() local.onConnect?.()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
} }
@@ -568,7 +559,6 @@ export const Terminal = (props: TerminalProps) => {
<div <div
ref={container} ref={container}
data-component="terminal" data-component="terminal"
{...{ [terminalAttr]: id }}
data-prevent-autofocus data-prevent-autofocus
tabIndex={-1} tabIndex={-1}
style={{ "background-color": terminalColors().background }} style={{ "background-color": terminalColors().background }}
+2 -2
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -282,7 +282,7 @@ export function Titlebar() {
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
{!tauriApi() && <div class="w-36 shrink-0" />} <div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />
</Show> </Show>
</div> </div>
+6 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import {
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" canDisposeDirectory,
estimateRootSessionTotal,
loadRootSessionsWithFallback,
pickDirectoriesToEvict,
} from "./global-sync"
describe("pickDirectoriesToEvict", () => { describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => { test("keeps pinned stores and evicts idle stores", () => {
+3 -2
View File
@@ -29,7 +29,6 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
@@ -162,7 +161,6 @@ function createGlobalSync() {
queue.clear(directory) queue.clear(directory)
sessionMeta.delete(directory) sessionMeta.delete(directory)
sdkCache.delete(directory) sdkCache.delete(directory)
clearSessionPrefetchDirectory(directory)
}, },
}) })
@@ -404,3 +402,6 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context return context
} }
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
@@ -1,63 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
clearSessionPrefetch,
clearSessionPrefetchDirectory,
getSessionPrefetch,
runSessionPrefetch,
setSessionPrefetch,
} from "./session-prefetch"
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch("/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
sessionID: "ses_1",
limit: 200,
complete: false,
at: 123,
})
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 })
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"])
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch("/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
sessionID: "ses_2",
task: async () => {
calls += 1
return { limit: 100, complete: true, at: 456 }
},
})
const [a, b] = await Promise.all([run(), run()])
expect(calls).toBe(1)
expect(a).toEqual({ limit: 100, complete: true, at: 456 })
expect(b).toEqual({ limit: 100, complete: true, at: 456 })
})
test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 })
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 })
clearSessionPrefetchDirectory("/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 })
})
})
@@ -1,85 +0,0 @@
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
export const SESSION_PREFETCH_TTL = 15_000
type Meta = {
limit: number
complete: boolean
at: number
}
const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(directory: string, sessionID: string) {
return cache.get(key(directory, sessionID))
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(key(directory, sessionID))
}
export function clearSessionPrefetchInflight() {
inflight.clear()
}
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
return version(key(directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
const value = version(id)
const promise = input.task(value).finally(() => {
if (inflight.get(id) === promise) inflight.delete(id)
})
inflight.set(id, promise)
return promise
}
export function setSessionPrefetch(input: {
directory: string
sessionID: string
limit: number
complete: boolean
at?: number
}) {
cache.set(key(input.directory, input.sessionID), {
limit: input.limit,
complete: input.complete,
at: input.at ?? Date.now(),
})
}
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(directory: string) {
const prefix = `${directory}\n`
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
+7 -8
View File
@@ -1,4 +1,4 @@
import { createEffect, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -146,10 +146,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const settings = useSettings() const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined })) const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [range, setRange] = createStore({ const [from, setFrom] = createSignal<string | undefined>(undefined)
from: undefined as string | undefined, const [to, setTo] = createSignal<string | undefined>(undefined)
to: undefined as string | undefined,
})
const state = { started: false } const state = { started: false }
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
@@ -216,14 +214,15 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
if (previous === platform.version) return if (previous === platform.version) return
setRange({ from: previous, to: platform.version }) setFrom(previous)
setTo(platform.version)
start(previous) start(previous)
}) })
return { return {
ready, ready,
from: () => range.from, from,
to: () => range.to, to,
get last() { get last() {
return store.version return store.version
}, },
+4 -51
View File
@@ -793,67 +793,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}, },
review: { review: {
open: createMemo(() => s().reviewOpen ?? []), open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) { setOpen(open: string[]) {
const session = key() const session = key()
const next = Array.from(new Set(open))
const current = store.sessionView[session] const current = store.sessionView[session]
if (!current) { if (!current) {
setStore("sessionView", session, { setStore("sessionView", session, {
scroll: {}, scroll: {},
reviewOpen: next, reviewOpen: open,
}) })
return return
} }
if (same(current.reviewOpen, next)) return if (same(current.reviewOpen, open)) return
setStore("sessionView", session, "reviewOpen", next) setStore("sessionView", session, "reviewOpen", open)
},
openPath(path: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
reviewOpen: [path],
})
return
}
if (!current.reviewOpen) {
setStore("sessionView", session, "reviewOpen", [path])
return
}
if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
}, },
}, },
} }
@@ -0,0 +1,66 @@
type NotificationIndexItem = {
directory?: string
session?: string
viewed: boolean
type: string
}
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
const sessionAll = new Map<string, T[]>()
const sessionUnseen = new Map<string, T[]>()
const sessionUnseenCount = new Map<string, number>()
const sessionUnseenHasError = new Map<string, boolean>()
const projectAll = new Map<string, T[]>()
const projectUnseen = new Map<string, T[]>()
const projectUnseenCount = new Map<string, number>()
const projectUnseenHasError = new Map<string, boolean>()
for (const notification of list) {
const session = notification.session
if (session) {
const all = sessionAll.get(session)
if (all) all.push(notification)
else sessionAll.set(session, [notification])
if (!notification.viewed) {
const unseen = sessionUnseen.get(session)
if (unseen) unseen.push(notification)
else sessionUnseen.set(session, [notification])
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
if (notification.type === "error") sessionUnseenHasError.set(session, true)
}
}
const directory = notification.directory
if (directory) {
const all = projectAll.get(directory)
if (all) all.push(notification)
else projectAll.set(directory, [notification])
if (!notification.viewed) {
const unseen = projectUnseen.get(directory)
if (unseen) unseen.push(notification)
else projectUnseen.set(directory, [notification])
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
if (notification.type === "error") projectUnseenHasError.set(directory, true)
}
}
}
return {
session: {
all: sessionAll,
unseen: sessionUnseen,
unseenCount: sessionUnseenCount,
unseenHasError: sessionUnseenHasError,
},
project: {
all: projectAll,
unseen: projectUnseen,
unseenCount: projectUnseenCount,
unseenHasError: projectUnseenHasError,
},
}
}
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { buildNotificationIndex } from "./notification-index"
type Notification = {
type: "turn-complete" | "error"
session: string
directory: string
viewed: boolean
time: number
}
const turn = (session: string, directory: string, viewed = false): Notification => ({
type: "turn-complete",
session,
directory,
viewed,
time: 1,
})
const error = (session: string, directory: string, viewed = false): Notification => ({
type: "error",
session,
directory,
viewed,
time: 1,
})
describe("buildNotificationIndex", () => {
test("builds unseen counts and unseen error flags", () => {
const list = [
turn("s1", "d1", false),
error("s1", "d1", false),
turn("s1", "d1", true),
turn("s2", "d1", false),
error("s3", "d2", true),
]
const index = buildNotificationIndex(list)
expect(index.session.all.get("s1")?.length).toBe(3)
expect(index.session.unseen.get("s1")?.length).toBe(2)
expect(index.session.unseenCount.get("s1")).toBe(2)
expect(index.session.unseenHasError.get("s1")).toBe(true)
expect(index.session.unseenCount.get("s2")).toBe(1)
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
expect(index.project.unseenCount.get("d1")).toBe(3)
expect(index.project.unseenHasError.get("d1")).toBe(true)
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
})
test("updates selectors after viewed transitions", () => {
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
const before = buildNotificationIndex(list)
const after = buildNotificationIndex(next)
expect(before.session.unseenCount.get("s1")).toBe(2)
expect(before.session.unseenHasError.get("s1")).toBe(true)
expect(before.project.unseenCount.get("d1")).toBe(3)
expect(before.project.unseenHasError.get("d1")).toBe(true)
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
expect(after.project.unseenCount.get("d1")).toBe(1)
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
})
})
+2 -3
View File
@@ -1,7 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -59,10 +58,10 @@ export type Platform = {
fetch?: typeof fetch fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */ /** Get the configured default server URL (platform-specific) */
getDefaultServer?(): Promise<ServerConnection.Key | null> getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (platform-specific) */ /** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */ /** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean> getWslEnabled?(): Promise<boolean>
+5 -3
View File
@@ -1,8 +1,9 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health" import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -95,7 +96,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => { init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const checkServerHealth = useCheckServerHealth() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
@@ -196,7 +197,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => { createEffect(() => {
const current_ = current() const current_ = current()
+34 -75
View File
@@ -3,12 +3,6 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -166,7 +160,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined) globalSync.todo.set(sessionID, undefined)
} }
@@ -224,12 +217,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
setMeta("limit", key, input.limit) setMeta("limit", key, input.limit)
setMeta("complete", key, next.complete) setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: input.limit,
complete: next.complete,
})
}) })
}) })
.finally(() => { .finally(() => {
@@ -293,82 +280,54 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
parts: input.parts, parts: input.parts,
}) })
}, },
async sync(sessionID: string, opts?: { force?: boolean }) { async sync(sessionID: string) {
const directory = sdk.directory const directory = sdk.directory
const client = sdk.client const client = sdk.client
const [store, setStore] = globalSync.child(directory) const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID) if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => { const limit = meta.limit[key] ?? messagePageSize
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found const sessionReq = hasSession
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined ? Promise.resolve()
if (cached && hasSession && !opts?.force) return : retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const limit = meta.limit[key] ?? messagePageSize const messagesReq = loadMessages({
const sessionReq = directory,
hasSession && !opts?.force client,
? Promise.resolve() setStore,
: retry(() => client.session.get({ sessionID })).then((session) => { sessionID,
if (!tracked(directory, sessionID)) return limit,
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
}) })
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
}, },
async diff(sessionID: string, opts?: { force?: boolean }) { async diff(sessionID: string) {
const directory = sdk.directory const directory = sdk.directory
const client = sdk.client const client = sdk.client
const [store, setStore] = globalSync.child(directory) const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () => return runInflight(inflightDiff, key, () =>
@@ -378,7 +337,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}), }),
) )
}, },
async todo(sessionID: string, opts?: { force?: boolean }) { async todo(sessionID: string) {
const directory = sdk.directory const directory = sdk.directory
const client = sdk.client const client = sdk.client
const [store, setStore] = globalSync.child(directory) const [store, setStore] = globalSync.child(directory)
@@ -389,7 +348,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (cached === undefined) { if (cached === undefined) {
globalSync.todo.set(sessionID, existing) globalSync.todo.set(sessionID, existing)
} }
if (!opts?.force) return return
} }
if (cached !== undefined) { if (cached !== undefined) {
-43
View File
@@ -2,7 +2,6 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
beforeAll(async () => { beforeAll(async () => {
mock.module("@solidjs/router", () => ({ mock.module("@solidjs/router", () => ({
@@ -18,7 +17,6 @@ beforeAll(async () => {
const mod = await import("./terminal") const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
}) })
describe("getWorkspaceTerminalCacheKey", () => { describe("getWorkspaceTerminalCacheKey", () => {
@@ -39,44 +37,3 @@ describe("getLegacyTerminalStorageKeys", () => {
]) ])
}) })
}) })
describe("migrateTerminalState", () => {
test("drops invalid terminals and restores a valid active terminal", () => {
expect(
migrateTerminalState({
active: "missing",
all: [
null,
{ id: "one", title: "Terminal 2" },
{ id: "one", title: "duplicate", titleNumber: 9 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
{ title: "no-id" },
],
}),
).toEqual({
active: "one",
all: [
{ id: "one", title: "Terminal 2", titleNumber: 2 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
],
})
})
test("keeps a valid active id", () => {
expect(
migrateTerminalState({
active: "two",
all: [
{ id: "one", title: "Terminal 1" },
{ id: "two", title: "shell", titleNumber: 7 },
],
}),
).toEqual({
active: "two",
all: [
{ id: "one", title: "Terminal 1", titleNumber: 1 },
{ id: "two", title: "shell", titleNumber: 7 },
],
})
})
})
+29 -69
View File
@@ -20,71 +20,6 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20 const MAX_TERMINAL_SESSIONS = 20
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function text(value: unknown) {
return typeof value === "string" ? value : undefined
}
function num(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function numberFromTitle(title: string) {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
function pty(value: unknown): LocalPTY | undefined {
if (!record(value)) return
const id = text(value.id)
if (!id) return
const title = text(value.title) ?? ""
const number = num(value.titleNumber)
const rows = num(value.rows)
const cols = num(value.cols)
const buffer = text(value.buffer)
const scrollY = num(value.scrollY)
const cursor = num(value.cursor)
return {
id,
title,
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
...(rows !== undefined ? { rows } : {}),
...(cols !== undefined ? { cols } : {}),
...(buffer !== undefined ? { buffer } : {}),
...(scrollY !== undefined ? { scrollY } : {}),
...(cursor !== undefined ? { cursor } : {}),
}
}
export function migrateTerminalState(value: unknown) {
if (!record(value)) return value
const seen = new Set<string>()
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
const next = pty(item)
if (!next || seen.has(next.id)) return []
seen.add(next.id)
return [next]
})
const active = text(value.active)
return {
active: active && seen.has(active) ? active : all[0]?.id,
all,
}
}
export function getWorkspaceTerminalCacheKey(dir: string) { export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}` return `${dir}:${WORKSPACE_KEY}`
} }
@@ -136,11 +71,16 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) { function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const numberFromTitle = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ Persist.workspace(dir, "terminal", legacy),
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{ createStore<{
active?: string active?: string
all: LocalPTY[] all: LocalPTY[]
@@ -188,6 +128,26 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}) })
onCleanup(unsub) onCleanup(unsub)
const meta = { migrated: false }
createEffect(() => {
if (!ready()) return
if (meta.migrated) return
meta.migrated = true
setStore("all", (all) => {
const next = all.map((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return pty
const parsed = numberFromTitle(pty.title)
if (parsed === undefined) return pty
return { ...pty, titleNumber: parsed }
})
if (next.every((pty, index) => pty === all[index])) return all
return next
})
})
return { return {
ready, ready,
all: createMemo(() => store.all), all: createMemo(() => store.all),
+13 -20
View File
@@ -98,19 +98,6 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError()) throw new Error(getRootNotFoundError())
} }
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
const getDefaultUrl = () => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
return getCurrentUrl()
}
const platform: Platform = { const platform: Platform = {
platform: "web", platform: "web",
version: pkg.version, version: pkg.version,
@@ -119,20 +106,26 @@ const platform: Platform = {
forward, forward,
restart, restart,
notify, notify,
getDefaultServer: async () => { getDefaultServerUrl: async () => readDefaultServerUrl(),
const stored = readDefaultServerUrl() setDefaultServerUrl: writeDefaultServerUrl,
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
} }
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} /> <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),
+16 -18
View File
@@ -18,27 +18,25 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders() { export function useProviders() {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const params = useParams() const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "") const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
const providers = () => { const providers = createMemo(() => {
if (dir()) { if (currentDirectory()) {
const [projectStore] = globalSync.child(dir()) const [projectStore] = globalSync.child(currentDirectory())
return projectStore.provider return projectStore.provider
} }
return globalSync.data.provider return globalSync.data.provider
} })
const connectedIDs = createMemo(() => new Set(providers().connected))
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
const paid = createMemo(() =>
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
)
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
return { return {
all: () => providers().all, all: createMemo(() => providers().all),
default: () => providers().default, default: createMemo(() => providers().default),
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)), popular,
connected: () => { connected,
const connected = new Set(providers().connected) paid,
return providers().all.filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
return providers().all.filter(
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
)
},
} }
} }
-5
View File
@@ -530,11 +530,6 @@ export const dict = {
"session.todo.title": "Todos", "session.todo.title": "Todos",
"session.todo.collapse": "Collapse", "session.todo.collapse": "Collapse",
"session.todo.expand": "Expand", "session.todo.expand": "Expand",
"session.revertDock.summary.one": "{{count}} rolled back message",
"session.revertDock.summary.other": "{{count}} rolled back messages",
"session.revertDock.collapse": "Collapse rolled back messages",
"session.revertDock.expand": "Expand rolled back messages",
"session.revertDock.restore": "Restore message",
"session.new.title": "Build anything", "session.new.title": "Build anything",
"session.new.worktree.main": "Main branch", "session.new.worktree.main": "Main branch",
+268 -299
View File
@@ -35,15 +35,6 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals } from "@/context/terminal" import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
clearSessionPrefetch,
getSessionPrefetch,
isSessionPrefetchCurrent,
runSessionPrefetch,
SESSION_PREFETCH_TTL,
setSessionPrefetch,
} from "@/context/global-sync/session-prefetch"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
@@ -60,10 +51,9 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings" import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language" import { useLanguage, type Locale } from "@/context/language"
@@ -71,6 +61,7 @@ import {
displayName, displayName,
effectiveWorkspaceOrder, effectiveWorkspaceOrder,
errorMessage, errorMessage,
getDraggableId,
latestRootSession, latestRootSession,
sortedRootSessions, sortedRootSessions,
workspaceKey, workspaceKey,
@@ -88,6 +79,7 @@ import {
WorkspaceDragOverlay, WorkspaceDragOverlay,
type WorkspaceSidebarContext, type WorkspaceSidebarContext,
} from "./layout/sidebar-workspace" } from "./layout/sidebar-workspace"
import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell" import { SidebarContent } from "./layout/sidebar-shell"
@@ -671,9 +663,8 @@ export default function Layout(props: ParentProps) {
} }
const prefetchChunk = 200 const prefetchChunk = 200
const prefetchConcurrency = 2 const prefetchConcurrency = 1
const prefetchPendingLimit = 10 const prefetchPendingLimit = 6
const span = 4
const prefetchToken = { value: 0 } const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>() const prefetchQueues = new Map<string, PrefetchQueue>()
@@ -698,30 +689,14 @@ export default function Layout(props: ParentProps) {
}) })
} }
createEffect(() => {
const active = new Set(visibleSessionDirs())
for (const directory of [...prefetchedByDir.keys()]) {
if (active.has(directory)) continue
prefetchedByDir.delete(directory)
}
})
createEffect(() => { createEffect(() => {
params.dir params.dir
globalSDK.url globalSDK.url
prefetchToken.value += 1 prefetchToken.value += 1
clearSessionPrefetchInflight() for (const q of prefetchQueues.values()) {
prefetchQueues.clear()
})
createEffect(() => {
const visible = new Set(visibleSessionDirs())
for (const [directory, q] of prefetchQueues) {
if (visible.has(directory)) continue
q.pending.length = 0 q.pending.length = 0
q.pendingSet.clear() q.pendingSet.clear()
if (q.running === 0) prefetchQueues.delete(directory)
} }
}) })
@@ -757,67 +732,36 @@ export default function Layout(props: ParentProps) {
async function prefetchMessages(directory: string, sessionID: string, token: number) { async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = globalSync.child(directory, { bootstrap: false }) const [store, setStore] = globalSync.child(directory, { bootstrap: false })
return runSessionPrefetch({ return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
directory, .then((messages) => {
sessionID, if (prefetchToken.value !== token) return
task: (rev) => if (!lruFor(directory).has(sessionID)) return
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
const sorted = mergeByID([], next) const sorted = mergeByID([], next)
const stale = markPrefetched(directory, sessionID)
const meta = {
limit: prefetchChunk,
complete: sorted.length < prefetchChunk,
at: Date.now(),
}
if (stale.length > 0) { const current = store.message[sessionID] ?? []
clearSessionPrefetch(directory, stale) const merged = mergeByID(
for (const id of stale) { current.filter((item): item is Message => !!item?.id),
globalSync.todo.set(id, undefined) sorted,
} )
}
const current = store.message[sessionID] ?? [] batch(() => {
const merged = mergeByID( setStore("message", sessionID, reconcile(merged, { key: "id" }))
current.filter((item): item is Message => !!item?.id),
sorted, for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
const mergedParts = mergeByID(
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
) )
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
}
batch(() => { })
if (stale.length > 0) { })
setStore( .catch(() => undefined)
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
setStore("message", sessionID, reconcile(merged, { key: "id" }))
setSessionPrefetch({ directory, sessionID, ...meta })
for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
const mergedParts = mergeByID(
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
)
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
}
})
return meta
})
.catch(() => undefined),
})
} }
const pumpPrefetch = (directory: string) => { const pumpPrefetch = (directory: string) => {
@@ -845,29 +789,28 @@ export default function Layout(props: ParentProps) {
if (!directory) return if (!directory) return
const [store] = globalSync.child(directory, { bootstrap: false }) const [store] = globalSync.child(directory, { bootstrap: false })
const cached = untrack(() => { const cached = untrack(() => store.message[session.id] !== undefined)
if (store.message[session.id] === undefined) return false
const info = getSessionPrefetch(directory, session.id)
if (!info) return false
return Date.now() - info.at < SESSION_PREFETCH_TTL
})
if (cached) return if (cached) return
const q = queueFor(directory) const q = queueFor(directory)
if (q.inflight.has(session.id)) return if (q.inflight.has(session.id)) return
if (q.pendingSet.has(session.id)) { if (q.pendingSet.has(session.id)) return
if (priority !== "high") return
const index = q.pending.indexOf(session.id)
if (index > 0) {
q.pending.splice(index, 1)
q.pending.unshift(session.id)
}
return
}
const lru = lruFor(directory) const lru = lruFor(directory)
const known = lru.has(session.id) const known = lru.has(session.id)
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
const stale = markPrefetched(directory, session.id)
if (stale.length > 0) {
const [, setStore] = globalSync.child(directory, { bootstrap: false })
for (const id of stale) {
globalSync.todo.set(id, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, stale)
}),
)
}
if (priority === "high") q.pending.unshift(session.id) if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id) if (priority !== "high") q.pending.push(session.id)
@@ -882,29 +825,27 @@ export default function Layout(props: ParentProps) {
pumpPrefetch(directory) pumpPrefetch(directory)
} }
const warm = (sessions: Session[], index: number) => {
for (let offset = 1; offset <= span; offset++) {
const next = sessions[index + offset]
if (next) prefetchSession(next, offset === 1 ? "high" : "low")
const prev = sessions[index - offset]
if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
}
}
createEffect(() => { createEffect(() => {
const sessions = currentSessions() const sessions = currentSessions()
if (sessions.length === 0) return const id = params.id
const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0 if (!id) {
if (index === -1) return const first = sessions[0]
if (first) prefetchSession(first)
if (!params.id) { const second = sessions[1]
const first = sessions[index] if (second) prefetchSession(second)
if (first) prefetchSession(first, "high") return
} }
warm(sessions, index) const index = sessions.findIndex((s) => s.id === id)
if (index === -1) return
const next = sessions[index + 1]
if (next) prefetchSession(next)
const prev = sessions[index - 1]
if (prev) prefetchSession(prev)
}) })
function navigateSessionByOffset(offset: number) { function navigateSessionByOffset(offset: number) {
@@ -923,8 +864,18 @@ export default function Layout(props: ParentProps) {
const session = sessions[targetIndex] const session = sessions[targetIndex]
if (!session) return if (!session) return
prefetchSession(session, "high") const next = sessions[(targetIndex + 1) % sessions.length]
warm(sessions, targetIndex) const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
navigateToSession(session) navigateToSession(session)
} }
@@ -946,7 +897,19 @@ export default function Layout(props: ParentProps) {
if (notification.session.unseenCount(session.id) === 0) continue if (notification.session.unseenCount(session.id) === 0) continue
prefetchSession(session, "high") prefetchSession(session, "high")
warm(sessions, index)
const next = sessions[(index + 1) % sessions.length]
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
navigateToSession(session) navigateToSession(session)
return return
@@ -1880,7 +1843,6 @@ export default function Layout(props: ParentProps) {
const workspaceSidebarCtx: WorkspaceSidebarContext = { const workspaceSidebarCtx: WorkspaceSidebarContext = {
currentDir, currentDir,
navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering, sidebarHovering,
nav: () => state.nav, nav: () => state.nav,
@@ -1897,7 +1859,7 @@ export default function Layout(props: ParentProps) {
setEditor, setEditor,
InlineEditor, InlineEditor,
isBusy, isBusy,
workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local, workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value), setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
showResetWorkspaceDialog: (root, directory) => showResetWorkspaceDialog: (root, directory) =>
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />), dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
@@ -1926,7 +1888,6 @@ export default function Layout(props: ParentProps) {
workspaceIds, workspaceIds,
workspaceLabel, workspaceLabel,
sessionProps: { sessionProps: {
navList: currentSessions,
sidebarExpanded, sidebarExpanded,
sidebarHovering, sidebarHovering,
nav: () => state.nav, nav: () => state.nav,
@@ -1942,7 +1903,6 @@ export default function Layout(props: ParentProps) {
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const projectName = createMemo(() => { const projectName = createMemo(() => {
const project = panelProps.project const project = panelProps.project
if (!project) return "" if (!project) return ""
@@ -2086,7 +2046,6 @@ export default function Layout(props: ParentProps) {
project={p()} project={p()}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
</div> </div>
</> </>
@@ -2122,7 +2081,6 @@ export default function Layout(props: ParentProps) {
project={p()} project={p()}
sortNow={sortNow} sortNow={sortNow}
mobile={panelProps.mobile} mobile={panelProps.mobile}
popover={popover()}
/> />
)} )}
</For> </For>
@@ -2176,183 +2134,194 @@ export default function Layout(props: ParentProps) {
) )
} }
const projects = () => layout.projects.list()
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
const sidebarContent = (mobile?: boolean) => (
<SidebarContent
mobile={mobile}
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={projects}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
openProjectLabel={language.t("command.project.open")}
openProjectKeybind={() => command.keybind("project.open")}
onOpenProject={chooseProject}
renderProjectOverlay={projectOverlay}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() =>
mobile ? (
<SidebarPanel project={currentProject()} mobile />
) : (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} merged />}
</Show>
)
}
/>
)
return ( return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <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 /> <Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex"> <div class="flex-1 min-h-0 relative overflow-x-hidden">
<div class="flex-1 min-h-0 relative"> <nav
<div class="size-full relative overflow-x-hidden"> aria-label={language.t("sidebar.nav.projectsAndSessions")}
<nav data-component="sidebar-nav-desktop"
aria-label={language.t("sidebar.nav.projectsAndSessions")} classList={{
data-component="sidebar-nav-desktop" "hidden xl:block": true,
classList={{ "absolute inset-y-0 left-0": true,
"hidden xl:block": true, "z-10": true,
"absolute inset-y-0 left-0": true, }}
"z-10": true, style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
}} ref={(el) => {
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} setState("nav", el)
ref={(el) => { }}
setState("nav", el) onMouseEnter={() => {
}} disarm()
onMouseEnter={() => { }}
disarm() onMouseLeave={() => {
}} aim.reset()
onMouseLeave={() => { if (!sidebarHovering()) return
aim.reset()
if (!sidebarHovering()) return
arm() arm()
}} }}
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">
<Show when={layout.sidebar.opened()}> <SidebarContent
<div onPointerDown={() => setSizing(true)}> opened={() => layout.sidebar.opened()}
<ResizeHandle aimMove={aim.move}
direction="horizontal" projects={() => layout.projects.list()}
size={layout.sidebar.width()} renderProject={(project) => (
min={244} <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} )}
collapseThreshold={244} handleDragStart={handleDragStart}
onResize={(w) => { handleDragEnd={handleDragEnd}
setSizing(true) handleDragOver={handleDragOver}
if (sizet !== undefined) clearTimeout(sizet) openProjectLabel={language.t("command.project.open")}
sizet = window.setTimeout(() => setSizing(false), 120) openProjectKeybind={() => command.keybind("project.open")}
layout.sidebar.resize(w) onOpenProject={chooseProject}
}} renderProjectOverlay={() => (
onCollapse={layout.sidebar.close} <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
/> )}
</div> settingsLabel={() => language.t("sidebar.settings")}
</Show> settingsKeybind={() => command.keybind("settings.open")}
</nav> onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
<div onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base" renderPanel={() => (
style={{ left: "calc(4rem + 12px)" }} <Show when={currentProject()} keyed>
/> {(project) => <SidebarPanel project={project} merged />}
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
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,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
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",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show> </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>
</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>
</div> </div>
<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>
</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={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
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,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<SidebarContent
mobile
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
)}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
openProjectLabel={language.t("command.project.open")}
openProjectKeybind={() => command.keybind("project.open")}
onOpenProject={chooseProject}
renderProjectOverlay={() => (
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
)}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
/>
</nav>
</div>
<div
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",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-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>
</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> </div>
{import.meta.env.DEV && <DebugBar />}
</div> </div>
<Toast.Region /> <Toast.Region />
</div> </div>
@@ -6,15 +6,9 @@ import {
parseDeepLink, parseDeepLink,
parseNewSessionDeepLink, parseNewSessionDeepLink,
} from "./deep-links" } from "./deep-links"
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { import { hasProjectPermissions, latestRootSession } from "./helpers"
displayName,
effectiveWorkspaceOrder,
errorMessage,
hasProjectPermissions,
latestRootSession,
workspaceKey,
} from "./helpers"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
@@ -116,7 +110,7 @@ describe("layout workspace helpers", () => {
}) })
test("keeps local first while preserving known order", () => { test("keeps local first while preserving known order", () => {
const result = effectiveWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
expect(result).toEqual(["/root", "/c", "/b"]) expect(result).toEqual(["/root", "/c", "/b"])
}) })
@@ -198,6 +192,12 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root") expect(result?.id).toBe("root")
}) })
test("extracts draggable id safely", () => {
expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
expect(getDraggableId(null)).toBeUndefined()
})
test("formats fallback project display name", () => { test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+12 -2
View File
@@ -8,7 +8,7 @@ export const workspaceKey = (directory: string) => {
return directory.replace(/[\\/]+$/, "") return directory.replace(/[\\/]+$/, "")
} }
function sortSessions(now: number) { export function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => { return (a: Session, b: Session) => {
const aUpdated = a.time.updated ?? a.time.created const aUpdated = a.time.updated ?? a.time.created
@@ -22,7 +22,7 @@ function sortSessions(now: number) {
} }
} }
const isRootVisibleSession = (session: Session, directory: string) => export const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
@@ -54,6 +54,14 @@ export const childMapByParent = (sessions: Session[]) => {
return map return map
} }
export function getDraggableId(event: unknown): string | undefined {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined
}
export const displayName = (project: { name?: string; worktree: string }) => export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree) project.name || getFilename(project.worktree)
@@ -90,3 +98,5 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
return [...result, ...live.values()] return [...result, ...live.values()]
} }
export const syncWorkspaceOrder = effectiveWorkspaceOrder
+11 -42
View File
@@ -10,7 +10,6 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -68,8 +67,6 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
export type SessionItemProps = { export type SessionItemProps = {
session: Session session: Session
list: Session[]
navList?: Accessor<Session[]>
slug: string slug: string
mobile?: boolean mobile?: boolean
dense?: boolean dense?: boolean
@@ -98,18 +95,18 @@ const SessionRow = (props: {
setHoverSession: (id: string | undefined) => void setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean> sidebarOpened: Accessor<boolean>
warmHover: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void
warmPress: () => void scheduleHoverPrefetch: () => void
warmFocus: () => void
cancelHoverPrefetch: () => void cancelHoverPrefetch: () => void
}): JSX.Element => ( }): JSX.Element => (
<A <A
href={`/${props.slug}/session/${props.session.id}`} href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress} onPointerEnter={props.scheduleHoverPrefetch}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch} onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus} onMouseEnter={props.scheduleHoverPrefetch}
onMouseLeave={props.cancelHoverPrefetch}
onFocus={() => props.prefetchSession(props.session, "high")}
onClick={() => { onClick={() => {
props.setHoverSession(undefined) props.setHoverSession(undefined)
if (props.sidebarOpened()) return if (props.sidebarOpened()) return
@@ -228,37 +225,11 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const hoverMessages = createMemo(() => const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
) )
const hoverReady = createMemo(() => { const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
if (sessionStore.message[props.session.id] === undefined) return false
if (props.session.id === params.id) return true
const info = getSessionPrefetch(props.session.directory, props.session.id)
if (!info) return false
return Date.now() - info.at < SESSION_PREFETCH_TTL
})
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id) const isActive = createMemo(() => props.session.id === params.id)
const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.()
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
? nav
: props.list
props.prefetchSession(props.session, priority)
const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory)
if (idx === -1) return
for (let step = 1; step <= span; step++) {
const next = list[idx + step]
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
const prev = list[idx - step]
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
}
}
const hoverPrefetch = { const hoverPrefetch = {
current: undefined as ReturnType<typeof setTimeout> | undefined, current: undefined as ReturnType<typeof setTimeout> | undefined,
} }
@@ -268,12 +239,11 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hoverPrefetch.current = undefined hoverPrefetch.current = undefined
} }
const scheduleHoverPrefetch = () => { const scheduleHoverPrefetch = () => {
warm(1, "high")
if (hoverPrefetch.current !== undefined) return if (hoverPrefetch.current !== undefined) return
hoverPrefetch.current = setTimeout(() => { hoverPrefetch.current = setTimeout(() => {
hoverPrefetch.current = undefined hoverPrefetch.current = undefined
warm(2, "low") props.prefetchSession(props.session)
}, 80) }, 200)
} }
onCleanup(cancelHoverPrefetch) onCleanup(cancelHoverPrefetch)
@@ -297,9 +267,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
setHoverSession={props.setHoverSession} setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon} clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened} sidebarOpened={layout.sidebar.opened}
warmHover={scheduleHoverPrefetch} prefetchSession={props.prefetchSession}
warmPress={() => warm(2, "high")} scheduleHoverPrefetch={scheduleHoverPrefetch}
warmFocus={() => warm(2, "high")}
cancelHoverPrefetch={cancelHoverPrefetch} cancelHoverPrefetch={cancelHoverPrefetch}
/> />
) )
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
describe("projectSelected", () => {
test("matches direct worktree", () => {
expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true)
})
test("matches sandbox worktree", () => {
expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true)
expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false)
})
})
describe("projectTileActive", () => {
test("menu state always wins", () => {
expect(
projectTileActive({
menu: true,
preview: false,
open: false,
overlay: false,
worktree: "/tmp/root",
}),
).toBe(true)
})
test("preview mode uses open state", () => {
expect(
projectTileActive({
menu: false,
preview: true,
open: true,
overlay: true,
hoverProject: "/tmp/other",
worktree: "/tmp/root",
}),
).toBe(true)
})
test("overlay mode uses hovered project", () => {
expect(
projectTileActive({
menu: false,
preview: false,
open: false,
overlay: true,
hoverProject: "/tmp/root",
worktree: "/tmp/root",
}),
).toBe(true)
expect(
projectTileActive({
menu: false,
preview: false,
open: false,
overlay: true,
hoverProject: "/tmp/other",
worktree: "/tmp/root",
}),
).toBe(false)
})
})
@@ -0,0 +1,11 @@
export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) =>
worktree === currentDir || sandboxes?.includes(currentDir) === true
export const projectTileActive = (args: {
menu: boolean
preview: boolean
open: boolean
overlay: boolean
hoverProject?: string
worktree: string
}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree)
@@ -12,6 +12,7 @@ import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers" import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
export type ProjectSidebarContext = { export type ProjectSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
@@ -30,7 +31,7 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[] workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover"> sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
setHoverSession: (id: string | undefined) => void setHoverSession: (id: string | undefined) => void
} }
@@ -204,12 +205,11 @@ const ProjectPreviewPanel = (props: {
<Show <Show
when={props.workspaceEnabled()} when={props.workspaceEnabled()}
fallback={ fallback={
<For each={props.projectSessions().slice(0, 2)}> <For each={props.projectSessions()}>
{(session) => ( {(session) => (
<SessionItem <SessionItem
{...props.ctx.sessionProps} {...props.ctx.sessionProps}
session={session} session={session}
list={props.projectSessions()}
slug={base64Encode(props.project.worktree)} slug={base64Encode(props.project.worktree)}
dense dense
mobile={props.mobile} mobile={props.mobile}
@@ -232,12 +232,11 @@ const ProjectPreviewPanel = (props: {
</div> </div>
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span> <span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
</div> </div>
<For each={sessions().slice(0, 2)}> <For each={sessions()}>
{(session) => ( {(session) => (
<SessionItem <SessionItem
{...props.ctx.sessionProps} {...props.ctx.sessionProps}
session={session} session={session}
list={sessions()}
slug={base64Encode(directory)} slug={base64Encode(directory)}
dense dense
mobile={props.mobile} mobile={props.mobile}
@@ -278,10 +277,8 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const selected = createMemo( const selected = createMemo(() =>
() => projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
) )
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
@@ -294,8 +291,15 @@ export const SortableProject = (props: {
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo( const active = createMemo(() =>
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree), projectTileActive({
menu: state.menu,
preview: preview(),
open: state.open,
overlay: overlay(),
hoverProject: props.ctx.hoverProject(),
worktree: props.project.worktree,
}),
) )
createEffect(() => { createEffect(() => {
@@ -319,11 +323,11 @@ export const SortableProject = (props: {
} }
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2))
const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const projectChildren = createMemo(() => childMapByParent(projectStore().session))
const workspaceSessions = (directory: string) => { const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow()) return sortedRootSessions(data, props.sortNow()).slice(0, 2)
} }
const workspaceChildren = (directory: string) => { const workspaceChildren = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
@@ -0,0 +1 @@
export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { sidebarExpanded } from "./sidebar-shell-helpers"
describe("sidebarExpanded", () => {
test("expands on mobile regardless of desktop open state", () => {
expect(sidebarExpanded(true, false)).toBe(true)
})
test("follows desktop open state when not mobile", () => {
expect(sidebarExpanded(false, true)).toBe(true)
expect(sidebarExpanded(false, false)).toBe(false)
})
})
@@ -11,6 +11,7 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { sidebarExpanded } from "./sidebar-shell-helpers"
export const SidebarContent = (props: { export const SidebarContent = (props: {
mobile?: boolean mobile?: boolean
@@ -32,7 +33,7 @@ export const SidebarContent = (props: {
onOpenHelp: () => void onOpenHelp: () => void
renderPanel: () => JSX.Element renderPanel: () => JSX.Element
}): JSX.Element => { }): JSX.Element => {
const expanded = createMemo(() => !!props.mobile || props.opened()) const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
const placement = () => (props.mobile ? "bottom" : "right") const placement = () => (props.mobile ? "bottom" : "right")
let panel: HTMLDivElement | undefined let panel: HTMLDivElement | undefined
@@ -115,7 +116,7 @@ export const SidebarContent = (props: {
ref={(el) => { ref={(el) => {
panel = el panel = el
}} }}
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }} classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()} aria-hidden={!expanded()}
> >
{props.renderPanel()} {props.renderPanel()}
@@ -0,0 +1,2 @@
export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
expanded[directory] ?? local
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { workspaceOpenState } from "./sidebar-workspace-helpers"
describe("workspaceOpenState", () => {
test("defaults to local workspace open", () => {
expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true)
})
test("uses persisted expansion state when present", () => {
expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false)
expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true)
})
})
@@ -32,7 +32,6 @@ type InlineEditorComponent = (props: {
export type WorkspaceSidebarContext = { export type WorkspaceSidebarContext = {
currentDir: Accessor<string> currentDir: Accessor<string>
navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean> sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
nav: Accessor<HTMLElement | undefined> nav: Accessor<HTMLElement | undefined>
@@ -145,6 +144,8 @@ const WorkspaceActions = (props: {
setMenuOpen: (open: boolean) => void setMenuOpen: (open: boolean) => void
setPendingRename: (value: boolean) => void setPendingRename: (value: boolean) => void
sidebarHovering: Accessor<boolean> sidebarHovering: Accessor<boolean>
mobile?: boolean
nav: Accessor<HTMLElement | undefined>
touch: Accessor<boolean> touch: Accessor<boolean>
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
workspaceValue: Accessor<string> workspaceValue: Accessor<string>
@@ -239,7 +240,6 @@ const WorkspaceActions = (props: {
const WorkspaceSessionList = (props: { const WorkspaceSessionList = (props: {
slug: Accessor<string> slug: Accessor<string>
mobile?: boolean mobile?: boolean
popover?: boolean
ctx: WorkspaceSidebarContext ctx: WorkspaceSidebarContext
showNew: Accessor<boolean> showNew: Accessor<boolean>
loading: Accessor<boolean> loading: Accessor<boolean>
@@ -266,11 +266,8 @@ const WorkspaceSessionList = (props: {
{(session) => ( {(session) => (
<SessionItem <SessionItem
session={session} session={session}
list={props.sessions()}
navList={props.ctx.navList}
slug={props.slug()} slug={props.slug()}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
children={props.children()} children={props.children()}
sidebarExpanded={props.ctx.sidebarExpanded} sidebarExpanded={props.ctx.sidebarExpanded}
sidebarHovering={props.ctx.sidebarHovering} sidebarHovering={props.ctx.sidebarHovering}
@@ -307,7 +304,6 @@ export const SortableWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
@@ -344,22 +340,6 @@ export const SortableWorkspace = (props: {
} }
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`)) const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
const header = () => (
<WorkspaceHeader
local={local}
busy={busy}
open={open}
directory={props.directory}
language={language}
branch={() => workspaceStore.vcs?.branch}
workspaceValue={workspaceValue}
workspaceEditActive={workspaceEditActive}
InlineEditor={props.ctx.InlineEditor}
renameWorkspace={props.ctx.renameWorkspace}
setEditor={props.ctx.setEditor}
projectId={props.project.id}
/>
)
const openWrapper = (value: boolean) => { const openWrapper = (value: boolean) => {
props.ctx.setWorkspaceExpanded(props.directory, value) props.ctx.setWorkspaceExpanded(props.directory, value)
@@ -399,7 +379,20 @@ export const SortableWorkspace = (props: {
data-action="workspace-toggle" data-action="workspace-toggle"
data-workspace={base64Encode(props.directory)} data-workspace={base64Encode(props.directory)}
> >
{header()} <WorkspaceHeader
local={local}
busy={busy}
open={open}
directory={props.directory}
language={language}
branch={() => workspaceStore.vcs?.branch}
workspaceValue={workspaceValue}
workspaceEditActive={workspaceEditActive}
InlineEditor={props.ctx.InlineEditor}
renameWorkspace={props.ctx.renameWorkspace}
setEditor={props.ctx.setEditor}
projectId={props.project.id}
/>
</Collapsible.Trigger> </Collapsible.Trigger>
} }
> >
@@ -408,7 +401,20 @@ export const SortableWorkspace = (props: {
menu.open ? "pr-16" : "pr-2" menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`} } group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
> >
{header()} <WorkspaceHeader
local={local}
busy={busy}
open={open}
directory={props.directory}
language={language}
branch={() => workspaceStore.vcs?.branch}
workspaceValue={workspaceValue}
workspaceEditActive={workspaceEditActive}
InlineEditor={props.ctx.InlineEditor}
renameWorkspace={props.ctx.renameWorkspace}
setEditor={props.ctx.setEditor}
projectId={props.project.id}
/>
</div> </div>
</Show> </Show>
<WorkspaceActions <WorkspaceActions
@@ -420,6 +426,8 @@ export const SortableWorkspace = (props: {
setMenuOpen={(open) => setMenu("open", open)} setMenuOpen={(open) => setMenu("open", open)}
setPendingRename={(value) => setMenu("pendingRename", value)} setPendingRename={(value) => setMenu("pendingRename", value)}
sidebarHovering={props.ctx.sidebarHovering} sidebarHovering={props.ctx.sidebarHovering}
mobile={props.mobile}
nav={props.ctx.nav}
touch={touch} touch={touch}
language={language} language={language}
workspaceValue={workspaceValue} workspaceValue={workspaceValue}
@@ -439,7 +447,6 @@ export const SortableWorkspace = (props: {
<WorkspaceSessionList <WorkspaceSessionList
slug={slug} slug={slug}
mobile={props.mobile} mobile={props.mobile}
popover={props.popover}
ctx={props.ctx} ctx={props.ctx}
showNew={showNew} showNew={showNew}
loading={loading} loading={loading}
@@ -460,7 +467,6 @@ export const LocalWorkspace = (props: {
project: LocalProject project: LocalProject
sortNow: Accessor<number> sortNow: Accessor<number>
mobile?: boolean mobile?: boolean
popover?: boolean
}): JSX.Element => { }): JSX.Element => {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
@@ -484,19 +490,44 @@ export const LocalWorkspace = (props: {
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)} ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
> >
<WorkspaceSessionList <nav class="flex flex-col gap-1 px-3">
slug={slug} <Show when={loading()}>
mobile={props.mobile} <SessionSkeleton />
popover={props.popover} </Show>
ctx={props.ctx} <For each={sessions()}>
showNew={() => false} {(session) => (
loading={loading} <SessionItem
sessions={sessions} session={session}
children={children} slug={slug()}
hasMore={hasMore} mobile={props.mobile}
loadMore={loadMore} children={children()}
language={language} sidebarExpanded={props.ctx.sidebarExpanded}
/> sidebarHovering={props.ctx.sidebarHovering}
nav={props.ctx.nav}
hoverSession={props.ctx.hoverSession}
setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
/>
)}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large"
onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
{language.t("common.loadMore")}
</Button>
</div>
</Show>
</nav>
</div> </div>
) )
} }
+179 -299
View File
@@ -19,34 +19,29 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode, checksum } from "@opencode-ai/util/encode" import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { useNavigate, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
@@ -267,13 +262,12 @@ export default function Page() {
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => { createEffect(() => {
if (!untrack(() => prompt.ready())) return if (!untrack(() => prompt.ready())) return
@@ -290,7 +284,6 @@ export default function Page() {
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
git: false, git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reviewSnap: false, reviewSnap: false,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -301,8 +294,11 @@ export default function Page() {
const composer = createSessionComposerState() const composer = createSessionComposerState()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "") const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
createEffect( createEffect(
on( on(
@@ -373,22 +369,18 @@ export default function Page() {
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
} }
createEffect(() => {
const active = tabs().active()
if (!active) return
const path = file.pathFromTab(active)
if (path) file.load(path)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0) const hasReview = createMemo(() => reviewCount() > 0)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID) const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => { const messagesReady = createMemo(() => {
@@ -425,14 +417,6 @@ export default function Page() {
) )
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(() => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (path) file.load(path)
})
createEffect( createEffect(
on( on(
() => lastUserMessage()?.id, () => lastUserMessage()?.id,
@@ -450,6 +434,7 @@ export default function Page() {
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
if (prev.id) sync.session.evict(prev.id, prev.dir)
if (!next.id) resetSessionModel(local) if (!next.id) resetSessionModel(local)
}, },
{ defer: true }, { defer: true },
@@ -476,10 +461,6 @@ export default function Page() {
}, sessionKey()) }, sessionKey())
let reviewFrame: number | undefined let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
createComputed((prev) => { createComputed((prev) => {
const open = desktopReviewOpen() const open = desktopReviewOpen()
@@ -639,36 +620,10 @@ export default function Page() {
createEffect( createEffect(
on([() => sdk.directory, () => params.id] as const, ([, id]) => { on([() => sdk.directory, () => params.id] as const, ([, id]) => {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined
refreshTimer = undefined
if (!id) return if (!id) return
const cached = untrack(() => sync.data.message[id] !== undefined)
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(sdk.directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
untrack(() => { untrack(() => {
void sync.session.sync(id) void sync.session.sync(id)
}) void sync.session.todo(id)
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (params.id !== id) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
})
}, 0)
}) })
}), }),
) )
@@ -711,7 +666,11 @@ export default function Page() {
const selectionPreview = (path: string, selection: FileSelection) => { const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content const content = file.get(path)?.content?.content
if (!content) return undefined if (!content) return undefined
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
} }
const addCommentToContext = (input: { const addCommentToContext = (input: {
@@ -800,11 +759,8 @@ export default function Page() {
return return
} }
// Prefer the open terminal over the composer when it can take focus // Don't autofocus chat if desktop terminal panel is open
if (view().terminal.opened()) { if (isDesktop() && view().terminal.opened()) return
const id = terminal.active()
if (id && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures. // Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
@@ -818,7 +774,15 @@ export default function Page() {
} }
} }
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context" && tab !== "review"),
)
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const reviewTab = createMemo(() => isDesktop())
const fileTreeTab = () => layout.fileTree.tab() const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -854,7 +818,6 @@ export default function Page() {
navigateMessageByOffset, navigateMessageByOffset,
setActiveMessage, setActiveMessage,
focusInput, focusInput,
review: reviewTab,
}) })
const openReviewFile = createOpenReviewFile({ const openReviewFile = createOpenReviewFile({
@@ -894,36 +857,6 @@ export default function Page() {
</div> </div>
) )
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
if (hasReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
return (
<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>
)
}
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
</div>
)
}
const reviewContent = (input: { const reviewContent = (input: {
diffStyle: DiffStyle diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void onDiffStyleChange?: (style: DiffStyle) => void
@@ -932,25 +865,98 @@ export default function Page() {
emptyClass: string emptyClass: string
}) => ( }) => (
<Show when={!store.deferRender}> <Show when={!store.deferRender}>
<SessionReviewTab <Switch>
title={changesTitle()} <Match when={store.changes === "turn" && !!params.id}>
empty={reviewEmpty(input)} <SessionReviewTab
diffs={reviewDiffs} title={changesTitle()}
view={view} empty={emptyTurn()}
diffStyle={input.diffStyle} diffs={reviewDiffs}
onDiffStyleChange={input.onDiffStyleChange} view={view}
onScrollRef={(el) => setTree("reviewScroll", el)} diffStyle={input.diffStyle}
focusedFile={tree.activeDiff} onDiffStyleChange={input.onDiffStyleChange}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onScrollRef={(el) => setTree("reviewScroll", el)}
onLineCommentUpdate={updateCommentInContext} focusedFile={tree.activeDiff}
onLineCommentDelete={removeCommentFromContext} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
lineCommentActions={reviewCommentActions()} onLineCommentUpdate={updateCommentInContext}
comments={comments.all()} onLineCommentDelete={removeCommentFromContext}
focusedComment={comments.focus()} lineCommentActions={reviewCommentActions()}
onFocusedCommentChange={comments.setFocus} comments={comments.all()}
onViewFile={openReviewFile} focusedComment={comments.focus()}
classes={input.classes} 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()}
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>
) )
@@ -969,10 +975,11 @@ export default function Page() {
createEffect( createEffect(
on( on(
activeFileTab, () => tabs().active(),
(active) => { (active) => {
if (!active) return if (!active) return
if (fileTreeTab() !== "changes") return if (fileTreeTab() !== "changes") return
if (!file.pathFromTab(active)) return
showAllFiles() showAllFiles()
}, },
{ defer: true }, { defer: true },
@@ -1015,7 +1022,8 @@ export default function Page() {
const focusReviewDiff = (path: string) => { const focusReviewDiff = (path: string) => {
openReviewPanel() openReviewPanel()
view().review.openPath(path) const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setTree({ activeDiff: path, pendingDiff: path }) setTree({ activeDiff: path, pendingDiff: path })
} }
@@ -1060,6 +1068,29 @@ export default function Page() {
requestAnimationFrame(() => attempt(0)) requestAnimationFrame(() => attempt(0))
}) })
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return
const next = activeTab()
if (next === "empty") return
tabs().setActive(next)
})
createEffect(() => { createEffect(() => {
const id = params.id const id = params.id
if (!id) return if (!id) return
@@ -1074,39 +1105,6 @@ export default function Page() {
void sync.session.diff(id) void sync.session.diff(id)
}) })
createEffect(
on(
() =>
[
sessionKey(),
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
] as const,
([key, wants]) => {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
diffFrame = undefined
diffTimer = undefined
if (!wants) return
const id = params.id
if (!id) return
if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
diffFrame = requestAnimationFrame(() => {
diffFrame = undefined
diffTimer = window.setTimeout(() => {
diffTimer = undefined
if (sessionKey() !== key) return
void sync.session.diff(id, { force: true })
}, 0)
})
},
{ defer: true },
),
)
let treeDir: string | undefined let treeDir: string | undefined
createEffect(() => { createEffect(() => {
const dir = sdk.directory const dir = sdk.directory
@@ -1126,9 +1124,9 @@ export default function Page() {
() => { () => {
void file.tree.list("") void file.tree.list("")
const tab = activeFileTab() const active = tabs().active()
if (!tab) return if (!active) return
const path = file.pathFromTab(tab) const path = file.pathFromTab(active)
if (!path) return if (!path) return
void file.load(path, { force: true }) void file.load(path, { force: true })
}, },
@@ -1219,110 +1217,6 @@ export default function Page() {
scroller: () => scroller, scroller: () => scroller,
}) })
const draft = (id: string) =>
extractPromptFromParts(sync.data.part[id] ?? [], {
directory: sdk.directory,
attachmentName: language.t("common.attachment"),
})
const line = (id: string) => {
const text = draft(id)
.map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
.join("")
.replace(/\s+/g, " ")
.trim()
if (text) return text
return `[${language.t("common.attachment")}]`
}
const fail = (err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
}
const busy = (sessionID: string) => {
if (sync.data.session_status[sessionID]?.type !== "idle") return true
return (sync.data.message[sessionID] ?? []).some(
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
}
const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID)
return sdk.client.session
.fork(input)
.then((result) => {
const next = result.data
if (!next) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
})
return
}
navigate(`/${base64Encode(sdk.directory)}/session/${next.id}`)
requestAnimationFrame(() => {
prompt.set(value)
})
})
.catch(fail)
}
const revert = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID)
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then(() => {
prompt.set(value)
})
.catch(fail)
}
const restore = (id: string) => {
const sessionID = params.id
if (!sessionID || ui.restoring) return
const next = userMessages().find((item) => item.id > id)
setUi("restoring", id)
const task = !next
? halt(sessionID)
.then(() => sdk.client.session.unrevert({ sessionID }))
.then(() => {
prompt.reset()
})
: halt(sessionID)
.then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
.then(() => {
prompt.set(draft(next.id))
})
return task.catch(fail).finally(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
})
}
const rolled = createMemo(() => {
const id = revertMessageID()
if (!id) return []
return userMessages()
.filter((item) => item.id >= id)
.map((item) => ({ id: item.id, text: line(item.id) }))
})
const actions = { fork, revert }
createResizeObserver( createResizeObserver(
() => promptDock, () => promptDock,
({ height }) => { ({ height }) => {
@@ -1369,10 +1263,6 @@ export default function Page() {
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
}) })
@@ -1380,30 +1270,14 @@ export default function Page() {
<div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader /> <SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row"> <div class="flex-1 min-h-0 flex flex-col md:flex-row">
<Show when={!isDesktop() && !!params.id}> <SessionMobileTabs
<Tabs value={store.mobileTab} class="h-auto"> open={!isDesktop() && !!params.id}
<Tabs.List> mobileTab={store.mobileTab}
<Tabs.Trigger hasReview={hasReview()}
value="session" reviewCount={reviewCount()}
class="!w-1/2 !max-w-none" onSession={() => setStore("mobileTab", "session")}
classes={{ button: "w-full" }} onChanges={() => setStore("mobileTab", "changes")}
onClick={() => setStore("mobileTab", "session")} />
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "changes")}
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
{/* Session panel */} {/* Session panel */}
<div <div
@@ -1432,7 +1306,6 @@ export default function Page() {
loadingClass: "px-4 py-4 text-text-weak", loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})} })}
actions={actions}
scroll={ui.scroll} scroll={ui.scroll}
onResumeScroll={resumeScroll} onResumeScroll={resumeScroll}
setScrollRef={setScrollRef} setScrollRef={setScrollRef}
@@ -1463,7 +1336,23 @@ export default function Page() {
</Show> </Show>
</Match> </Match>
<Match when={true}> <Match when={true}>
<NewSessionView worktree={newSessionWorktree()} /> <NewSessionView
worktree={newSessionWorktree()}
onWorktreeChange={(value) => {
if (value === "create") {
setStore("newSessionWorktree", value)
return
}
setStore("newSessionWorktree", "main")
const target = value === "main" ? sync.project?.worktree : value
if (!target) return
if (target === sdk.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}
/>
</Match> </Match>
</Switch> </Switch>
</div> </div>
@@ -1482,15 +1371,6 @@ export default function Page() {
resumeScroll() resumeScroll()
}} }}
onResponseSubmit={resumeScroll} onResponseSubmit={resumeScroll}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: ui.restoring,
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => { setPromptDockRef={(el) => {
promptDock = el promptDock = el
}} }}
@@ -1,2 +1,3 @@
export { SessionComposerRegion } from "./session-composer-region" export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerState } from "./session-composer-state" export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
export type { SessionComposerState } from "./session-composer-state"
@@ -1,14 +1,13 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
@@ -21,17 +20,30 @@ export function SessionComposerRegion(props: {
onNewSessionWorktreeReset: () => void onNewSessionWorktreeReset: () => void
onSubmit: () => void onSubmit: () => void
onResponseSubmit: () => void onResponseSubmit: () => void
revert?: {
items: { id: string; text: string }[]
restoring?: string
onRestore: (id: string) => void
}
setPromptDockRef: (el: HTMLDivElement) => 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() const prompt = usePrompt()
const language = useLanguage() const language = useLanguage()
const { sessionKey } = useSessionKey()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const previewPrompt = () => const previewPrompt = () =>
@@ -51,10 +63,8 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
}) })
const [store, setStore] = createStore({ const [gate, setGate] = createStore({
ready: false, ready: false,
height: 320,
body: undefined as HTMLDivElement | undefined,
}) })
let timer: number | undefined let timer: number | undefined
let frame: number | undefined let frame: number | undefined
@@ -76,13 +86,13 @@ export function SessionComposerRegion(props: {
const delay = 140 const delay = 140
clear() clear()
setStore("ready", false) setGate("ready", false)
if (!ready) return if (!ready) return
frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => {
frame = undefined frame = undefined
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
setStore("ready", true) setGate("ready", true)
timer = undefined timer = undefined
}, delay) }, delay)
}) })
@@ -90,19 +100,30 @@ export function SessionComposerRegion(props: {
onCleanup(clear) onCleanup(clear)
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing()) const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) 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 progress = useSpring(() => (open() ? 1 : 0), config)
const value = createMemo(() => Math.max(0, Math.min(1, progress()))) const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001) const [height, setHeight] = createSignal(320)
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined)) const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
const lift = createMemo(() => (rolled() ? 18 : 36 * value())) const full = createMemo(() => Math.max(78, height()))
const full = createMemo(() => Math.max(78, store.height)) const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(() => { createEffect(() => {
const el = store.body const el = contentRef()
if (!el) return if (!el) return
const update = () => { const update = () => {
setStore("height", el.getBoundingClientRect().height) setHeight(el.getBoundingClientRect().height)
} }
update() update()
const observer = new ResizeObserver(update) const observer = new ResizeObserver(update)
@@ -149,22 +170,9 @@ export function SessionComposerRegion(props: {
<Show <Show
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
<> <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
<Show when={rolled()} keyed> {handoffPrompt() || language.t("prompt.loading")}
{(revert) => ( </div>
<div class="pb-2">
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}
onRestore={revert.onRestore}
/>
</div>
)}
</Show>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
</div>
</>
} }
> >
<Show when={dock()}> <Show when={dock()}>
@@ -177,34 +185,36 @@ export function SessionComposerRegion(props: {
"max-height": `${full() * value()}px`, "max-height": `${full() * value()}px`,
}} }}
> >
<div ref={(el) => setStore("body", el)}> <div ref={setContentRef}>
<SessionTodoDock <SessionTodoDock
todos={props.state.todos()} todos={props.state.todos()}
title={language.t("session.todo.title")} title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")} collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")} expandLabel={language.t("session.todo.expand")}
dockProgress={value()} 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}
/> />
</div> </div>
</div> </div>
</Show> </Show>
<Show when={rolled()} keyed>
{(revert) => (
<div
style={{
"margin-top": `${-36 * value()}px`,
}}
>
<SessionRevertDock items={revert.items} restoring={revert.restoring} onRestore={revert.onRestore} />
</div>
)}
</Show>
<div <div
classList={{ classList={{
"relative z-10": true, "relative z-10": true,
}} }}
style={{ style={{
"margin-top": `${-lift()}px`, "margin-top": `${-36 * value()}px`,
}} }}
> >
<PromptInput <PromptInput
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) => const session = (input: { id: string; parentID?: string }) =>
@@ -104,25 +103,3 @@ describe("sessionQuestionRequest", () => {
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
}) })
}) })
describe("todoState", () => {
test("hides when there are no todos", () => {
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
})
test("opens while the session is still working", () => {
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
})
test("closes completed todos after a running turn", () => {
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
})
test("clears stale todos when the turn ends", () => {
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
})
test("clears completed todos when the session is no longer live", () => {
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
})
})
@@ -10,18 +10,24 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: { export function createSessionComposerBlocked() {
count: number const params = useParams()
done: boolean const permission = usePermission()
live: boolean const sdk = useSDK()
}): "hide" | "clear" | "open" | "close" => { const sync = useSync()
if (input.count === 0) return "hide" const permissionRequest = createMemo(() =>
if (!input.live) return "clear" sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
if (!input.done) return "open" return !permission.autoResponds(item, sdk.directory)
return "close" }),
} )
const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
const idle = { type: "idle" as const } return createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
})
}
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
const params = useParams() const params = useParams()
@@ -53,22 +59,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
return globalSync.data.session_todo[id] ?? [] return globalSync.data.session_todo[id] ?? []
}) })
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const status = createMemo(() => {
const id = params.id
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const busy = createMemo(() => status().type !== "idle")
const live = createMemo(() => busy() || blocked())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
responding: undefined as string | undefined, responding: undefined as string | undefined,
dock: todos().length > 0 && live(), dock: todos().length > 0,
closing: false, closing: false,
opening: false, opening: false,
}) })
@@ -96,6 +89,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
}) })
} }
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
let timer: number | undefined let timer: number | undefined
let raf: number | undefined let raf: number | undefined
@@ -114,42 +111,21 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
}, closeMs()) }, closeMs())
} }
// Keep stale turn todos from reopening if the model never clears them.
const clear = () => {
const id = params.id
if (!id) return
globalSync.todo.set(id, [])
sync.set("todo", id, [])
}
createEffect( createEffect(
on( on(
() => [todos().length, done(), live()] as const, () => [todos().length, done()] as const,
([count, complete, active]) => { ([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf) if (raf) cancelAnimationFrame(raf)
raf = undefined raf = undefined
const next = todoState({ if (count === 0) {
count,
done: complete,
live: active,
})
if (next === "hide") {
if (timer) window.clearTimeout(timer) if (timer) window.clearTimeout(timer)
timer = undefined timer = undefined
setStore({ dock: false, closing: false, opening: false }) setStore({ dock: false, closing: false, opening: false })
return return
} }
if (next === "clear") { if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
clear()
return
}
if (next === "open") {
if (timer) window.clearTimeout(timer) if (timer) window.clearTimeout(timer)
timer = undefined timer = undefined
const hidden = !store.dock || store.closing const hidden = !store.dock || store.closing
@@ -166,8 +142,13 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
return return
} }
if (prev && prev[1]) {
if (store.closing && !timer) scheduleClose()
return
}
setStore({ dock: true, opening: false, closing: true }) setStore({ dock: true, opening: false, closing: true })
if (!timer) scheduleClose() scheduleClose()
}, },
), ),
) )
@@ -1,92 +0,0 @@
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useLanguage } from "@/context/language"
export function SessionRevertDock(props: {
items: { id: string; text: string }[]
restoring?: string
onRestore: (id: string) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const total = createMemo(() => props.items.length)
const label = createMemo(() =>
language.t(total() === 1 ? "session.revertDock.summary.one" : "session.revertDock.summary.other", {
count: total(),
}),
)
const preview = createMemo(() => props.items[0]?.text ?? "")
return (
<DockTray data-component="session-revert-dock">
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>
<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 rounded-[10px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={!!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
)
}
@@ -6,7 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) { function dot(status: Todo["status"]) {
@@ -39,11 +39,23 @@ export function SessionTodoDock(props: {
title: string title: string
collapseLabel: string collapseLabel: string
expandLabel: string expandLabel: string
dockProgress: number 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({ const [store, setStore] = createStore({
collapsed: false, collapsed: false,
height: 320,
}) })
const toggle = () => setStore("collapsed", (value) => !value) const toggle = () => setStore("collapsed", (value) => !value)
@@ -61,21 +73,33 @@ export function SessionTodoDock(props: {
) )
const preview = createMemo(() => active()?.content ?? "") const preview = createMemo(() => active()?.content ?? "")
const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const config = createMemo(() =>
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) 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 shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut())) const hide = createMemo(() => Math.max(value(), shut()))
const off = createMemo(() => hide() > 0.98) const off = createMemo(() => hide() > 0.98)
const turn = createMemo(() => Math.max(0, Math.min(1, value()))) const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const full = createMemo(() => Math.max(78, store.height)) const [height, setHeight] = createSignal(320)
const full = createMemo(() => Math.max(78, height()))
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
createEffect(() => { createEffect(() => {
const el = contentRef const el = contentRef
if (!el) return if (!el) return
const update = () => { const update = () => {
setStore("height", el.getBoundingClientRect().height) setHeight(el.getBoundingClientRect().height)
} }
update() update()
const observer = new ResizeObserver(update) const observer = new ResizeObserver(update)
@@ -109,11 +133,12 @@ export function SessionTodoDock(props: {
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()} aria-label={label()}
style={{ style={{
"--tool-motion-odometer-ms": "600ms", "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
"--tool-motion-mask": "18%", "--tool-motion-mask": `${props.countMask ?? 18}%`,
"--tool-motion-mask-height": "0px", "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": "560ms", "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
}} }}
> >
<AnimatedNumber value={done()} /> <AnimatedNumber value={done()} />
@@ -132,9 +157,9 @@ export function SessionTodoDock(props: {
<TextReveal <TextReveal
class="text-14-regular text-text-base cursor-default" class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined} text={store.collapsed ? preview() : undefined}
duration={600} duration={props.subtitleDuration ?? 600}
travel={25} travel={props.subtitleTravel ?? 25}
edge={17} edge={props.subtitleEdge ?? 17}
spring="cubic-bezier(0.34, 1, 0.64, 1)" spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)" springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly growOnly
@@ -171,6 +196,7 @@ export function SessionTodoDock(props: {
style={{ style={{
visibility: off() ? "hidden" : "visible", visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
}} }}
> >
<TodoList todos={props.todos} open={!store.collapsed} /> <TodoList todos={props.todos} open={!store.collapsed} />
@@ -181,10 +207,8 @@ export function SessionTodoDock(props: {
} }
function TodoList(props: { todos: Todo[]; open: boolean }) { function TodoList(props: { todos: Todo[]; open: boolean }) {
const [store, setStore] = createStore({ const [stuck, setStuck] = createSignal(false)
stuck: false, const [scrolling, setScrolling] = createSignal(false)
scrolling: false,
})
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let timer: number | undefined let timer: number | undefined
@@ -192,7 +216,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
const ensure = () => { const ensure = () => {
if (!props.open) return if (!props.open) return
if (store.scrolling) return if (scrolling()) return
if (!scrollRef || scrollRef.offsetParent === null) return if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]") const el = scrollRef.querySelector("[data-in-progress]")
@@ -213,7 +237,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
} }
setStore("stuck", scrollRef.scrollTop > 0) setStuck(scrollRef.scrollTop > 0)
} }
createEffect( createEffect(
@@ -235,11 +259,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
ref={scrollRef} ref={scrollRef}
style={{ "overflow-anchor": "none" }} style={{ "overflow-anchor": "none" }}
onScroll={(e) => { onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0) setStuck(e.currentTarget.scrollTop > 0)
setStore("scrolling", true) setScrolling(true)
if (timer) window.clearTimeout(timer) if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
setStore("scrolling", false) setScrolling(false)
if (inProgress() < 0) return if (inProgress() < 0) return
requestAnimationFrame(ensure) requestAnimationFrame(ensure)
}, 250) }, 250)
@@ -284,7 +308,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
style={{ style={{
background: "linear-gradient(to bottom, var(--background-base), transparent)", background: "linear-gradient(to bottom, var(--background-base), transparent)",
opacity: store.stuck ? 1 : 0, opacity: stuck() ? 1 : 0,
}} }}
/> />
</div> </div>
+11 -11
View File
@@ -1,6 +1,7 @@
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js" import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import type { FileSearchHandle } from "@opencode-ai/ui/file" import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -11,13 +12,12 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
function FileCommentMenu(props: { function FileCommentMenu(props: {
moreLabel: string moreLabel: string
@@ -53,17 +53,17 @@ function FileCommentMenu(props: {
} }
export function FileTabContent(props: { tab: string }) { export function FileTabContent(props: { tab: string }) {
const params = useParams()
const layout = useLayout()
const file = useFile() const file = useFile()
const comments = useComments() const comments = useComments()
const language = useLanguage() const language = useLanguage()
const prompt = usePrompt() const prompt = usePrompt()
const fileComponent = useFileComponent() const fileComponent = useFileComponent()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
tabs, const tabs = createMemo(() => layout.tabs(sessionKey))
pathFromTab: file.pathFromTab, const view = createMemo(() => layout.view(sessionKey))
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined let scrollFrame: number | undefined
@@ -234,7 +234,7 @@ export function FileTabContent(props: { tab: string }) {
if (typeof window === "undefined") return if (typeof window === "undefined") return
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (activeFileTab() !== props.tab) return if (tabs().active() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return if (event.key.toLowerCase() !== "f") return
@@ -262,7 +262,7 @@ export function FileTabContent(props: { tab: string }) {
const p = path() const p = path()
if (!focus || !p) return if (!focus || !p) return
if (focus.file !== p) return if (focus.file !== p) return
if (activeFileTab() !== props.tab) return if (tabs().active() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id) const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return if (!target) return
@@ -382,7 +382,7 @@ export function FileTabContent(props: { tab: string }) {
createEffect(() => { createEffect(() => {
const loaded = !!state()?.loaded const loaded = !!state()?.loaded
const ready = file.ready() const ready = file.ready()
const active = activeFileTab() === props.tab const active = tabs().active() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active } prev = { loaded, ready, active }
if (!restore) return if (!restore) return
+1 -72
View File
@@ -1,13 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js" import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers"
import { createStore } from "solid-js/store"
import {
createOpenReviewFile,
createOpenSessionFileTab,
createSessionTabs,
focusTerminalById,
getTabReorderIndex,
} from "./helpers"
describe("createOpenReviewFile", () => { describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => { test("opens and loads selected review file", () => {
@@ -95,66 +87,3 @@ describe("getTabReorderIndex", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined()
}) })
}) })
describe("createSessionTabs", () => {
test("normalizes the effective file tab", () => {
createRoot((dispose) => {
const [state] = createStore({
active: undefined as string | undefined,
all: ["file://src/a.ts", "context"],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab),
})
expect(result.activeTab()).toBe("norm:src/a.ts")
expect(result.activeFileTab()).toBe("norm:src/a.ts")
expect(result.closableTab()).toBe("norm:src/a.ts")
dispose()
})
})
test("prefers context and review fallbacks when no file tab is active", () => {
createRoot((dispose) => {
const [state] = createStore({
active: undefined as string | undefined,
all: ["context"],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
review: () => true,
hasReview: () => true,
})
expect(result.activeTab()).toBe("context")
expect(result.closableTab()).toBe("context")
dispose()
})
createRoot((dispose) => {
const [state] = createStore({
active: undefined as string | undefined,
all: [],
})
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
review: () => true,
hasReview: () => true,
})
expect(result.activeTab()).toBe("review")
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBeUndefined()
dispose()
})
})
})
+55 -73
View File
@@ -1,77 +1,5 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { same } from "@/utils/same"
const emptyTabs: string[] = []
type Tabs = {
active: Accessor<string | undefined>
all: Accessor<string[]>
}
type TabsInput = {
tabs: Accessor<Tabs>
pathFromTab: (tab: string) => string | undefined
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
() => {
const seen = new Set<string>()
return input
.tabs()
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
return [value]
})
},
emptyTabs,
{ equals: same },
)
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (review() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (!openedTabs().includes(active)) return
return active
})
return {
contextOpen,
openedTabs,
activeTab,
activeFileTab,
closableTab,
}
}
export const focusTerminalById = (id: string) => { export const focusTerminalById = (id: string) => {
const wrapper = document.getElementById(`terminal-wrapper-${id}`) const wrapper = document.getElementById(`terminal-wrapper-${id}`)
@@ -189,3 +117,57 @@ export const createSizing = () => {
} }
export type Sizing = ReturnType<typeof createSizing> export type Sizing = ReturnType<typeof createSizing>
export const createPresence = (open: Accessor<boolean>, wait = 200) => {
const [state, setState] = createStore({
show: open(),
open: open(),
})
let frame: number | undefined
let t: number | undefined
const clear = () => {
if (frame !== undefined) {
cancelAnimationFrame(frame)
frame = undefined
}
if (t !== undefined) {
clearTimeout(t)
t = undefined
}
}
createEffect(
on(open, (next) => {
clear()
if (next) {
if (state.show) {
setState("open", true)
return
}
setState({ show: true, open: false })
frame = requestAnimationFrame(() => {
frame = undefined
setState("open", true)
})
return
}
if (!state.show) return
setState("open", false)
t = window.setTimeout(() => {
t = undefined
setState("show", false)
}, wait)
}),
)
onCleanup(clear)
return {
show: () => state.show,
open: () => state.open,
}
}
@@ -1,6 +1,6 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -19,7 +19,6 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
@@ -37,11 +36,6 @@ type MessageComment = {
const emptyMessages: MessageType[] = [] const emptyMessages: MessageType[] = []
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
type UserActions = {
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
}
const messageComments = (parts: Part[]): MessageComment[] => const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => { parts.flatMap((part) => {
if (part.type !== "text" || !(part as TextPart).synthetic) return [] if (part.type !== "text" || !(part as TextPart).synthetic) return []
@@ -192,7 +186,6 @@ function createTimelineStaging(input: TimelineStageInput) {
export function MessageTimeline(props: { export function MessageTimeline(props: {
mobileChanges: boolean mobileChanges: boolean
mobileFallback: JSX.Element mobileFallback: JSX.Element
actions?: UserActions
scroll: { overflow: boolean; bottom: boolean } scroll: { overflow: boolean; bottom: boolean }
onResumeScroll: () => void onResumeScroll: () => void
setScrollRef: (el: HTMLDivElement | undefined) => void setScrollRef: (el: HTMLDivElement | undefined) => void
@@ -214,15 +207,16 @@ export function MessageTimeline(props: {
}) { }) {
let touchGesture: number | undefined let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const settings = useSettings() const settings = useSettings()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id) const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => { const sessionMessages = createMemo(() => {
const id = sessionID() const id = sessionID()
@@ -770,7 +764,6 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:max-w-200 2xl:max-w-[1000px]": props.centered,
}} }}
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
> >
<Show when={commentCount() > 0}> <Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2"> <div class="w-full px-4 md:px-5 pb-2">
@@ -780,31 +773,27 @@ export function MessageTimeline(props: {
{(commentAccessor: () => MessageComment) => { {(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor()) const comment = createMemo(() => commentAccessor())
return ( return (
<Show when={comment()}> <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
{(c) => ( <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> <FileIcon
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> node={{ path: comment().path, type: "file" }}
<FileIcon class="size-3.5 shrink-0"
node={{ path: c().path, type: "file" }} />
class="size-3.5 shrink-0" <span class="truncate">{getFilename(comment().path)}</span>
/> <Show when={comment().selection}>
<span class="truncate">{getFilename(c().path)}</span> {(selection) => (
<Show when={c().selection}> <span class="shrink-0 text-text-weak">
{(selection) => ( {selection().startLine === selection().endLine
<span class="shrink-0 text-text-weak"> ? `:${selection().startLine}`
{selection().startLine === selection().endLine : `:${selection().startLine}-${selection().endLine}`}
? `:${selection().startLine}` </span>
: `:${selection().startLine}-${selection().endLine}`} )}
</span> </Show>
)} </div>
</Show> <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
</div> {comment().comment}
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words"> </div>
{c().comment} </div>
</div>
</div>
)}
</Show>
) )
}} }}
</Index> </Index>
@@ -815,7 +804,6 @@ export function MessageTimeline(props: {
<SessionTurn <SessionTurn
sessionID={sessionID() ?? ""} sessionID={sessionID() ?? ""}
messageID={messageID} messageID={messageID}
actions={props.actions}
active={active()} active={active()}
queued={queued()} queued={queued()}
status={active() ? sessionStatus() : undefined} status={active() ? sessionStatus() : undefined}
@@ -37,6 +37,14 @@ export interface SessionReviewTabProps {
} }
} }
export function StickyAddButton(props: { children: JSX.Element }) {
return (
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
{props.children}
</div>
)
}
export function SessionReviewTab(props: SessionReviewTabProps) { export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let restoreFrame: number | undefined let restoreFrame: number | undefined
@@ -0,0 +1,10 @@
export const canAddSelectionContext = (input: {
active?: string
pathFromTab: (tab: string) => string | undefined
selectedLines: (path: string) => unknown
}) => {
if (!input.active) return false
const path = input.pathFromTab(input.active)
if (!path) return false
return input.selectedLines(path) != null
}
@@ -1,20 +0,0 @@
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
export const useSessionKey = () => {
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
return { params, sessionKey }
}
export const useSessionLayout = () => {
const layout = useLayout()
const { params, sessionKey } = useSessionKey()
return {
params,
sessionKey,
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
}
}
@@ -0,0 +1,41 @@
import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLanguage } from "@/context/language"
export function SessionMobileTabs(props: {
open: boolean
mobileTab: "session" | "changes"
hasReview: boolean
reviewCount: number
onSession: () => void
onChanges: () => void
}) {
const language = useLanguage()
return (
<Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto">
<Tabs.List>
<Tabs.Trigger
value="session"
class="!w-1/2 !max-w-none"
classes={{ button: "w-full" }}
onClick={props.onSession}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
class="!w-1/2 !max-w-none !border-r-0"
classes={{ button: "w-full" }}
onClick={props.onChanges}
>
{props.hasReview
? language.t("session.review.filesChanged", { count: props.reviewCount })
: language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
)
}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { questionSubtitle } from "./session-prompt-helpers"
describe("questionSubtitle", () => {
const t = (key: string) => {
if (key === "ui.common.question.one") return "question"
if (key === "ui.common.question.other") return "questions"
return key
}
test("returns empty for zero", () => {
expect(questionSubtitle(0, t)).toBe("")
})
test("uses singular label", () => {
expect(questionSubtitle(1, t)).toBe("1 question")
})
test("uses plural label", () => {
expect(questionSubtitle(3, t)).toBe("3 questions")
})
})
@@ -0,0 +1,4 @@
export const questionSubtitle = (count: number, t: (key: string) => string) => {
if (count === 0) return ""
return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
}
@@ -1,6 +1,7 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
@@ -22,9 +23,9 @@ import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { StickyAddButton } from "@/pages/session/review-tab"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
@@ -33,15 +34,18 @@ export function SessionSidePanel(props: {
reviewSnap: boolean reviewSnap: boolean
size: Sizing size: Sizing
}) { }) {
const params = useParams()
const layout = useLayout() const layout = useLayout()
const sync = useSync() const sync = useSync()
const file = useFile() const file = useFile()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
@@ -131,17 +135,31 @@ export function SessionSidePanel(props: {
setActive: tabs().setActive, setActive: tabs().setActive,
}) })
const tabState = createSessionTabs({ const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
tabs, const openedTabs = createMemo(() =>
pathFromTab: file.pathFromTab, tabs()
normalizeTab, .all()
review: reviewTab, .filter((tab) => tab !== "context" && tab !== "review"),
hasReview, )
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
}) })
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const fileTreeTab = () => layout.fileTree.tab() const fileTreeTab = () => layout.fileTree.tab()
@@ -282,7 +300,7 @@ export function SessionSidePanel(props: {
<SortableProvider ids={openedTabs()}> <SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For> <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider> </SortableProvider>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"> <StickyAddButton>
<TooltipKeybind <TooltipKeybind
title={language.t("command.file.open")} title={language.t("command.file.open")}
keybind={command.keybind("file.open")} keybind={command.keybind("file.open")}
@@ -299,7 +317,7 @@ export function SessionSidePanel(props: {
aria-label={language.t("command.file.open")} aria-label={language.t("command.file.open")}
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </StickyAddButton>
</Tabs.List> </Tabs.List>
</div> </div>
@@ -339,10 +357,10 @@ export function SessionSidePanel(props: {
<DragOverlay> <DragOverlay>
<Show when={store.activeDraggable} keyed> <Show when={store.activeDraggable} keyed>
{(tab) => { {(tab) => {
const path = file.pathFromTab(tab) const path = createMemo(() => file.pathFromTab(tab))
return ( return (
<div data-component="tabs-drag-preview"> <div data-component="tabs-drag-preview">
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show> <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
</div> </div>
) )
}} }}
+152 -181
View File
@@ -1,5 +1,7 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { For, Show, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -13,20 +15,25 @@ import { Terminal } from "@/components/terminal"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useTerminal } from "@/context/terminal" import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function TerminalPanel() { export function TerminalPanel() {
const params = useParams()
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const { params, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const panel = createPresence(open)
const size = createSizing() const size = createSizing()
const height = createMemo(() => layout.terminal.height()) const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close() const close = () => view().terminal.close()
@@ -35,25 +42,6 @@ export function TerminalPanel() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
autoCreated: false, autoCreated: false,
activeDraggable: undefined as string | undefined, activeDraggable: undefined as string | undefined,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
const max = () => store.view * 0.6
const pane = () => Math.min(height(), max())
onMount(() => {
if (typeof window === "undefined") return
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
const port = window.visualViewport
sync()
window.addEventListener("resize", sync)
port?.addEventListener("resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
}) })
createEffect(() => { createEffect(() => {
@@ -78,42 +66,21 @@ export function TerminalPanel() {
), ),
) )
const focus = (id: string) => {
focusTerminalById(id)
const frame = requestAnimationFrame(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
})
const timers = [120, 240].map((ms) =>
window.setTimeout(() => {
if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
}, ms),
)
return () => {
cancelAnimationFrame(frame)
for (const timer of timers) clearTimeout(timer)
}
}
createEffect( createEffect(
on( on(
() => [opened(), terminal.active()] as const, () => terminal.active(),
([next, id]) => { (activeId) => {
if (!next || !id) return if (!activeId || !panel.open()) return
const stop = focus(id) if (document.activeElement instanceof HTMLElement) {
onCleanup(stop) document.activeElement.blur()
}
setTimeout(() => focusTerminalById(activeId), 0)
}, },
), ),
) )
createEffect(() => { createEffect(() => {
if (opened()) return if (panel.open()) return
const active = document.activeElement const active = document.activeElement
if (!(active instanceof HTMLElement)) return if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return if (!root?.contains(active)) return
@@ -144,8 +111,9 @@ export function TerminalPanel() {
return getTerminalHandoff(dir) ?? [] return getTerminalHandoff(dir) ?? []
}) })
const all = terminal.all const all = createMemo(() => terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id)) const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
const handleTerminalDragStart = (event: unknown) => { const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event) const id = getDraggableId(event)
@@ -158,8 +126,8 @@ export function TerminalPanel() {
if (!draggable || !droppable) return if (!draggable || !droppable) return
const terminals = terminal.all() const terminals = terminal.all()
const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex) terminal.move(draggable.id.toString(), toIndex)
} }
@@ -170,147 +138,150 @@ export function TerminalPanel() {
const activeId = terminal.active() const activeId = terminal.active()
if (!activeId) return if (!activeId) return
requestAnimationFrame(() => { setTimeout(() => {
if (terminal.active() !== activeId) return
focusTerminalById(activeId) focusTerminalById(activeId)
}) }, 0)
} }
return ( return (
<div <Show when={panel.show()}>
ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
classList={{
"border-t border-border-weak-base": opened(),
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(),
}}
style={{ height: opened() ? `${pane()}px` : "0px" }}
>
<div <div
class="absolute inset-x-0 top-0 flex flex-col" ref={root}
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
aria-hidden={!panel.open()}
inert={!panel.open()}
class="relative w-full shrink-0 overflow-hidden"
classList={{ classList={{
"pointer-events-none": !opened(), "opacity-100": panel.open(),
"opacity-0 pointer-events-none": !panel.open(),
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(),
}} }}
style={{ height: `${pane()}px` }} style={{ height: panel.open() ? `${height()}px` : "0px" }}
> >
<div class="hidden md:block" onPointerDown={() => size.start()}> <div class="size-full flex flex-col border-t border-border-weak-base">
<ResizeHandle <div onPointerDown={() => size.start()}>
direction="vertical" <ResizeHandle
size={pane()} direction="vertical"
min={100} size={height()}
max={max()} min={100}
collapseThreshold={50} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
onResize={(next) => { collapseThreshold={50}
size.touch() onResize={(next) => {
layout.terminal.resize(next) size.touch()
}} layout.terminal.resize(next)
onCollapse={close} }}
/> onCollapse={close}
</div> />
<Show </div>
when={terminal.ready()} <Show
fallback={ when={terminal.ready()}
<div class="flex flex-col h-full pointer-events-none"> fallback={
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden"> <div class="flex flex-col h-full pointer-events-none">
<For each={handoff()}> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
{(title) => ( <For each={handoff()}>
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> {(title) => (
{title} <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
</div> {title}
)} </div>
</For> )}
<div class="flex-1" /> </For>
<div class="text-text-weak pr-2"> <div class="flex-1" />
{language.t("common.loading")} <div class="text-text-weak pr-2">
{language.t("common.loading.ellipsis")} {language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{language.t("terminal.loading")}
</div> </div>
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div> }
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropProvider
<ConstrainDragYAxis /> onDragStart={handleTerminalDragStart}
<div class="flex flex-col h-full"> onDragEnd={handleTerminalDragEnd}
<Tabs onDragOver={handleTerminalDragOver}
variant="alt" collisionDetector={closestCenter}
value={terminal.active()} >
onChange={(id) => terminal.open(id)} <DragDropSensors />
class="!h-auto !flex-none" <ConstrainDragYAxis />
> <div class="flex flex-col h-full">
<Tabs.List class="h-10 border-b border-border-weaker-base"> <Tabs
<SortableProvider ids={ids()}> variant="alt"
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For> value={terminal.active()}
</SortableProvider> onChange={(id) => terminal.open(id)}
<div class="h-full flex items-center justify-center"> class="!h-auto !flex-none"
<TooltipKeybind >
title={language.t("command.terminal.new")} <Tabs.List class="h-10 border-b border-border-weaker-base">
keybind={command.keybind("terminal.new")} <SortableProvider ids={ids()}>
class="flex items-center" <For each={ids()}>
> {(id) => (
<IconButton <Show when={byId().get(id)}>
icon="plus-small" {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
variant="ghost" </Show>
iconSize="large" )}
onClick={terminal.new} </For>
aria-label={language.t("command.terminal.new")} </SortableProvider>
/> <div class="h-full flex items-center justify-center">
</TooltipKeybind> <TooltipKeybind
</div> title={language.t("command.terminal.new")}
</Tabs.List> keybind={command.keybind("terminal.new")}
</Tabs> class="flex items-center"
<div class="flex-1 min-h-0 relative"> >
<Show when={terminal.active()} keyed> <IconButton
{(id) => ( icon="plus-small"
<Show when={all().find((pty) => pty.id === id)}> variant="ghost"
{(pty) => ( iconSize="large"
<div id={`terminal-wrapper-${id}`} class="absolute inset-0"> onClick={terminal.new}
<Terminal aria-label={language.t("command.terminal.new")}
pty={pty()} />
autoFocus={opened()} </TooltipKeybind>
onConnect={() => terminal.trim(id)} </div>
onCleanup={terminal.update} </Tabs.List>
onConnectError={() => terminal.clone(id)} </Tabs>
/> <div class="flex-1 min-h-0 relative">
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>
</div>
)}
</Show>
)}
</Show>
</div>
</div>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedId) => (
<Show when={byId().get(draggedId())}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div> </div>
)} )}
</Show> </Show>
)} )}
</Show> </Show>
</div> </DragOverlay>
</div> </DragDropProvider>
<DragOverlay> </Show>
<Show when={store.activeDraggable} keyed> </div>
{(id) => (
<Show when={all().find((pty) => pty.id === id)}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({
title: t().title,
titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
})}
</div>
)}
</Show>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div> </div>
</div> </Show>
) )
} }
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import { canAddSelectionContext } from "./session-command-helpers"
describe("canAddSelectionContext", () => {
test("returns false without active tab", () => {
expect(
canAddSelectionContext({
active: undefined,
pathFromTab: () => "src/a.ts",
selectedLines: () => ({ start: 1, end: 1 }),
}),
).toBe(false)
})
test("returns false when active tab is not a file", () => {
expect(
canAddSelectionContext({
active: "context",
pathFromTab: () => undefined,
selectedLines: () => ({ start: 1, end: 1 }),
}),
).toBe(false)
})
test("returns false without selected lines", () => {
expect(
canAddSelectionContext({
active: "file://src/a.ts",
pathFromTab: () => "src/a.ts",
selectedLines: () => null,
}),
).toBe(false)
})
test("returns true when file and selection exist", () => {
expect(
canAddSelectionContext({
active: "file://src/a.ts",
pathFromTab: () => "src/a.ts",
selectedLines: () => ({ start: 1, end: 2 }),
}),
).toBe(true)
})
})
@@ -1,7 +1,7 @@
import { useNavigate } from "@solidjs/router" import { createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -17,16 +17,14 @@ import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork" import { DialogFork } from "@/components/dialog-fork"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout" import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void focusInput: () => void
review?: () => boolean
} }
const withCategory = (category: string) => { const withCategory = (category: string) => {
@@ -48,46 +46,23 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const layout = useLayout() const layout = useLayout()
const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout()
const info = () => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const id = params.id const tabs = createMemo(() => layout.tabs(sessionKey))
if (!id) return const view = createMemo(() => layout.view(sessionKey))
return sync.session.get(id) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
}
const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: actions.review,
hasReview,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = () => { const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const id = params.id const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
if (!id) return [] const visibleUserMessages = createMemo(() => {
return sync.data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert) return userMessages().filter((m) => m.id < revert)
} })
const showAllFiles = () => { const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return if (layout.fileTree.tab() !== "changes") return
@@ -97,7 +72,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const selectionPreview = (path: string, selection: FileSelection) => { const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content const content = file.get(path)?.content?.content
if (!content) return undefined if (!content) return undefined
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
} }
const addSelectionToContext = (path: string, selection: FileSelection) => { const addSelectionToContext = (path: string, selection: FileSelection) => {
@@ -105,14 +84,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
prompt.context.add({ type: "file", path, selection, preview }) prompt.context.add({ type: "file", path, selection, preview })
} }
const canAddSelectionContext = () => {
const tab = activeFileTab()
if (!tab) return false
const path = file.pathFromTab(tab)
if (!path) return false
return file.selectedLines(path) != null
}
const navigateMessageByOffset = actions.navigateMessageByOffset const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput const focusInput = actions.focusInput
@@ -127,369 +98,408 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const agentCommand = withCategory(language.t("command.category.agent")) const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions")) const permissionsCommand = withCategory(language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !tabs().active(),
onSelect: () => {
const active = tabs().active()
if (!active) return
tabs().close(active)
},
}),
])
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: tabs().active(),
pathFromTab: file.pathFromTab,
selectedLines: file.selectedLines,
}),
onSelect: () => {
const active = tabs().active()
if (!active) return
const path = file.pathFromTab(active)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
}),
])
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: () => focusInput(),
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
])
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
},
}),
])
const isAutoAcceptActive = () => { const isAutoAcceptActive = () => {
const sessionID = params.id const sessionID = params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory)
} }
command.register("session", () => {
const share =
sync.data.config.share === "disabled"
? []
: [
sessionCommand({
id: "session.share",
title: info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: async () => {
if (!params.id) return
const write = (value: string) => { const permissionCommands = createMemo(() => [
const body = typeof document === "undefined" ? undefined : document.body permissionsCommand({
if (body) { id: "permissions.autoaccept",
const textarea = document.createElement("textarea") title: isAutoAcceptActive()
textarea.value = value ? language.t("command.permissions.autoaccept.disable")
textarea.setAttribute("readonly", "") : language.t("command.permissions.autoaccept.enable"),
textarea.style.position = "fixed" keybind: "mod+shift+a",
textarea.style.opacity = "0" disabled: false,
textarea.style.pointerEvents = "none" onSelect: () => {
body.appendChild(textarea) const sessionID = params.id
textarea.select() if (sessionID) {
const copied = document.execCommand("copy") permission.toggleAutoAccept(sessionID, sdk.directory)
body.removeChild(textarea) } else {
if (copied) return Promise.resolve(true) permission.toggleAutoAcceptDirectory(sdk.directory)
} }
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
])
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard const sessionActionCommands = createMemo(() => [
if (!clipboard?.writeText) return Promise.resolve(false) sessionCommand({
return clipboard.writeText(value).then( id: "session.undo",
() => true, title: language.t("command.session.undo"),
() => false, description: language.t("command.session.undo.description"),
) slash: "undo",
} disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const copy = async (url: string, existing: boolean) => { const sessionID = params.id
const ok = await write(url) if (!sessionID) return
if (!ok) { if (status()?.type !== "idle") {
showToast({ await sdk.client.session.abort({ sessionID }).catch(() => {})
title: language.t("toast.session.share.copyFailed.title"), }
variant: "error", const revert = info()?.revert?.messageID
}) const message = findLast(userMessages(), (x) => !revert || x.id < revert)
return if (!message) return
} await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
showToast({ if (parts) {
title: existing const restored = extractPromptFromParts(parts, { directory: sdk.directory })
? language.t("session.share.copy.copied") prompt.set(restored)
: language.t("toast.session.share.success.title"), }
description: language.t("toast.session.share.success.description"), const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
variant: "success", setActiveMessage(priorMessage)
}) },
} }),
sessionCommand({
const existing = info()?.share?.url id: "session.redo",
if (existing) { title: language.t("command.session.redo"),
await copy(existing, true) description: language.t("command.session.redo.description"),
return slash: "redo",
} disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const url = await sdk.client.session const sessionID = params.id
.share({ sessionID: params.id }) if (!sessionID) return
.then((res) => res.data?.share?.url) const revertMessageID = info()?.revert?.messageID
.catch(() => undefined) if (!revertMessageID) return
if (!url) { const nextMessage = userMessages().find((x) => x.id > revertMessageID)
showToast({ if (!nextMessage) {
title: language.t("toast.session.share.failed.title"), await sdk.client.session.unrevert({ sessionID })
description: language.t("toast.session.share.failed.description"), prompt.reset()
variant: "error", const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
}) setActiveMessage(lastMsg)
return return
} }
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
await copy(url, false) const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
}, setActiveMessage(priorMsg)
}), },
sessionCommand({ }),
id: "session.unshare", sessionCommand({
title: language.t("command.session.unshare"), id: "session.compact",
description: language.t("command.session.unshare.description"), title: language.t("command.session.compact"),
slash: "unshare", description: language.t("command.session.compact.description"),
disabled: !params.id || !info()?.share?.url, slash: "compact",
onSelect: async () => { disabled: !params.id || visibleUserMessages().length === 0,
if (!params.id) return onSelect: async () => {
await sdk.client.session const sessionID = params.id
.unshare({ sessionID: params.id }) if (!sessionID) return
.then(() => const model = local.model.current()
showToast({ if (!model) {
title: language.t("toast.session.unshare.success.title"), showToast({
description: language.t("toast.session.unshare.success.description"), title: language.t("toast.model.none.title"),
variant: "success", description: language.t("toast.model.none.description"),
}), })
) return
.catch(() => }
showToast({ await sdk.client.session.summarize({
title: language.t("toast.session.unshare.failed.title"), sessionID,
description: language.t("toast.session.unshare.failed.description"), modelID: model.id,
variant: "error", providerID: model.provider.id,
}), })
) },
}, }),
}), sessionCommand({
] id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
if (sync.data.config.share === "disabled") return []
return [ return [
sessionCommand({ sessionCommand({
id: "session.new", id: "session.share",
title: language.t("command.session.new"), title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
keybind: "mod+shift+s", description: info()?.share?.url
slash: "new", ? language.t("toast.session.share.success.description")
onSelect: () => navigate(`/${params.dir}/session`), : language.t("command.session.share.description"),
}), slash: "share",
fileCommand({ disabled: !params.id,
id: "file.open", onSelect: async () => {
title: language.t("command.file.open"), if (!params.id) return
description: language.t("palette.search.placeholder"),
keybind: "mod+p", const write = (value: string) => {
slash: "open", const body = typeof document === "undefined" ? undefined : document.body
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), if (body) {
}), const textarea = document.createElement("textarea")
fileCommand({ textarea.value = value
id: "tab.close", textarea.setAttribute("readonly", "")
title: language.t("command.tab.close"), textarea.style.position = "fixed"
keybind: "mod+w", textarea.style.opacity = "0"
disabled: !closableTab(), textarea.style.pointerEvents = "none"
onSelect: () => { body.appendChild(textarea)
const tab = closableTab() textarea.select()
if (!tab) return const copied = document.execCommand("copy")
tabs().close(tab) body.removeChild(textarea)
}, if (copied) return Promise.resolve(true)
}), }
contextCommand({
id: "context.addSelection", const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
title: language.t("command.context.addSelection"), if (!clipboard?.writeText) return Promise.resolve(false)
description: language.t("command.context.addSelection.description"), return clipboard.writeText(value).then(
keybind: "mod+shift+l", () => true,
disabled: !canAddSelectionContext(), () => false,
onSelect: () => { )
const tab = activeFileTab() }
if (!tab) return
const path = file.pathFromTab(tab) const copy = async (url: string, existing: boolean) => {
if (!path) return const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({ showToast({
title: language.t("toast.context.noLineSelection.title"), title: existing
description: language.t("toast.context.noLineSelection.description"), ? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
}) })
return return
} }
addSelectionToContext(path, selectionFromLines(range)) await copy(url, false)
},
}),
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
}, },
}), }),
sessionCommand({ sessionCommand({
id: "message.previous", id: "session.unshare",
title: language.t("command.message.previous"), title: language.t("command.session.unshare"),
description: language.t("command.message.previous.description"), description: language.t("command.session.unshare.description"),
keybind: "mod+arrowup", slash: "unshare",
disabled: !params.id, disabled: !params.id || !info()?.share?.url,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
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)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
const sessionID = params.id if (!params.id) return
if (!sessionID) return await sdk.client.session
if (status().type !== "idle") { .unshare({ sessionID: params.id })
await sdk.client.session.abort({ sessionID }).catch(() => {}) .then(() =>
} showToast({
const revert = info()?.revert?.messageID title: language.t("toast.session.unshare.success.title"),
const message = findLast(userMessages(), (x) => !revert || x.id < revert) description: language.t("toast.session.unshare.success.description"),
if (!message) return variant: "success",
await sdk.client.session.revert({ sessionID, messageID: message.id }) }),
const parts = sync.data.part[message.id] )
if (parts) { .catch(() =>
const restored = extractPromptFromParts(parts, { directory: sdk.directory }) showToast({
prompt.set(restored) title: language.t("toast.session.unshare.failed.title"),
} description: language.t("toast.session.unshare.failed.description"),
const priorMessage = findLast(userMessages(), (x) => x.id < message.id) variant: "error",
setActiveMessage(priorMessage) }),
)
}, },
}), }),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
}),
...share,
] ]
}) })
command.register("session", () =>
[
sessionCommands(),
fileCommands(),
contextCommands(),
viewCommands(),
messageCommands(),
agentCommands(),
permissionCommands(),
sessionActionCommands(),
shareCommands(),
].flatMap((x) => x),
)
} }
@@ -3,6 +3,8 @@ import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash" import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
export const useSessionHashScroll = (input: { export const useSessionHashScroll = (input: {
sessionKey: () => string sessionKey: () => string
sessionID: () => string | undefined sessionID: () => string | undefined
-64
View File
@@ -1,64 +0,0 @@
export const terminalAttr = "data-pty-id"
export type TerminalProbeState = {
connected: boolean
rendered: string
settled: number
}
export type E2EWindow = Window & {
__opencode_e2e?: {
terminal?: {
enabled?: boolean
terminals?: Record<string, TerminalProbeState>
}
}
}
const seed = (): TerminalProbeState => ({
connected: false,
rendered: "",
settled: 0,
})
const root = () => {
if (typeof window === "undefined") return
const state = (window as E2EWindow).__opencode_e2e?.terminal
if (!state?.enabled) return
state.terminals ??= {}
return state.terminals
}
export const terminalProbe = (id: string) => {
const set = (next: Partial<TerminalProbeState>) => {
const terms = root()
if (!terms) return
terms[id] = { ...(terms[id] ?? seed()), ...next }
}
return {
init() {
set(seed())
},
connect() {
set({ connected: true })
},
render(data: string) {
const terms = root()
if (!terms) return
const prev = terms[id] ?? seed()
terms[id] = { ...prev, rendered: prev.rendered + data }
},
settle() {
const terms = root()
if (!terms) return
const prev = terms[id] ?? seed()
terms[id] = { ...prev, settled: prev.settled + 1 }
},
drop() {
const terms = root()
if (!terms) return
delete terms[id]
},
}
}
+51
View File
@@ -0,0 +1,51 @@
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
const r = document.createRange()
r.selectNodeContents(lineElement)
r.setEnd(targetNode, offset)
return r.toString().length
}
export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
let remaining = Math.max(0, charIndex)
let lastText: Node | null = null
let lastLen = 0
let node: Node | null
while ((node = walker.nextNode())) {
const len = node.textContent?.length || 0
lastText = node
lastLen = len
if (remaining <= len) return { node, offset: remaining }
remaining -= len
}
if (lastText) return { node: lastText, offset: lastLen }
if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
return null
}
export function getSelectionInContainer(
container: HTMLElement,
): { sl: number; sch: number; el: number; ech: number } | null {
const s = window.getSelection()
if (!s || s.rangeCount === 0) return null
const r = s.getRangeAt(0)
const sc = r.startContainer
const ec = r.endContainer
const getLineElement = (n: Node) =>
(n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
const sle = getLineElement(sc)
const ele = getLineElement(ec)
if (!sle || !ele) return null
if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
const cc = container.querySelector("code") as HTMLElement | null
if (!cc) return null
const lines = Array.from(cc.querySelectorAll(".line"))
const sli = lines.indexOf(sle as Element)
const eli = lines.indexOf(ele as Element)
if (sli === -1 || eli === -1) return null
const sl = sli + 1
const el = eli + 1
const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
return { sl, sch, el, ech }
}
+1
View File
@@ -0,0 +1 @@
export * from "./dom"
-8
View File
@@ -1,4 +1,3 @@
import { usePlatform } from "@/context/platform"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server" import { createSdkForServer } from "./server"
@@ -82,10 +81,3 @@ export async function checkServerHealth(
.catch((error) => next(count, error)) .catch((error) => next(count, error))
return attempt(0).finally(() => timeout?.clear?.()) return attempt(0).finally(() => timeout?.clear?.())
} }
export function useCheckServerHealth() {
const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
}
+326
View File
@@ -0,0 +1,326 @@
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters"
// Minimal types to avoid relying on non-standard DOM typings
type RecognitionResult = {
0: { transcript: string }
isFinal: boolean
}
type RecognitionEvent = {
results: RecognitionResult[]
resultIndex: number
}
interface Recognition {
continuous: boolean
interimResults: boolean
lang: string
start: () => void
stop: () => void
onresult: ((e: RecognitionEvent) => void) | null
onerror: ((e: { error: string }) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
}
const COMMIT_DELAY = 250
const appendSegment = (base: string, addition: string) => {
const trimmed = addition.trim()
if (!trimmed) return base
if (!base) return trimmed
const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
return `${base}${needsSpace ? " " : ""}${trimmed}`
}
const extractSuffix = (committed: string, hypothesis: string) => {
const cleanHypothesis = hypothesis.trim()
if (!cleanHypothesis) return ""
const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
const hypothesisTokens = cleanHypothesis.split(/\s+/)
let index = 0
while (
index < baseTokens.length &&
index < hypothesisTokens.length &&
baseTokens[index] === hypothesisTokens[index]
) {
index += 1
}
if (index < baseTokens.length) return ""
return hypothesisTokens.slice(index).join(" ")
}
export function createSpeechRecognition(opts?: {
lang?: string
onFinal?: (text: string) => void
onInterim?: (text: string) => void
}) {
const ctor = getSpeechRecognitionCtor<Recognition>(typeof window === "undefined" ? undefined : window)
const hasSupport = Boolean(ctor)
const [store, setStore] = createStore({
isRecording: false,
committed: "",
interim: "",
})
const isRecording = () => store.isRecording
const committed = () => store.committed
const interim = () => store.interim
let recognition: Recognition | undefined
let shouldContinue = false
let committedText = ""
let sessionCommitted = ""
let pendingHypothesis = ""
let lastInterimSuffix = ""
let shrinkCandidate: string | undefined
let commitTimer: number | undefined
let restartTimer: number | undefined
const cancelPendingCommit = () => {
if (commitTimer === undefined) return
clearTimeout(commitTimer)
commitTimer = undefined
}
const clearRestart = () => {
if (restartTimer === undefined) return
window.clearTimeout(restartTimer)
restartTimer = undefined
}
const scheduleRestart = () => {
clearRestart()
if (!shouldContinue) return
if (!recognition) return
restartTimer = window.setTimeout(() => {
restartTimer = undefined
if (!shouldContinue) return
if (!recognition) return
try {
recognition.start()
} catch {}
}, 150)
}
const commitSegment = (segment: string) => {
const nextCommitted = appendSegment(committedText, segment)
if (nextCommitted === committedText) return
committedText = nextCommitted
setStore("committed", committedText)
if (opts?.onFinal) opts.onFinal(segment.trim())
}
const promotePending = () => {
if (!pendingHypothesis) return
const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
if (!suffix) {
pendingHypothesis = ""
return
}
sessionCommitted = appendSegment(sessionCommitted, suffix)
commitSegment(suffix)
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
}
const applyInterim = (suffix: string, hypothesis: string) => {
cancelPendingCommit()
pendingHypothesis = hypothesis
lastInterimSuffix = suffix
shrinkCandidate = undefined
setStore("interim", suffix)
if (opts?.onInterim) {
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
}
if (!suffix) return
const snapshot = hypothesis
commitTimer = window.setTimeout(() => {
if (pendingHypothesis !== snapshot) return
const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
if (!currentSuffix) return
sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
commitSegment(currentSuffix)
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
}, COMMIT_DELAY)
}
if (ctor) {
recognition = new ctor()
recognition.continuous = false
recognition.interimResults = true
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
recognition.onresult = (event: RecognitionEvent) => {
if (!event.results.length) return
let aggregatedFinal = ""
let latestHypothesis = ""
for (let i = 0; i < event.results.length; i += 1) {
const result = event.results[i]
const transcript = (result[0]?.transcript || "").trim()
if (!transcript) continue
if (result.isFinal) {
aggregatedFinal = appendSegment(aggregatedFinal, transcript)
} else {
latestHypothesis = transcript
}
}
if (aggregatedFinal) {
cancelPendingCommit()
const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
if (finalSuffix) {
sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
commitSegment(finalSuffix)
}
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
return
}
cancelPendingCommit()
if (!latestHypothesis) {
shrinkCandidate = undefined
applyInterim("", "")
return
}
const suffix = extractSuffix(sessionCommitted, latestHypothesis)
if (!suffix) {
if (!lastInterimSuffix) {
shrinkCandidate = undefined
applyInterim("", latestHypothesis)
return
}
if (shrinkCandidate === "") {
applyInterim("", latestHypothesis)
return
}
shrinkCandidate = ""
pendingHypothesis = latestHypothesis
return
}
if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
if (shrinkCandidate === suffix) {
applyInterim(suffix, latestHypothesis)
return
}
shrinkCandidate = suffix
pendingHypothesis = latestHypothesis
return
}
shrinkCandidate = undefined
applyInterim(suffix, latestHypothesis)
}
recognition.onerror = (e: { error: string }) => {
clearRestart()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
if (e.error === "no-speech" && shouldContinue) {
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
scheduleRestart()
return
}
shouldContinue = false
setStore("isRecording", false)
}
recognition.onstart = () => {
clearRestart()
sessionCommitted = ""
pendingHypothesis = ""
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
setStore("isRecording", true)
}
recognition.onend = () => {
clearRestart()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("isRecording", false)
if (shouldContinue) {
scheduleRestart()
}
}
}
const start = () => {
if (!recognition) return
clearRestart()
shouldContinue = true
sessionCommitted = ""
pendingHypothesis = ""
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
try {
recognition.start()
} catch {}
}
const stop = () => {
if (!recognition) return
shouldContinue = false
clearRestart()
promotePending()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
try {
recognition.stop()
} catch {}
}
onCleanup(() => {
shouldContinue = false
clearRestart()
promotePending()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
try {
recognition?.stop()
} catch {}
})
return {
isSupported: () => hasSupport,
isRecording,
committed,
interim,
start,
stop,
}
}

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