Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c80e2662c |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
plans/
|
plans/
|
||||||
bun.lock
|
bun.lock
|
||||||
package.json
|
package.json
|
||||||
package-lock.json
|
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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,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,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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dom"
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user