mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-12 17:43:56 +00:00
Compare commits
23 Commits
perf/sessi
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce7eceb28 | ||
|
|
0e077f7483 | ||
|
|
776e7a9c15 | ||
|
|
c455d41876 | ||
|
|
a776a3ee12 | ||
|
|
64fb9233bf | ||
|
|
3533f33ecb | ||
|
|
1cb7df7159 | ||
|
|
a4f8d66a9b | ||
|
|
12efbbfa4c | ||
|
|
13402529ce | ||
|
|
fc678ef36c | ||
|
|
03cd891ea9 | ||
|
|
6314d741e7 | ||
|
|
c45467964c | ||
|
|
2eeba53b07 | ||
|
|
d4107d51f1 | ||
|
|
d8fbe0af01 | ||
|
|
b76ead3fe8 | ||
|
|
51835ecf90 | ||
|
|
328c6de80d | ||
|
|
c9c0318e0e | ||
|
|
d481f64bde |
14
bun.lock
14
bun.lock
@@ -46,6 +46,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
@@ -226,6 +227,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
@@ -612,7 +614,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -2736,7 +2738,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="],
|
||||
"effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -5224,6 +5226,10 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
@@ -6122,6 +6128,10 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||
|
||||
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -70,6 +70,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
- `openSettings(page)` - Open settings dialog
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `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
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||
@@ -167,6 +169,13 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||
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
|
||||
|
||||
1. Choose appropriate folder or create new one
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
@@ -28,6 +30,53 @@ export async function defocus(page: Page) {
|
||||
.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) {
|
||||
await defocus(page)
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
@@ -91,6 +92,14 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
},
|
||||
}
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -13,7 +14,7 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
await prompt.fill("/terminal")
|
||||
await expect(slash).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
|
||||
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||
// which can steal focus from the prompt and prevent fill() from triggering
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await expect(terminals.first()).toBeVisible()
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// 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(terminals).toHaveCount(1)
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { runTerminal, waitTerminalReady } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
@@ -17,16 +18,7 @@ async function open(page: Page) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const visible = await terminal.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
}
|
||||
|
||||
async function run(page: Page, cmd: string) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).toBeVisible()
|
||||
await terminal.click()
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
}
|
||||
|
||||
async function store(page: Page, key: string) {
|
||||
@@ -56,15 +48,16 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await run(page, `echo ${one}`)
|
||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
await run(page, `echo ${two}`)
|
||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -76,7 +69,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
@@ -93,7 +86,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
}
|
||||
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
})
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import "@/index.css"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { type Duration, Effect } from "effect"
|
||||
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 { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -62,6 +77,9 @@ declare global {
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +133,11 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
<Font />
|
||||
<ThemeProvider>
|
||||
<ThemeProvider
|
||||
onThemeApplied={(_, mode) => {
|
||||
void window.api?.setTitlebar?.({ mode })
|
||||
}}
|
||||
>
|
||||
<LanguageProvider>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
@@ -132,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
const effectMinDuration =
|
||||
(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 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 (
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
@@ -149,7 +264,7 @@ export function AppInterface(props: {
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerKey>
|
||||
<ConnectionGate>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
@@ -164,7 +279,7 @@ export function AppInterface(props: {
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,28 +382,28 @@ export function DebugBar() {
|
||||
dim={state.fps === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="FRM"
|
||||
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="JNK"
|
||||
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="LNG"
|
||||
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="DLY"
|
||||
label="DELAY"
|
||||
tip="Worst observed input delay in the last 5 seconds."
|
||||
value={time(state.delay)}
|
||||
bad={bad(state.delay, 100)}
|
||||
|
||||
159
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
159
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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: {} })
|
||||
82
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
82
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
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 = {
|
||||
back?: "providers" | "close"
|
||||
}
|
||||
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
|
||||
name: "",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
models: [{ id: "", name: "" }],
|
||||
headers: [{ key: "", value: "" }],
|
||||
models: [modelRow()],
|
||||
headers: [headerRow()],
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = createStore<FormErrors>({
|
||||
providerID: undefined,
|
||||
name: undefined,
|
||||
baseURL: undefined,
|
||||
models: [{}],
|
||||
headers: [{}],
|
||||
err: {},
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||
setErrors("models", (v) => [...v, {}])
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.push(modelRow())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeModel = (index: number) => {
|
||||
if (form.models.length <= 1) return
|
||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
setForm(
|
||||
"models",
|
||||
produce((rows) => {
|
||||
rows.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const addHeader = () => {
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
setForm(
|
||||
"headers",
|
||||
produce((rows) => {
|
||||
rows.push(headerRow())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
if (form.headers.length <= 1) return
|
||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setForm(
|
||||
"headers",
|
||||
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 = () => {
|
||||
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
})
|
||||
setErrors(output.errors)
|
||||
batch(() => {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setForm("providerID", v)}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setForm("name", v)}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setForm("baseURL", v)}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm("apiKey", v)}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setForm("models", i(), "id", v)}
|
||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setForm("models", i(), "name", v)}
|
||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -382,16 +272,16 @@ export function DialogCustomProvider(props: Props) {
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
|
||||
@@ -132,9 +134,14 @@ function createFileEntries(props: {
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
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 all = props.tabs().all()
|
||||
const active = props.tabs().active()
|
||||
const all = tabState.openedTabs()
|
||||
const active = tabState.activeFileTab()
|
||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const category = props.language.t("palette.group.files")
|
||||
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
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 [grouped, setGrouped] = createSignal(false)
|
||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||
|
||||
@@ -14,7 +14,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -41,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||
const [defaultUrl, defaultUrlActions] = createResource(
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const url = await platform.getDefaultServerUrl?.()
|
||||
if (!url) return null
|
||||
return normalizeServerUrl(url) ?? null
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
@@ -56,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||
const setDefault = async (url: string | null) => {
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServerUrl?.(url)
|
||||
defaultUrlActions.mutate(url)
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultUrl, canDefault, setDefault }
|
||||
return { defaultKey, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview(fetcher: typeof fetch) {
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
@@ -92,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http, fetcher)
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
@@ -170,15 +176,15 @@ export function DialogSelectServer() {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
@@ -201,7 +207,7 @@ export function DialogSelectServer() {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
@@ -264,7 +270,7 @@ export function DialogSelectServer() {
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
items().map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -362,9 +368,9 @@ export function DialogSelectServer() {
|
||||
http: { url: normalized },
|
||||
}
|
||||
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
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("addServer", { adding: false })
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
@@ -404,7 +410,7 @@ export function DialogSelectServer() {
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
const result = await checkServerHealth(conn.http)
|
||||
setStore("editServer", { busy: false })
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
@@ -441,7 +447,7 @@ export function DialogSelectServer() {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
@@ -494,8 +500,8 @@ export function DialogSelectServer() {
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
platform.setDefaultServerUrl?.(null)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
platform.setDefaultServer?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +557,7 @@ export function DialogSelectServer() {
|
||||
status={store.status[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -584,14 +590,14 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||
<Show when={canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||
<Show when={canDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -37,6 +36,8 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useLanguage } from "@/context/language"
|
||||
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 { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||
import {
|
||||
@@ -102,13 +103,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const params = useParams()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
let fileInputRef: HTMLInputElement | undefined
|
||||
let scrollRef!: HTMLDivElement
|
||||
@@ -154,9 +155,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
requestAnimationFrame(scrollCursorIntoView)
|
||||
}
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = params.id
|
||||
@@ -209,7 +212,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = tabs().active()
|
||||
const active = activeFileTab()
|
||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||
const seen = new Set<string>()
|
||||
const paths: string[] = []
|
||||
@@ -255,6 +258,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
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(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -1275,11 +1287,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={buttons()}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1317,11 +1325,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1379,10 +1383,7 @@ 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"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
...shell(),
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
@@ -1402,13 +1403,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={control()}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1426,13 +1421,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={control()}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1461,13 +1450,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
style: control(),
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1499,13 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={control()}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
@@ -27,14 +29,17 @@ function openSessionContext(args: {
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const params = useParams()
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
|
||||
const usd = createMemo(
|
||||
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const openContext = () => {
|
||||
if (!params.id) return
|
||||
|
||||
if (tabs().active() === "context") {
|
||||
if (tabState.activeTab() === "context") {
|
||||
tabs().close("context")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const layout = useLayout()
|
||||
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 messages = createMemo(
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
@@ -23,6 +22,7 @@ import { useServer } from "@/context/server"
|
||||
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 { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
@@ -225,13 +225,13 @@ function useSessionShare(args: {
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const command = useCommand()
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const terminal = useTerminal()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const project = createMemo(() => {
|
||||
@@ -249,8 +249,6 @@ export function SessionHeader() {
|
||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
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 [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
@@ -282,10 +280,7 @@ export function SessionHeader() {
|
||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||
.then((value) => Boolean(value))
|
||||
.catch(() => false)
|
||||
.then((ok) => {
|
||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||
return [app.id, ok] as const
|
||||
}),
|
||||
.then((ok) => [app.id, ok] as const),
|
||||
),
|
||||
).then((entries) => {
|
||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||
|
||||
@@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
|
||||
|
||||
interface NewSessionViewProps {
|
||||
worktree: string
|
||||
onWorktreeChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||
const terminal = useTerminal()
|
||||
@@ -53,21 +54,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
|
||||
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 }))
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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 { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
@@ -53,7 +53,8 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
@@ -168,7 +169,6 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
@@ -176,10 +176,10 @@ export function StatusPopover() {
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, fetcher)
|
||||
const health = useServerHealth(servers)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const mcp = useMcpToggle({ sync, sdk, language })
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
|
||||
@@ -160,6 +161,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const probe = terminalProbe(id)
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
@@ -326,6 +328,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
probe.init()
|
||||
cleanups.push(() => probe.drop())
|
||||
|
||||
const run = async () => {
|
||||
const loaded = await loadGhostty()
|
||||
if (disposed) return
|
||||
@@ -353,7 +358,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
ghostty = g
|
||||
term = t
|
||||
output = terminalWriter((data, done) => t.write(data, done))
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
probe.render(data)
|
||||
probe.settle()
|
||||
done?.()
|
||||
}),
|
||||
)
|
||||
|
||||
t.attachCustomKeyEventHandler((event) => {
|
||||
const key = event.key.toLowerCase()
|
||||
@@ -441,10 +452,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
startResize()
|
||||
}
|
||||
|
||||
// t.onScroll((ydisp) => {
|
||||
// console.log("Scroll position:", ydisp)
|
||||
// })
|
||||
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
@@ -452,7 +459,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.username = server.current?.http.username ?? "opencode"
|
||||
url.password = server.current?.http.password ?? ""
|
||||
|
||||
const socket = new WebSocket(url)
|
||||
@@ -460,6 +467,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
ws = socket
|
||||
|
||||
const handleOpen = () => {
|
||||
probe.connect()
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
@@ -560,6 +568,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
<div
|
||||
ref={container}
|
||||
data-component="terminal"
|
||||
{...{ [terminalAttr]: id }}
|
||||
data-prevent-autofocus
|
||||
tabIndex={-1}
|
||||
style={{ "background-color": terminalColors().background }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
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" />
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
{!tauriApi() && <div class="w-36 shrink-0" />}
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
pickDirectoriesToEvict,
|
||||
} from "./global-sync"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
@@ -161,6 +162,7 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -402,6 +404,3 @@ export function useGlobalSync() {
|
||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||
return context
|
||||
}
|
||||
|
||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
85
packages/app/src/context/global-sync/session-prefetch.ts
Normal file
85
packages/app/src/context/global-sync/session-prefetch.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
const settings = useSettings()
|
||||
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
||||
|
||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||
const [range, setRange] = createStore({
|
||||
from: undefined as string | undefined,
|
||||
to: undefined as string | undefined,
|
||||
})
|
||||
const state = { started: false }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
||||
|
||||
if (previous === platform.version) return
|
||||
|
||||
setFrom(previous)
|
||||
setTo(platform.version)
|
||||
setRange({ from: previous, to: platform.version })
|
||||
start(previous)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
from,
|
||||
to,
|
||||
from: () => range.from,
|
||||
to: () => range.to,
|
||||
get last() {
|
||||
return store.version
|
||||
},
|
||||
|
||||
@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
review: {
|
||||
open: createMemo(() => s().reviewOpen),
|
||||
open: createMemo(() => s().reviewOpen ?? []),
|
||||
setOpen(open: string[]) {
|
||||
const session = key()
|
||||
const next = Array.from(new Set(open))
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: next,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, next)) return
|
||||
setStore("sessionView", session, "reviewOpen", next)
|
||||
},
|
||||
openPath(path: string) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, {
|
||||
scroll: {},
|
||||
reviewOpen: open,
|
||||
reviewOpen: [path],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (same(current.reviewOpen, open)) return
|
||||
setStore("sessionView", session, "reviewOpen", open)
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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,6 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { ServerConnection } from "./server"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -58,10 +59,10 @@ export type Platform = {
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServerUrl?(): Promise<string | null>
|
||||
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { checkServerHealth } from "@/utils/server-health"
|
||||
import { useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
@@ -96,7 +95,7 @@ export namespace ServerConnection {
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||
const platform = usePlatform()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
||||
|
||||
createEffect(() => {
|
||||
const current_ = current()
|
||||
|
||||
@@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -160,6 +166,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
@@ -217,6 +224,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.complete)
|
||||
setSessionPrefetch({
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit,
|
||||
complete: next.complete,
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -280,54 +293,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
||||
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 limit = meta.limit[key] ?? messagePageSize
|
||||
return runInflight(inflight, key, async () => {
|
||||
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 sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: 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 hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const messagesReq = loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: 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 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) {
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
@@ -337,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string) {
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
@@ -348,7 +389,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
return
|
||||
if (!opts?.force) return
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
|
||||
@@ -98,6 +98,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
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 = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
@@ -106,26 +119,20 @@ const platform: Platform = {
|
||||
forward,
|
||||
restart,
|
||||
notify,
|
||||
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
||||
setDefaultServerUrl: writeDefaultServerUrl,
|
||||
getDefaultServer: async () => {
|
||||
const stored = readDefaultServerUrl()
|
||||
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) {
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
||||
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
|
||||
export function useProviders() {
|
||||
const globalSync = useGlobalSync()
|
||||
const params = useParams()
|
||||
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = createMemo(() => {
|
||||
if (currentDirectory()) {
|
||||
const [projectStore] = globalSync.child(currentDirectory())
|
||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
return projectStore.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 {
|
||||
all: createMemo(() => providers().all),
|
||||
default: createMemo(() => providers().default),
|
||||
popular,
|
||||
connected,
|
||||
paid,
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
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)),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
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 { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
@@ -51,7 +60,7 @@ import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
@@ -62,7 +71,6 @@ import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
getDraggableId,
|
||||
latestRootSession,
|
||||
sortedRootSessions,
|
||||
workspaceKey,
|
||||
@@ -80,7 +88,6 @@ import {
|
||||
WorkspaceDragOverlay,
|
||||
type WorkspaceSidebarContext,
|
||||
} from "./layout/sidebar-workspace"
|
||||
import { workspaceOpenState } from "./layout/sidebar-workspace-helpers"
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
|
||||
@@ -664,8 +671,9 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const prefetchChunk = 200
|
||||
const prefetchConcurrency = 1
|
||||
const prefetchPendingLimit = 6
|
||||
const prefetchConcurrency = 2
|
||||
const prefetchPendingLimit = 10
|
||||
const span = 4
|
||||
const prefetchToken = { value: 0 }
|
||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||
|
||||
@@ -690,14 +698,30 @@ 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(() => {
|
||||
params.dir
|
||||
globalSDK.url
|
||||
|
||||
prefetchToken.value += 1
|
||||
for (const q of prefetchQueues.values()) {
|
||||
clearSessionPrefetchInflight()
|
||||
prefetchQueues.clear()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const visible = new Set(visibleSessionDirs())
|
||||
for (const [directory, q] of prefetchQueues) {
|
||||
if (visible.has(directory)) continue
|
||||
q.pending.length = 0
|
||||
q.pendingSet.clear()
|
||||
if (q.running === 0) prefetchQueues.delete(directory)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -733,36 +757,67 @@ export default function Layout(props: ParentProps) {
|
||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
|
||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!lruFor(directory).has(sessionID)) return
|
||||
return runSessionPrefetch({
|
||||
directory,
|
||||
sessionID,
|
||||
task: (rev) =>
|
||||
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 next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
const sorted = mergeByID([], next)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
const sorted = mergeByID([], next)
|
||||
const stale = markPrefetched(directory, sessionID)
|
||||
const meta = {
|
||||
limit: prefetchChunk,
|
||||
complete: sorted.length < prefetchChunk,
|
||||
at: Date.now(),
|
||||
}
|
||||
|
||||
const current = store.message[sessionID] ?? []
|
||||
const merged = mergeByID(
|
||||
current.filter((item): item is Message => !!item?.id),
|
||||
sorted,
|
||||
)
|
||||
if (stale.length > 0) {
|
||||
clearSessionPrefetch(directory, stale)
|
||||
for (const id of stale) {
|
||||
globalSync.todo.set(id, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||
|
||||
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),
|
||||
const current = store.message[sessionID] ?? []
|
||||
const merged = mergeByID(
|
||||
current.filter((item): item is Message => !!item?.id),
|
||||
sorted,
|
||||
)
|
||||
|
||||
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||
|
||||
batch(() => {
|
||||
if (stale.length > 0) {
|
||||
setStore(
|
||||
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) => {
|
||||
@@ -790,28 +845,29 @@ export default function Layout(props: ParentProps) {
|
||||
if (!directory) return
|
||||
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const cached = untrack(() => store.message[session.id] !== undefined)
|
||||
const cached = untrack(() => {
|
||||
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
|
||||
|
||||
const q = queueFor(directory)
|
||||
if (q.inflight.has(session.id)) return
|
||||
if (q.pendingSet.has(session.id)) return
|
||||
if (q.pendingSet.has(session.id)) {
|
||||
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 known = lru.has(session.id)
|
||||
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.push(session.id)
|
||||
@@ -826,27 +882,29 @@ export default function Layout(props: ParentProps) {
|
||||
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(() => {
|
||||
const sessions = currentSessions()
|
||||
const id = params.id
|
||||
if (sessions.length === 0) return
|
||||
|
||||
if (!id) {
|
||||
const first = sessions[0]
|
||||
if (first) prefetchSession(first)
|
||||
|
||||
const second = sessions[1]
|
||||
if (second) prefetchSession(second)
|
||||
return
|
||||
}
|
||||
|
||||
const index = sessions.findIndex((s) => s.id === id)
|
||||
const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
|
||||
if (index === -1) return
|
||||
|
||||
const next = sessions[index + 1]
|
||||
if (next) prefetchSession(next)
|
||||
if (!params.id) {
|
||||
const first = sessions[index]
|
||||
if (first) prefetchSession(first, "high")
|
||||
}
|
||||
|
||||
const prev = sessions[index - 1]
|
||||
if (prev) prefetchSession(prev)
|
||||
warm(sessions, index)
|
||||
})
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
@@ -865,18 +923,8 @@ export default function Layout(props: ParentProps) {
|
||||
const session = sessions[targetIndex]
|
||||
if (!session) return
|
||||
|
||||
const next = sessions[(targetIndex + 1) % sessions.length]
|
||||
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)
|
||||
}
|
||||
prefetchSession(session, "high")
|
||||
warm(sessions, targetIndex)
|
||||
|
||||
navigateToSession(session)
|
||||
}
|
||||
@@ -898,19 +946,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (notification.session.unseenCount(session.id) === 0) continue
|
||||
|
||||
prefetchSession(session, "high")
|
||||
|
||||
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)
|
||||
}
|
||||
warm(sessions, index)
|
||||
|
||||
navigateToSession(session)
|
||||
return
|
||||
@@ -1844,6 +1880,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const workspaceSidebarCtx: WorkspaceSidebarContext = {
|
||||
currentDir,
|
||||
navList: currentSessions,
|
||||
sidebarExpanded,
|
||||
sidebarHovering,
|
||||
nav: () => state.nav,
|
||||
@@ -1860,7 +1897,7 @@ export default function Layout(props: ParentProps) {
|
||||
setEditor,
|
||||
InlineEditor,
|
||||
isBusy,
|
||||
workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local),
|
||||
workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
|
||||
setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
|
||||
showResetWorkspaceDialog: (root, directory) =>
|
||||
dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
|
||||
@@ -1889,6 +1926,7 @@ export default function Layout(props: ParentProps) {
|
||||
workspaceIds,
|
||||
workspaceLabel,
|
||||
sessionProps: {
|
||||
navList: currentSessions,
|
||||
sidebarExpanded,
|
||||
sidebarHovering,
|
||||
nav: () => state.nav,
|
||||
@@ -1904,6 +1942,7 @@ export default function Layout(props: ParentProps) {
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? 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 project = panelProps.project
|
||||
if (!project) return ""
|
||||
@@ -2047,6 +2086,7 @@ export default function Layout(props: ParentProps) {
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
popover={popover()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -2082,6 +2122,7 @@ export default function Layout(props: ParentProps) {
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
popover={popover()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -2135,6 +2176,41 @@ 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 (
|
||||
<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">
|
||||
<Titlebar />
|
||||
@@ -2163,38 +2239,7 @@ export default function Layout(props: ParentProps) {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
<SidebarContent
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||
)}
|
||||
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={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
@@ -2241,33 +2286,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
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 />}
|
||||
/>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { hasProjectPermissions, latestRootSession } from "./helpers"
|
||||
import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
hasProjectPermissions,
|
||||
latestRootSession,
|
||||
workspaceKey,
|
||||
} from "./helpers"
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
@@ -110,7 +116,7 @@ describe("layout workspace helpers", () => {
|
||||
})
|
||||
|
||||
test("keeps local first while preserving known order", () => {
|
||||
const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
||||
const result = effectiveWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
|
||||
expect(result).toEqual(["/root", "/c", "/b"])
|
||||
})
|
||||
|
||||
@@ -192,12 +198,6 @@ describe("layout workspace helpers", () => {
|
||||
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", () => {
|
||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||
|
||||
@@ -8,7 +8,7 @@ export const workspaceKey = (directory: string) => {
|
||||
return directory.replace(/[\\/]+$/, "")
|
||||
}
|
||||
|
||||
export function sortSessions(now: number) {
|
||||
function sortSessions(now: number) {
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
return (a: Session, b: Session) => {
|
||||
const aUpdated = a.time.updated ?? a.time.created
|
||||
@@ -22,7 +22,7 @@ export function sortSessions(now: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
||||
|
||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
||||
@@ -54,14 +54,6 @@ export const childMapByParent = (sessions: Session[]) => {
|
||||
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 }) =>
|
||||
project.name || getFilename(project.worktree)
|
||||
|
||||
@@ -98,5 +90,3 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
|
||||
|
||||
return [...result, ...live.values()]
|
||||
}
|
||||
|
||||
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
||||
|
||||
@@ -10,6 +10,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
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 { useLanguage } from "@/context/language"
|
||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
@@ -67,6 +68,8 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
||||
|
||||
export type SessionItemProps = {
|
||||
session: Session
|
||||
list: Session[]
|
||||
navList?: Accessor<Session[]>
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
@@ -95,18 +98,18 @@ const SessionRow = (props: {
|
||||
setHoverSession: (id: string | undefined) => void
|
||||
clearHoverProjectSoon: () => void
|
||||
sidebarOpened: Accessor<boolean>
|
||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||
scheduleHoverPrefetch: () => void
|
||||
warmHover: () => void
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
cancelHoverPrefetch: () => void
|
||||
}): JSX.Element => (
|
||||
<A
|
||||
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"}`}
|
||||
onPointerEnter={props.scheduleHoverPrefetch}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
onMouseEnter={props.scheduleHoverPrefetch}
|
||||
onMouseLeave={props.cancelHoverPrefetch}
|
||||
onFocus={() => props.prefetchSession(props.session, "high")}
|
||||
onFocus={props.warmFocus}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (props.sidebarOpened()) return
|
||||
@@ -225,11 +228,37 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const hoverMessages = createMemo(() =>
|
||||
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
||||
)
|
||||
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
||||
const hoverReady = createMemo(() => {
|
||||
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 hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||
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 = {
|
||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
}
|
||||
@@ -239,11 +268,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
hoverPrefetch.current = undefined
|
||||
}
|
||||
const scheduleHoverPrefetch = () => {
|
||||
warm(1, "high")
|
||||
if (hoverPrefetch.current !== undefined) return
|
||||
hoverPrefetch.current = setTimeout(() => {
|
||||
hoverPrefetch.current = undefined
|
||||
props.prefetchSession(props.session)
|
||||
}, 200)
|
||||
warm(2, "low")
|
||||
}, 80)
|
||||
}
|
||||
|
||||
onCleanup(cancelHoverPrefetch)
|
||||
@@ -267,8 +297,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
setHoverSession={props.setHoverSession}
|
||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||
sidebarOpened={layout.sidebar.opened}
|
||||
prefetchSession={props.prefetchSession}
|
||||
scheduleHoverPrefetch={scheduleHoverPrefetch}
|
||||
warmHover={scheduleHoverPrefetch}
|
||||
warmPress={() => warm(2, "high")}
|
||||
warmFocus={() => warm(2, "high")}
|
||||
cancelHoverPrefetch={cancelHoverPrefetch}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
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,7 +12,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
|
||||
|
||||
export type ProjectSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
@@ -31,7 +30,7 @@ export type ProjectSidebarContext = {
|
||||
workspacesEnabled: (project: LocalProject) => boolean
|
||||
workspaceIds: (project: LocalProject) => string[]
|
||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||
sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
|
||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
|
||||
setHoverSession: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
@@ -205,11 +204,12 @@ const ProjectPreviewPanel = (props: {
|
||||
<Show
|
||||
when={props.workspaceEnabled()}
|
||||
fallback={
|
||||
<For each={props.projectSessions()}>
|
||||
<For each={props.projectSessions().slice(0, 2)}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
{...props.ctx.sessionProps}
|
||||
session={session}
|
||||
list={props.projectSessions()}
|
||||
slug={base64Encode(props.project.worktree)}
|
||||
dense
|
||||
mobile={props.mobile}
|
||||
@@ -232,11 +232,12 @@ const ProjectPreviewPanel = (props: {
|
||||
</div>
|
||||
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
||||
</div>
|
||||
<For each={sessions()}>
|
||||
<For each={sessions().slice(0, 2)}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
{...props.ctx.sessionProps}
|
||||
session={session}
|
||||
list={sessions()}
|
||||
slug={base64Encode(directory)}
|
||||
dense
|
||||
mobile={props.mobile}
|
||||
@@ -277,8 +278,10 @@ export const SortableProject = (props: {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const selected = createMemo(() =>
|
||||
projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
|
||||
const selected = createMemo(
|
||||
() =>
|
||||
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 workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
@@ -291,15 +294,8 @@ export const SortableProject = (props: {
|
||||
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(() =>
|
||||
projectTileActive({
|
||||
menu: state.menu,
|
||||
preview: preview(),
|
||||
open: state.open,
|
||||
overlay: overlay(),
|
||||
hoverProject: props.ctx.hoverProject(),
|
||||
worktree: props.project.worktree,
|
||||
}),
|
||||
const active = createMemo(
|
||||
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -323,11 +319,11 @@ export const SortableProject = (props: {
|
||||
}
|
||||
|
||||
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2))
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
return sortedRootSessions(data, props.sortNow()).slice(0, 2)
|
||||
return sortedRootSessions(data, props.sortNow())
|
||||
}
|
||||
const workspaceChildren = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened
|
||||
@@ -1,13 +0,0 @@
|
||||
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,7 +11,6 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { sidebarExpanded } from "./sidebar-shell-helpers"
|
||||
|
||||
export const SidebarContent = (props: {
|
||||
mobile?: boolean
|
||||
@@ -33,7 +32,7 @@ export const SidebarContent = (props: {
|
||||
onOpenHelp: () => void
|
||||
renderPanel: () => JSX.Element
|
||||
}): JSX.Element => {
|
||||
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
||||
const expanded = createMemo(() => !!props.mobile || props.opened())
|
||||
const placement = () => (props.mobile ? "bottom" : "right")
|
||||
let panel: HTMLDivElement | undefined
|
||||
|
||||
@@ -116,7 +115,7 @@ export const SidebarContent = (props: {
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
|
||||
expanded[directory] ?? local
|
||||
@@ -1,13 +0,0 @@
|
||||
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,6 +32,7 @@ type InlineEditorComponent = (props: {
|
||||
|
||||
export type WorkspaceSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
navList: Accessor<Session[]>
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
nav: Accessor<HTMLElement | undefined>
|
||||
@@ -144,8 +145,6 @@ const WorkspaceActions = (props: {
|
||||
setMenuOpen: (open: boolean) => void
|
||||
setPendingRename: (value: boolean) => void
|
||||
sidebarHovering: Accessor<boolean>
|
||||
mobile?: boolean
|
||||
nav: Accessor<HTMLElement | undefined>
|
||||
touch: Accessor<boolean>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
workspaceValue: Accessor<string>
|
||||
@@ -240,6 +239,7 @@ const WorkspaceActions = (props: {
|
||||
const WorkspaceSessionList = (props: {
|
||||
slug: Accessor<string>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
ctx: WorkspaceSidebarContext
|
||||
showNew: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
@@ -266,8 +266,11 @@ const WorkspaceSessionList = (props: {
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
session={session}
|
||||
list={props.sessions()}
|
||||
navList={props.ctx.navList}
|
||||
slug={props.slug()}
|
||||
mobile={props.mobile}
|
||||
popover={props.popover}
|
||||
children={props.children()}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
@@ -304,6 +307,7 @@ export const SortableWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
@@ -340,6 +344,22 @@ export const SortableWorkspace = (props: {
|
||||
}
|
||||
|
||||
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) => {
|
||||
props.ctx.setWorkspaceExpanded(props.directory, value)
|
||||
@@ -379,20 +399,7 @@ export const SortableWorkspace = (props: {
|
||||
data-action="workspace-toggle"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
@@ -401,20 +408,7 @@ export const SortableWorkspace = (props: {
|
||||
menu.open ? "pr-16" : "pr-2"
|
||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
{header()}
|
||||
</div>
|
||||
</Show>
|
||||
<WorkspaceActions
|
||||
@@ -426,8 +420,6 @@ export const SortableWorkspace = (props: {
|
||||
setMenuOpen={(open) => setMenu("open", open)}
|
||||
setPendingRename={(value) => setMenu("pendingRename", value)}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
mobile={props.mobile}
|
||||
nav={props.ctx.nav}
|
||||
touch={touch}
|
||||
language={language}
|
||||
workspaceValue={workspaceValue}
|
||||
@@ -447,6 +439,7 @@ export const SortableWorkspace = (props: {
|
||||
<WorkspaceSessionList
|
||||
slug={slug}
|
||||
mobile={props.mobile}
|
||||
popover={props.popover}
|
||||
ctx={props.ctx}
|
||||
showNew={showNew}
|
||||
loading={loading}
|
||||
@@ -467,6 +460,7 @@ export const LocalWorkspace = (props: {
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
@@ -490,44 +484,19 @@ export const LocalWorkspace = (props: {
|
||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-3">
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
<For each={sessions()}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
session={session}
|
||||
slug={slug()}
|
||||
mobile={props.mobile}
|
||||
children={children()}
|
||||
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>
|
||||
<WorkspaceSessionList
|
||||
slug={slug}
|
||||
mobile={props.mobile}
|
||||
popover={props.popover}
|
||||
ctx={props.ctx}
|
||||
showNew={() => false}
|
||||
loading={loading}
|
||||
sessions={sessions}
|
||||
children={children}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,13 +19,16 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -34,11 +37,11 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
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 { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
@@ -264,13 +267,13 @@ export default function Page() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
createEffect(() => {
|
||||
if (!untrack(() => prompt.ready())) return
|
||||
@@ -298,11 +301,8 @@ export default function Page() {
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const workspaceKey = createMemo(() => params.dir ?? "")
|
||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -373,18 +373,22 @@ export default function Page() {
|
||||
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 diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
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 messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const messagesReady = createMemo(() => {
|
||||
@@ -421,6 +425,14 @@ export default function Page() {
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
|
||||
createEffect(() => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) file.load(path)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => lastUserMessage()?.id,
|
||||
@@ -438,7 +450,6 @@ export default function Page() {
|
||||
(next, prev) => {
|
||||
if (!prev) 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)
|
||||
},
|
||||
{ defer: true },
|
||||
@@ -465,6 +476,10 @@ export default function Page() {
|
||||
}, sessionKey())
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let refreshFrame: number | undefined
|
||||
let refreshTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -624,10 +639,36 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
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
|
||||
|
||||
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(() => {
|
||||
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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -670,11 +711,7 @@ export default function Page() {
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
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")
|
||||
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
@@ -781,15 +818,7 @@ 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 reviewTab = createMemo(() => isDesktop())
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -825,6 +854,7 @@ export default function Page() {
|
||||
navigateMessageByOffset,
|
||||
setActiveMessage,
|
||||
focusInput,
|
||||
review: reviewTab,
|
||||
})
|
||||
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
@@ -939,11 +969,10 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => tabs().active(),
|
||||
activeFileTab,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
if (fileTreeTab() !== "changes") return
|
||||
if (!file.pathFromTab(active)) return
|
||||
showAllFiles()
|
||||
},
|
||||
{ defer: true },
|
||||
@@ -986,8 +1015,7 @@ export default function Page() {
|
||||
|
||||
const focusReviewDiff = (path: string) => {
|
||||
openReviewPanel()
|
||||
const current = view().review.open() ?? []
|
||||
if (!current.includes(path)) view().review.setOpen([...current, path])
|
||||
view().review.openPath(path)
|
||||
setTree({ activeDiff: path, pendingDiff: path })
|
||||
}
|
||||
|
||||
@@ -1032,29 +1060,6 @@ export default function Page() {
|
||||
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(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
@@ -1069,6 +1074,39 @@ export default function Page() {
|
||||
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
|
||||
createEffect(() => {
|
||||
const dir = sdk.directory
|
||||
@@ -1088,9 +1126,9 @@ export default function Page() {
|
||||
() => {
|
||||
void file.tree.list("")
|
||||
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
const path = file.pathFromTab(active)
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
void file.load(path, { force: true })
|
||||
},
|
||||
@@ -1331,6 +1369,10 @@ export default function Page() {
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -1338,14 +1380,30 @@ export default function Page() {
|
||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
<SessionMobileTabs
|
||||
open={!isDesktop() && !!params.id}
|
||||
mobileTab={store.mobileTab}
|
||||
hasReview={hasReview()}
|
||||
reviewCount={reviewCount()}
|
||||
onSession={() => setStore("mobileTab", "session")}
|
||||
onChanges={() => setStore("mobileTab", "changes")}
|
||||
/>
|
||||
<Show when={!isDesktop() && !!params.id}>
|
||||
<Tabs value={store.mobileTab} class="h-auto">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger
|
||||
value="session"
|
||||
class="!w-1/2 !max-w-none"
|
||||
classes={{ button: "w-full" }}
|
||||
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 */}
|
||||
<div
|
||||
@@ -1405,23 +1463,7 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<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`)
|
||||
}}
|
||||
/>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||
export type { SessionComposerState } from "./session-composer-state"
|
||||
export { createSessionComposerState } from "./session-composer-state"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
done: boolean
|
||||
live: boolean
|
||||
}): "hide" | "clear" | "open" | "close" => {
|
||||
if (input.count === 0) return "hide"
|
||||
if (!input.live) return "clear"
|
||||
if (!input.done) return "open"
|
||||
return "close"
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
@@ -27,29 +27,11 @@ export function SessionComposerRegion(props: {
|
||||
onRestore: (id: string) => 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 language = useLanguage()
|
||||
const { sessionKey } = useSessionKey()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
||||
|
||||
const previewPrompt = () =>
|
||||
@@ -69,8 +51,10 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [gate, setGate] = createStore({
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
@@ -92,13 +76,13 @@ export function SessionComposerRegion(props: {
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setGate("ready", false)
|
||||
setStore("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setGate("ready", true)
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
@@ -106,32 +90,19 @@ export function SessionComposerRegion(props: {
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||
const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
|
||||
const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
|
||||
const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
|
||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
@@ -206,26 +177,13 @@ export function SessionComposerRegion(props: {
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef}>
|
||||
<div ref={(el) => setStore("body", el)}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoState } from "./session-composer-helpers"
|
||||
import { todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
|
||||
@@ -8,30 +8,21 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { todoState } from "./session-composer-helpers"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerBlocked() {
|
||||
const params = useParams()
|
||||
const permission = usePermission()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const permissionRequest = createMemo(() =>
|
||||
sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk.directory)
|
||||
}),
|
||||
)
|
||||
const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
|
||||
|
||||
return createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
done: boolean
|
||||
live: boolean
|
||||
}): "hide" | "clear" | "open" | "close" => {
|
||||
if (input.count === 0) return "hide"
|
||||
if (!input.live) return "clear"
|
||||
if (!input.done) return "open"
|
||||
return "close"
|
||||
}
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
@@ -39,23 +39,11 @@ export function SessionTodoDock(props: {
|
||||
title: string
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
expandVisualDuration?: number
|
||||
expandBounce?: number
|
||||
collapseVisualDuration?: number
|
||||
collapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
dockProgress: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
height: 320,
|
||||
})
|
||||
|
||||
const toggle = () => setStore("collapsed", (value) => !value)
|
||||
@@ -73,33 +61,21 @@ export function SessionTodoDock(props: {
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const config = createMemo(() =>
|
||||
store.collapsed
|
||||
? {
|
||||
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
setStore("height", el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
@@ -133,10 +109,10 @@ export function SessionTodoDock(props: {
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
"--tool-motion-odometer-ms": "600ms",
|
||||
"--tool-motion-mask": "18%",
|
||||
"--tool-motion-mask-height": "0px",
|
||||
"--tool-motion-spring-ms": "560ms",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
}}
|
||||
>
|
||||
@@ -156,9 +132,9 @@ export function SessionTodoDock(props: {
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={props.subtitleDuration ?? 600}
|
||||
travel={props.subtitleTravel ?? 25}
|
||||
edge={props.subtitleEdge ?? 17}
|
||||
duration={600}
|
||||
travel={25}
|
||||
edge={17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
@@ -205,8 +181,10 @@ export function SessionTodoDock(props: {
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
const [scrolling, setScrolling] = createSignal(false)
|
||||
const [store, setStore] = createStore({
|
||||
stuck: false,
|
||||
scrolling: false,
|
||||
})
|
||||
let scrollRef!: HTMLDivElement
|
||||
let timer: number | undefined
|
||||
|
||||
@@ -214,7 +192,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
|
||||
const ensure = () => {
|
||||
if (!props.open) return
|
||||
if (scrolling()) return
|
||||
if (store.scrolling) return
|
||||
if (!scrollRef || scrollRef.offsetParent === null) return
|
||||
|
||||
const el = scrollRef.querySelector("[data-in-progress]")
|
||||
@@ -235,7 +213,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
|
||||
}
|
||||
|
||||
setStuck(scrollRef.scrollTop > 0)
|
||||
setStore("stuck", scrollRef.scrollTop > 0)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
@@ -257,11 +235,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
ref={scrollRef}
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
onScroll={(e) => {
|
||||
setStuck(e.currentTarget.scrollTop > 0)
|
||||
setScrolling(true)
|
||||
setStore("stuck", e.currentTarget.scrollTop > 0)
|
||||
setStore("scrolling", true)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setScrolling(false)
|
||||
setStore("scrolling", false)
|
||||
if (inProgress() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}, 250)
|
||||
@@ -306,7 +284,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"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||
opacity: stuck() ? 1 : 0,
|
||||
opacity: store.stuck ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import type { FileSearchHandle } from "@opencode-ai/ui/file"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
@@ -12,12 +11,13 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
function FileCommentMenu(props: {
|
||||
moreLabel: string
|
||||
@@ -53,17 +53,17 @@ function FileCommentMenu(props: {
|
||||
}
|
||||
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const prompt = usePrompt()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
@@ -234,7 +234,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (tabs().active() !== props.tab) return
|
||||
if (activeFileTab() !== props.tab) return
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "f") return
|
||||
|
||||
@@ -262,7 +262,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const p = path()
|
||||
if (!focus || !p) return
|
||||
if (focus.file !== p) return
|
||||
if (tabs().active() !== props.tab) return
|
||||
if (activeFileTab() !== props.tab) return
|
||||
|
||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||
if (!target) return
|
||||
@@ -382,7 +382,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
createEffect(() => {
|
||||
const loaded = !!state()?.loaded
|
||||
const ready = file.ready()
|
||||
const active = tabs().active() === props.tab
|
||||
const active = activeFileTab() === props.tab
|
||||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||
prev = { loaded, ready, active }
|
||||
if (!restore) return
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
createOpenReviewFile,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
focusTerminalById,
|
||||
getTabReorderIndex,
|
||||
} from "./helpers"
|
||||
|
||||
describe("createOpenReviewFile", () => {
|
||||
test("opens and loads selected review file", () => {
|
||||
@@ -87,3 +95,66 @@ describe("getTabReorderIndex", () => {
|
||||
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,5 +1,77 @@
|
||||
import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
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) => {
|
||||
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
|
||||
@@ -117,57 +189,3 @@ export const 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 { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -19,6 +19,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -213,16 +214,15 @@ export function MessageTimeline(props: {
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
|
||||
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
|
||||
@@ -37,14 +37,6 @@ 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) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let restoreFrame: number | undefined
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
}
|
||||
20
packages/app/src/pages/session/session-layout.ts
Normal file
20
packages/app/src/pages/session/session-layout.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
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 { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
@@ -23,9 +22,9 @@ import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
import { StickyAddButton } from "@/pages/session/review-tab"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
reviewPanel: () => JSX.Element
|
||||
@@ -34,18 +33,15 @@ export function SessionSidePanel(props: {
|
||||
reviewSnap: boolean
|
||||
size: Sizing
|
||||
}) {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
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 fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
@@ -135,31 +131,17 @@ export function SessionSidePanel(props: {
|
||||
setActive: tabs().setActive,
|
||||
})
|
||||
|
||||
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
||||
const openedTabs = createMemo(() =>
|
||||
tabs()
|
||||
.all()
|
||||
.filter((tab) => tab !== "context" && tab !== "review"),
|
||||
)
|
||||
|
||||
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 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 fileTreeTab = () => layout.fileTree.tab()
|
||||
|
||||
@@ -300,7 +282,7 @@ export function SessionSidePanel(props: {
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||
</SortableProvider>
|
||||
<StickyAddButton>
|
||||
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
@@ -317,7 +299,7 @@ export function SessionSidePanel(props: {
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</StickyAddButton>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
@@ -357,10 +339,10 @@ export function SessionSidePanel(props: {
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
const path = file.pathFromTab(tab)
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -14,20 +13,18 @@ import { Terminal } from "@/components/terminal"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function TerminalPanel() {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
@@ -44,7 +41,7 @@ export function TerminalPanel() {
|
||||
const max = () => store.view * 0.6
|
||||
const pane = () => Math.min(height(), max())
|
||||
|
||||
createEffect(() => {
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
|
||||
@@ -147,9 +144,8 @@ export function TerminalPanel() {
|
||||
return getTerminalHandoff(dir) ?? []
|
||||
})
|
||||
|
||||
const all = createMemo(() => terminal.all())
|
||||
const all = terminal.all
|
||||
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
|
||||
|
||||
const handleTerminalDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
@@ -162,8 +158,8 @@ export function TerminalPanel() {
|
||||
if (!draggable || !droppable) return
|
||||
|
||||
const terminals = terminal.all()
|
||||
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
||||
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
||||
const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
|
||||
const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
|
||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||
terminal.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
@@ -199,10 +195,7 @@ export function TerminalPanel() {
|
||||
<div
|
||||
class="absolute inset-x-0 top-0 flex flex-col"
|
||||
classList={{
|
||||
"translate-y-0": opened(),
|
||||
"translate-y-full pointer-events-none": !opened(),
|
||||
"transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
|
||||
!size.active(),
|
||||
"pointer-events-none": !opened(),
|
||||
}}
|
||||
style={{ height: `${pane()}px` }}
|
||||
>
|
||||
@@ -259,13 +252,7 @@ export function TerminalPanel() {
|
||||
>
|
||||
<Tabs.List class="h-10 border-b border-border-weaker-base">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
@@ -287,7 +274,7 @@ export function TerminalPanel() {
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
<Show when={all().find((pty) => pty.id === id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
@@ -305,9 +292,9 @@ export function TerminalPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(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({
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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 { createMemo } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
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 { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -17,14 +17,16 @@ import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
import { DialogFork } from "@/components/dialog-fork"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
focusInput: () => void
|
||||
review?: () => boolean
|
||||
}
|
||||
|
||||
const withCategory = (category: string) => {
|
||||
@@ -46,23 +48,46 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const info = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
}
|
||||
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 status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||
const messages = () => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return sync.data.message[id] ?? []
|
||||
}
|
||||
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
|
||||
const visibleUserMessages = () => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
}
|
||||
|
||||
const showAllFiles = () => {
|
||||
if (layout.fileTree.tab() !== "changes") return
|
||||
@@ -72,11 +97,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
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")
|
||||
return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
@@ -84,6 +105,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
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 setActiveMessage = actions.setActiveMessage
|
||||
const focusInput = actions.focusInput
|
||||
@@ -98,408 +127,369 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
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 sessionID = params.id
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, 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 permissionCommands = createMemo(() => [
|
||||
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"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
])
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return Promise.resolve(true)
|
||||
}
|
||||
|
||||
const sessionActionCommands = createMemo(() => [
|
||||
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 () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status()?.type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
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 />),
|
||||
}),
|
||||
])
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
const copy = async (url: string, existing: boolean) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? 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
|
||||
}
|
||||
|
||||
await copy(url, false)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const shareCommands = createMemo(() => {
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
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 body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return Promise.resolve(true)
|
||||
}
|
||||
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
const copy = async (url: string, existing: boolean) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
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: !closableTab(),
|
||||
onSelect: () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
},
|
||||
}),
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: existing
|
||||
? 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",
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await copy(url, false)
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
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({
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
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),
|
||||
}),
|
||||
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 () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
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,8 +3,6 @@ import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { messageIdFromHash } from "./message-id-from-hash"
|
||||
|
||||
export { messageIdFromHash } from "./message-id-from-hash"
|
||||
|
||||
export const useSessionHashScroll = (input: {
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
|
||||
64
packages/app/src/testing/terminal.ts
Normal file
64
packages/app/src/testing/terminal.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
|
||||
const r = document.createRange()
|
||||
r.selectNodeContents(lineElement)
|
||||
r.setEnd(targetNode, offset)
|
||||
return r.toString().length
|
||||
}
|
||||
|
||||
export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
|
||||
const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
|
||||
let remaining = Math.max(0, charIndex)
|
||||
let lastText: Node | null = null
|
||||
let lastLen = 0
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) {
|
||||
const len = node.textContent?.length || 0
|
||||
lastText = node
|
||||
lastLen = len
|
||||
if (remaining <= len) return { node, offset: remaining }
|
||||
remaining -= len
|
||||
}
|
||||
if (lastText) return { node: lastText, offset: lastLen }
|
||||
if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
|
||||
return null
|
||||
}
|
||||
|
||||
export function getSelectionInContainer(
|
||||
container: HTMLElement,
|
||||
): { sl: number; sch: number; el: number; ech: number } | null {
|
||||
const s = window.getSelection()
|
||||
if (!s || s.rangeCount === 0) return null
|
||||
const r = s.getRangeAt(0)
|
||||
const sc = r.startContainer
|
||||
const ec = r.endContainer
|
||||
const getLineElement = (n: Node) =>
|
||||
(n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
|
||||
const sle = getLineElement(sc)
|
||||
const ele = getLineElement(ec)
|
||||
if (!sle || !ele) return null
|
||||
if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
|
||||
const cc = container.querySelector("code") as HTMLElement | null
|
||||
if (!cc) return null
|
||||
const lines = Array.from(cc.querySelectorAll(".line"))
|
||||
const sli = lines.indexOf(sle as Element)
|
||||
const eli = lines.indexOf(ele as Element)
|
||||
if (sli === -1 || eli === -1) return null
|
||||
const sl = sli + 1
|
||||
const el = eli + 1
|
||||
const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
|
||||
const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
|
||||
return { sl, sch, el, ech }
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./dom"
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { createSdkForServer } from "./server"
|
||||
|
||||
@@ -81,3 +82,10 @@ export async function checkServerHealth(
|
||||
.catch((error) => next(count, error))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
|
||||
@@ -31,35 +31,13 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import {
|
||||
checkHealth,
|
||||
checkHealthOrAskRetry,
|
||||
getDefaultServerUrl,
|
||||
getSavedServerUrl,
|
||||
getWslConfig,
|
||||
setDefaultServerUrl,
|
||||
setWslConfig,
|
||||
spawnLocalServer,
|
||||
} from "./server"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
|
||||
|
||||
type ServerConnection =
|
||||
| { variant: "existing"; url: string }
|
||||
| {
|
||||
variant: "cli"
|
||||
url: string
|
||||
password: null | string
|
||||
health: {
|
||||
wait: Promise<void>
|
||||
}
|
||||
events: any
|
||||
}
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
const loadingWindow: BrowserWindow | null = null
|
||||
let sidecar: CommandChild | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
@@ -131,77 +109,48 @@ function setInitStep(step: InitStep) {
|
||||
initEmitter.emit("step", step)
|
||||
}
|
||||
|
||||
async function setupServerConnection(): Promise<ServerConnection> {
|
||||
const customUrl = await getSavedServerUrl()
|
||||
|
||||
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
|
||||
serverReady.resolve({ url: customUrl, password: null })
|
||||
return { variant: "existing", url: customUrl }
|
||||
}
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const hostname = "127.0.0.1"
|
||||
const localUrl = `http://${hostname}:${port}`
|
||||
|
||||
if (await checkHealth(localUrl)) {
|
||||
serverReady.resolve({ url: localUrl, password: null })
|
||||
return { variant: "existing", url: localUrl }
|
||||
}
|
||||
|
||||
const password = randomUUID()
|
||||
const { child, health, events } = spawnLocalServer(hostname, port, password)
|
||||
sidecar = child
|
||||
|
||||
return {
|
||||
variant: "cli",
|
||||
url: localUrl,
|
||||
password,
|
||||
health,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { child, health, events } = spawnLocalServer(hostname, port, password)
|
||||
sidecar = child
|
||||
serverReady.resolve({
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
const loadingTask = (async () => {
|
||||
logger.log("setting up server connection")
|
||||
const serverConnection = await setupServerConnection()
|
||||
logger.log("server connection ready", {
|
||||
variant: serverConnection.variant,
|
||||
url: serverConnection.url,
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
events.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
|
||||
const cliHealthCheck = (() => {
|
||||
if (serverConnection.variant == "cli") {
|
||||
return async () => {
|
||||
const { events, health } = serverConnection
|
||||
events.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
await health.wait
|
||||
serverReady.resolve({
|
||||
url: serverConnection.url,
|
||||
password: serverConnection.password,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
serverReady.resolve({ url: serverConnection.url, password: null })
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
logger.log("server connection started")
|
||||
|
||||
if (cliHealthCheck) {
|
||||
if (needsMigration) await sqliteDone?.promise
|
||||
cliHealthCheck?.()
|
||||
if (needsMigration) {
|
||||
await sqliteDone?.promise
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
health.wait,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error("Sidecar health check timed out")
|
||||
}),
|
||||
]).catch((error) => {
|
||||
logger.error("sidecar health check failed", error)
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
})()
|
||||
|
||||
@@ -211,32 +160,26 @@ async function initialize() {
|
||||
deepLinks: pendingDeepLinks,
|
||||
}
|
||||
|
||||
const loadingWindow = await (async () => {
|
||||
if (needsMigration /** TOOD: 1 second timeout */) {
|
||||
// showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
|
||||
const loadingWindow = createLoadingWindow(globals)
|
||||
await delay(1000)
|
||||
return loadingWindow
|
||||
} else {
|
||||
logger.log("showing main window without loading window")
|
||||
mainWindow = createMainWindow(globals)
|
||||
wireMenu()
|
||||
wireMenu()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
overlay = createLoadingWindow(globals)
|
||||
await delay(1_000)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
await loadingTask
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (loadingWindow) {
|
||||
if (overlay) {
|
||||
await loadingComplete.promise
|
||||
}
|
||||
|
||||
if (!mainWindow) {
|
||||
mainWindow = createMainWindow(globals)
|
||||
wireMenu()
|
||||
}
|
||||
mainWindow = createMainWindow(globals)
|
||||
|
||||
loadingWindow?.close()
|
||||
overlay?.close()
|
||||
}
|
||||
|
||||
function wireMenu() {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { execFile } from "node:child_process"
|
||||
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
|
||||
import { getStore } from "./store"
|
||||
import { setTitlebar } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
@@ -161,6 +162,11 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
|
||||
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
||||
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
|
||||
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
setTitlebar(win, theme)
|
||||
})
|
||||
}
|
||||
|
||||
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { dialog } from "electron"
|
||||
|
||||
import { getConfig, serve, type CommandChild, type Config } from "./cli"
|
||||
import { serve, type CommandChild } from "./cli"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { store } from "./store"
|
||||
|
||||
@@ -31,15 +29,6 @@ export function setWslConfig(config: WslConfig) {
|
||||
store.set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export async function getSavedServerUrl(): Promise<string | null> {
|
||||
const direct = getDefaultServerUrl()
|
||||
if (direct) return direct
|
||||
|
||||
const config = await getConfig().catch(() => null)
|
||||
if (!config) return null
|
||||
return getServerUrlFromConfig(config)
|
||||
}
|
||||
|
||||
export function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
const { child, exit, events } = serve(hostname, port, password)
|
||||
|
||||
@@ -94,36 +83,4 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
|
||||
while (true) {
|
||||
if (await checkHealth(url)) return true
|
||||
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
|
||||
title: "Connection Failed",
|
||||
buttons: ["Retry", "Start Local"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
|
||||
if (result.response === 0) continue
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHostnameForUrl(hostname: string) {
|
||||
if (hostname === "0.0.0.0") return "127.0.0.1"
|
||||
if (hostname === "::") return "[::1]"
|
||||
if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
|
||||
return hostname
|
||||
}
|
||||
|
||||
export function getServerUrlFromConfig(config: Config) {
|
||||
const server = config.server
|
||||
if (!server?.port) return null
|
||||
const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
|
||||
return `http://${host}:${server.port}`
|
||||
}
|
||||
|
||||
export type { CommandChild }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { app, BrowserWindow, nativeImage } from "electron"
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
|
||||
type Globals = {
|
||||
updaterEnabled: boolean
|
||||
@@ -20,6 +21,24 @@ function iconPath() {
|
||||
return join(iconsDir(), `icon.${ext}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: 40,
|
||||
}
|
||||
}
|
||||
|
||||
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
|
||||
if (process.platform !== "win32") return
|
||||
win.setTitleBarOverlay(overlay(theme))
|
||||
}
|
||||
|
||||
export function setDockIcon() {
|
||||
if (process.platform !== "darwin") return
|
||||
app.dock?.setIcon(nativeImage.createFromPath(join(iconsDir(), "128x128@2x.png")))
|
||||
@@ -31,6 +50,7 @@ export function createMainWindow(globals: Globals) {
|
||||
defaultHeight: 800,
|
||||
})
|
||||
|
||||
const mode = tone()
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
@@ -49,11 +69,7 @@ export function createMainWindow(globals: Globals) {
|
||||
? {
|
||||
frame: false,
|
||||
titleBarStyle: "hidden" as const,
|
||||
titleBarOverlay: {
|
||||
color: "transparent",
|
||||
symbolColor: "#999",
|
||||
height: 40,
|
||||
},
|
||||
titleBarOverlay: overlay({ mode }),
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
@@ -71,6 +87,7 @@ export function createMainWindow(globals: Globals) {
|
||||
}
|
||||
|
||||
export function createLoadingWindow(globals: Globals) {
|
||||
const mode = tone()
|
||||
const win = new BrowserWindow({
|
||||
width: 640,
|
||||
height: 480,
|
||||
@@ -83,11 +100,7 @@ export function createLoadingWindow(globals: Globals) {
|
||||
? {
|
||||
frame: false,
|
||||
titleBarStyle: "hidden" as const,
|
||||
titleBarOverlay: {
|
||||
color: "transparent",
|
||||
symbolColor: "#999",
|
||||
height: 40,
|
||||
},
|
||||
titleBarOverlay: overlay({ mode }),
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
|
||||
@@ -57,6 +57,7 @@ const api: ElectronAPI = {
|
||||
relaunch: () => ipcRenderer.send("relaunch"),
|
||||
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
|
||||
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
|
||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
|
||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||
|
||||
@@ -2,6 +2,7 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" }
|
||||
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
username: string | null
|
||||
password: string | null
|
||||
}
|
||||
|
||||
@@ -10,6 +11,9 @@ export type SqliteMigrationProgress = { type: "InProgress"; value: number } | {
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type LinuxDisplayBackend = "wayland" | "auto"
|
||||
export type TitlebarTheme = {
|
||||
mode: "light" | "dark"
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
killSidecar: () => Promise<void>
|
||||
@@ -57,6 +61,7 @@ export type ElectronAPI = {
|
||||
relaunch: () => void
|
||||
getZoomFactor: () => Promise<number>
|
||||
setZoomFactor: (factor: number) => Promise<void>
|
||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||
loadingWindowComplete: () => void
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
} from "@opencode-ai/app"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import pkg from "../../package.json"
|
||||
@@ -19,7 +18,6 @@ import { initI18n, t } from "./i18n"
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -198,11 +196,13 @@ const createPlatform = (): Platform => {
|
||||
await window.api.setWslConfig({ enabled })
|
||||
},
|
||||
|
||||
getDefaultServerUrl: async () => {
|
||||
return window.api.getDefaultServerUrl().catch(() => null)
|
||||
getDefaultServer: async () => {
|
||||
const url = await window.api.getDefaultServerUrl().catch(() => null)
|
||||
if (!url) return null
|
||||
return ServerConnection.Key.make(url)
|
||||
},
|
||||
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
setDefaultServer: async (url: string | null) => {
|
||||
await window.api.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
@@ -240,6 +240,31 @@ listenForDeepLinks()
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
|
||||
// Fetch sidecar credentials (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||
|
||||
const [defaultServer] = createResource(() =>
|
||||
platform.getDefaultServer?.().then((url) => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
|
||||
const servers = () => {
|
||||
const data = sidecar()
|
||||
if (!data) return []
|
||||
const server: ServerConnection.Sidecar = {
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
},
|
||||
}
|
||||
return [server] as ServerConnection.Any[]
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
@@ -248,6 +273,12 @@ render(() => {
|
||||
}
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
return null
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClick)
|
||||
onCleanup(() => {
|
||||
@@ -258,55 +289,20 @@ render(() => {
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<ServerGate>
|
||||
{(data) => {
|
||||
const server: ServerConnection.Sidecar = {
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data().url,
|
||||
username: "opencode",
|
||||
password: data().password ?? undefined,
|
||||
},
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} router={MemoryRouter}>
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
}}
|
||||
</ServerGate>
|
||||
</Show>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root!)
|
||||
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
|
||||
const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||
console.log({ serverData })
|
||||
if (serverData.state === "errored") throw serverData.error
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from "solid-js/web"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { render } from "solid-js/web"
|
||||
import "@opencode-ai/app/index.css"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
@@ -34,7 +34,10 @@ render(() => {
|
||||
|
||||
const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
|
||||
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
|
||||
if (progress.type === "Done") setPercent(100)
|
||||
if (progress.type === "Done") {
|
||||
setPercent(100)
|
||||
setStep({ phase: "done" })
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -12,12 +12,10 @@ mod window_customizer;
|
||||
mod windows;
|
||||
|
||||
use crate::cli::CommandChild;
|
||||
use futures::{
|
||||
FutureExt, TryFutureExt,
|
||||
future::{self, Shared},
|
||||
};
|
||||
use futures::{FutureExt, TryFutureExt};
|
||||
use std::{
|
||||
env,
|
||||
future::Future,
|
||||
net::TcpListener,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
@@ -35,7 +33,6 @@ use tokio::{
|
||||
|
||||
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
|
||||
use crate::constants::*;
|
||||
use crate::server::get_saved_server_url;
|
||||
use crate::windows::{LoadingWindow, MainWindow};
|
||||
|
||||
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
|
||||
@@ -43,7 +40,6 @@ struct ServerReadyData {
|
||||
url: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
is_sidecar: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
|
||||
@@ -65,27 +61,12 @@ struct InitState {
|
||||
current: watch::Receiver<InitStep>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState {
|
||||
child: Arc<Mutex<Option<CommandChild>>>,
|
||||
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
child: Option<CommandChild>,
|
||||
status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_child(&self, child: Option<CommandChild>) {
|
||||
*self.child.lock().unwrap() = child;
|
||||
}
|
||||
}
|
||||
/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
|
||||
struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -110,26 +91,21 @@ fn kill_sidecar(app: AppHandle) {
|
||||
tracing::info!("Killed server");
|
||||
}
|
||||
|
||||
fn get_logs() -> String {
|
||||
logging::tail()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn await_initialization(
|
||||
state: State<'_, ServerState>,
|
||||
state: State<'_, SidecarReady>,
|
||||
init_state: State<'_, InitState>,
|
||||
events: Channel<InitStep>,
|
||||
) -> Result<ServerReadyData, String> {
|
||||
let mut rx = init_state.current.clone();
|
||||
|
||||
let events = async {
|
||||
let stream = async {
|
||||
let e = *rx.borrow();
|
||||
let _ = events.send(e);
|
||||
|
||||
while rx.changed().await.is_ok() {
|
||||
let step = *rx.borrow_and_update();
|
||||
|
||||
let _ = events.send(step);
|
||||
|
||||
if matches!(step, InitStep::Done) {
|
||||
@@ -138,10 +114,18 @@ async fn await_initialization(
|
||||
}
|
||||
};
|
||||
|
||||
future::join(state.status.clone(), events)
|
||||
.await
|
||||
.0
|
||||
.map_err(|_| "Failed to get server status".to_string())?
|
||||
// Wait for sidecar credentials (available immediately after spawn, before health check)
|
||||
let data = async {
|
||||
state
|
||||
.inner()
|
||||
.0
|
||||
.clone()
|
||||
.await
|
||||
.map_err(|_| "Failed to get sidecar data".to_string())
|
||||
};
|
||||
|
||||
let (result, _) = futures::future::join(data, stream).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -439,22 +423,35 @@ async fn initialize(app: AppHandle) {
|
||||
setup_app(&app, init_rx);
|
||||
spawn_cli_sync_task(app.clone());
|
||||
|
||||
let (server_ready_tx, server_ready_rx) = oneshot::channel();
|
||||
let server_ready_rx = server_ready_rx.shared();
|
||||
app.manage(ServerState::new(None, server_ready_rx.clone()));
|
||||
// Spawn sidecar immediately - credentials are known before health check
|
||||
let port = get_sidecar_port();
|
||||
let hostname = "127.0.0.1";
|
||||
let url = format!("http://{hostname}:{port}");
|
||||
let password = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tracing::info!("Spawning sidecar on {url}");
|
||||
let (child, health_check) =
|
||||
server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
|
||||
|
||||
// Make sidecar credentials available immediately (before health check completes)
|
||||
let (ready_tx, ready_rx) = oneshot::channel();
|
||||
let _ = ready_tx.send(ServerReadyData {
|
||||
url: url.clone(),
|
||||
username: Some("opencode".to_string()),
|
||||
password: Some(password),
|
||||
});
|
||||
app.manage(SidecarReady(ready_rx.shared()));
|
||||
app.manage(ServerState {
|
||||
child: Arc::new(Mutex::new(Some(child))),
|
||||
});
|
||||
|
||||
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
|
||||
|
||||
tracing::info!("Main and loading windows created");
|
||||
|
||||
// SQLite migration handling:
|
||||
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
|
||||
// First, we spawn a task that listens for SqliteMigrationProgress events that can
|
||||
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
|
||||
// Then in the loading task, we wait for sqlite migration to complete before
|
||||
// starting our health check against the server, otherwise long migrations could result in a timeout.
|
||||
let needs_sqlite_migration = !sqlite_file_exists();
|
||||
let sqlite_done = needs_sqlite_migration.then(|| {
|
||||
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
|
||||
// A separate loading window is shown for long migrations.
|
||||
let needs_migration = !sqlite_file_exists();
|
||||
let sqlite_done = needs_migration.then(|| {
|
||||
tracing::info!(
|
||||
path = %opencode_db_path().expect("failed to get db path").display(),
|
||||
"Sqlite file not found, waiting for it to be generated"
|
||||
@@ -480,80 +477,22 @@ async fn initialize(app: AppHandle) {
|
||||
}))
|
||||
});
|
||||
|
||||
// The loading task waits for SQLite migration (if needed) then for the sidecar health check.
|
||||
// This is only used to drive the loading window progress - the main window is shown immediately.
|
||||
let loading_task = tokio::spawn({
|
||||
let app = app.clone();
|
||||
|
||||
async move {
|
||||
tracing::info!("Setting up server connection");
|
||||
let server_connection = setup_server_connection(app.clone()).await;
|
||||
tracing::info!("Server connection setup");
|
||||
|
||||
// we delay spawning this future so that the timeout is created lazily
|
||||
let cli_health_check = match server_connection {
|
||||
ServerConnection::CLI {
|
||||
child,
|
||||
health_check,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
} => {
|
||||
let app = app.clone();
|
||||
Some(
|
||||
async move {
|
||||
let res = timeout(Duration::from_secs(30), health_check.0).await;
|
||||
let err = match res {
|
||||
Ok(Ok(Ok(()))) => None,
|
||||
Ok(Ok(Err(e))) => Some(e),
|
||||
Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
|
||||
Err(_) => Some("Health check timed out".to_string()),
|
||||
};
|
||||
|
||||
if let Some(err) = err {
|
||||
let _ = child.kill();
|
||||
|
||||
return Err(format!(
|
||||
"Failed to spawn OpenCode Server ({err}). Logs:\n{}",
|
||||
get_logs()
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!("CLI health check OK");
|
||||
|
||||
app.state::<ServerState>().set_child(Some(child));
|
||||
|
||||
Ok(ServerReadyData {
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
is_sidecar: true,
|
||||
})
|
||||
}
|
||||
.map(move |res| {
|
||||
let _ = server_ready_tx.send(res);
|
||||
}),
|
||||
)
|
||||
}
|
||||
ServerConnection::Existing { url } => {
|
||||
let _ = server_ready_tx.send(Ok(ServerReadyData {
|
||||
url: url.to_string(),
|
||||
username: None,
|
||||
password: None,
|
||||
is_sidecar: false,
|
||||
}));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("server connection started");
|
||||
|
||||
if let Some(cli_health_check) = cli_health_check {
|
||||
if let Some(sqlite_done_rx) = sqlite_done {
|
||||
let _ = sqlite_done_rx.await;
|
||||
}
|
||||
tokio::spawn(cli_health_check);
|
||||
if let Some(sqlite_done_rx) = sqlite_done {
|
||||
let _ = sqlite_done_rx.await;
|
||||
}
|
||||
|
||||
let _ = server_ready_rx.await;
|
||||
// Wait for sidecar to become healthy (for loading window progress)
|
||||
let res = timeout(Duration::from_secs(30), health_check.0).await;
|
||||
match res {
|
||||
Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
|
||||
Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
|
||||
Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
|
||||
Err(_) => tracing::error!("Sidecar health check timed out"),
|
||||
}
|
||||
|
||||
tracing::info!("Loading task finished");
|
||||
}
|
||||
@@ -561,7 +500,8 @@ async fn initialize(app: AppHandle) {
|
||||
.map_err(|_| ())
|
||||
.shared();
|
||||
|
||||
let loading_window = if needs_sqlite_migration
|
||||
// Show loading window for SQLite migrations if they take >1s
|
||||
let loading_window = if needs_migration
|
||||
&& timeout(Duration::from_secs(1), loading_task.clone())
|
||||
.await
|
||||
.is_err()
|
||||
@@ -571,12 +511,12 @@ async fn initialize(app: AppHandle) {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
Some(loading_window)
|
||||
} else {
|
||||
tracing::debug!("Showing main window without loading window");
|
||||
MainWindow::create(&app).expect("Failed to create main window");
|
||||
|
||||
None
|
||||
};
|
||||
|
||||
// Create main window immediately - the web app handles its own loading/health gate
|
||||
MainWindow::create(&app).expect("Failed to create main window");
|
||||
|
||||
let _ = loading_task.await;
|
||||
|
||||
tracing::info!("Loading done, completing initialisation");
|
||||
@@ -584,12 +524,9 @@ async fn initialize(app: AppHandle) {
|
||||
|
||||
if loading_window.is_some() {
|
||||
loading_window_complete.await;
|
||||
|
||||
tracing::info!("Loading window completed");
|
||||
}
|
||||
|
||||
MainWindow::create(&app).expect("Failed to create main window");
|
||||
|
||||
if let Some(loading_window) = loading_window {
|
||||
let _ = loading_window.close();
|
||||
}
|
||||
@@ -610,59 +547,6 @@ fn spawn_cli_sync_task(app: AppHandle) {
|
||||
});
|
||||
}
|
||||
|
||||
enum ServerConnection {
|
||||
Existing {
|
||||
url: String,
|
||||
},
|
||||
CLI {
|
||||
url: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
child: CommandChild,
|
||||
health_check: server::HealthCheck,
|
||||
},
|
||||
}
|
||||
|
||||
async fn setup_server_connection(app: AppHandle) -> ServerConnection {
|
||||
let custom_url = get_saved_server_url(&app).await;
|
||||
|
||||
tracing::info!(?custom_url, "Attempting server connection");
|
||||
|
||||
if let Some(url) = &custom_url
|
||||
&& server::check_health_or_ask_retry(&app, url).await
|
||||
{
|
||||
tracing::info!(%url, "Connected to custom server");
|
||||
// If the default server is already local, no need to also spawn a sidecar
|
||||
if server::is_localhost_url(url) {
|
||||
return ServerConnection::Existing { url: url.clone() };
|
||||
}
|
||||
// Remote default server: fall through and also spawn a local sidecar
|
||||
}
|
||||
|
||||
let local_port = get_sidecar_port();
|
||||
let hostname = "127.0.0.1";
|
||||
let local_url = format!("http://{hostname}:{local_port}");
|
||||
|
||||
tracing::debug!(url = %local_url, "Checking health of local server");
|
||||
if server::check_health(&local_url, None).await {
|
||||
tracing::info!(url = %local_url, "Health check OK, using existing server");
|
||||
return ServerConnection::Existing { url: local_url };
|
||||
}
|
||||
|
||||
let password = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tracing::info!("Spawning new local server");
|
||||
let (child, health_check) =
|
||||
server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
|
||||
|
||||
ServerConnection::CLI {
|
||||
url: local_url,
|
||||
username: Some("opencode".to_string()),
|
||||
password: Some(password),
|
||||
child,
|
||||
health_check,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_sidecar_port() -> u32 {
|
||||
option_env!("OPENCODE_PORT")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@@ -85,22 +84,6 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
|
||||
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
|
||||
tracing::info!(%url, "Using desktop-specific custom URL");
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
if let Some(cli_config) = cli::get_config(app).await
|
||||
&& let Some(url) = get_server_url_from_config(&cli_config)
|
||||
{
|
||||
tracing::info!(%url, "Using custom server URL from config");
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn spawn_local_server(
|
||||
app: AppHandle,
|
||||
hostname: String,
|
||||
@@ -145,19 +128,27 @@ pub fn spawn_local_server(
|
||||
|
||||
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
|
||||
|
||||
pub async fn check_health(url: &str, password: Option<&str>) -> bool {
|
||||
async fn check_health(url: &str, password: Option<&str>) -> bool {
|
||||
let Ok(url) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
|
||||
|
||||
if url_is_localhost(&url) {
|
||||
if url
|
||||
.host_str()
|
||||
.is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
})
|
||||
{
|
||||
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
|
||||
// excluding loopback. reqwest respects these by default, which can prevent the desktop
|
||||
// app from reaching its own local sidecar server.
|
||||
builder = builder.no_proxy();
|
||||
};
|
||||
}
|
||||
|
||||
let Ok(client) = builder.build() else {
|
||||
return false;
|
||||
@@ -177,77 +168,3 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_localhost_url(url: &str) -> bool {
|
||||
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
|
||||
}
|
||||
|
||||
fn url_is_localhost(url: &reqwest::Url) -> bool {
|
||||
url.host_str().is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a bind address hostname to a valid URL hostname for connection.
|
||||
/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
|
||||
/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
|
||||
fn normalize_hostname_for_url(hostname: &str) -> String {
|
||||
// Wildcard bind addresses -> localhost equivalents
|
||||
if hostname == "0.0.0.0" {
|
||||
return "127.0.0.1".to_string();
|
||||
}
|
||||
if hostname == "::" {
|
||||
return "[::1]".to_string();
|
||||
}
|
||||
|
||||
// IPv6 addresses need brackets in URLs
|
||||
if hostname.contains(':') && !hostname.starts_with('[') {
|
||||
return format!("[{}]", hostname);
|
||||
}
|
||||
|
||||
hostname.to_string()
|
||||
}
|
||||
|
||||
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
|
||||
let server = config.server.as_ref()?;
|
||||
let port = server.port?;
|
||||
tracing::debug!(port, "server.port found in OC config");
|
||||
let hostname = server
|
||||
.hostname
|
||||
.as_ref()
|
||||
.map(|v| normalize_hostname_for_url(v))
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
|
||||
Some(format!("http://{}:{}", hostname, port))
|
||||
}
|
||||
|
||||
pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
|
||||
tracing::debug!(%url, "Checking health");
|
||||
loop {
|
||||
if check_health(url, None).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
const RETRY: &str = "Retry";
|
||||
|
||||
let res = app.dialog()
|
||||
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
|
||||
.title("Connection Failed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
|
||||
.blocking_show_with_result();
|
||||
|
||||
match res {
|
||||
MessageDialogResult::Custom(name) if name == RETRY => {
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export type ServerReadyData = {
|
||||
url: string,
|
||||
username: string | null,
|
||||
password: string | null,
|
||||
is_sidecar: boolean,
|
||||
};
|
||||
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
} from "@opencode-ai/app"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
|
||||
@@ -22,7 +21,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { open as shellOpen } from "@tauri-apps/plugin-shell"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater"
|
||||
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { Channel } from "@tauri-apps/api/core"
|
||||
import { commands, ServerReadyData, type InitStep } from "./bindings"
|
||||
import { commands, type InitStep } from "./bindings"
|
||||
import { createMenu } from "./menu"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
@@ -348,12 +347,13 @@ const createPlatform = (): Platform => {
|
||||
await commands.setWslConfig({ enabled })
|
||||
},
|
||||
|
||||
getDefaultServerUrl: async () => {
|
||||
const result = await commands.getDefaultServerUrl().catch(() => null)
|
||||
return result
|
||||
getDefaultServer: async () => {
|
||||
const url = await commands.getDefaultServerUrl().catch(() => null)
|
||||
if (!url) return null
|
||||
return ServerConnection.Key.make(url)
|
||||
},
|
||||
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
setDefaultServer: async (url: string | null) => {
|
||||
await commands.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
@@ -412,12 +412,33 @@ void listenForDeepLinks()
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
|
||||
// Fetch sidecar credentials from Rust (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||
|
||||
const [defaultServer] = createResource(() =>
|
||||
platform.getDefaultServerUrl?.().then((url) => {
|
||||
platform.getDefaultServer?.().then((url) => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
|
||||
// Build the sidecar server connection once credentials arrive
|
||||
const servers = () => {
|
||||
const data = sidecar()
|
||||
if (!data) return []
|
||||
const http = {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
}
|
||||
const server: ServerConnection.Sidecar = {
|
||||
displayName: t("desktop.server.local"),
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http,
|
||||
}
|
||||
return [server] as ServerConnection.Any[]
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
@@ -426,6 +447,12 @@ render(() => {
|
||||
}
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
return null
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClick)
|
||||
onCleanup(() => {
|
||||
@@ -436,60 +463,19 @@ render(() => {
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<ServerGate>
|
||||
{(data) => {
|
||||
const http = {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
}
|
||||
const server: ServerConnection.Any = data.is_sidecar
|
||||
? {
|
||||
displayName: t("desktop.server.local"),
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http,
|
||||
}
|
||||
: { type: "http", http }
|
||||
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<Show when={!defaultServer.loading}>
|
||||
<AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
</Show>
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
}}
|
||||
</ServerGate>
|
||||
</Show>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root!)
|
||||
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
|
||||
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||
if (serverData.state === "errored") throw serverData.error
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
fallback={
|
||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(data) => props.children(data())}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
DROP INDEX IF EXISTS `message_session_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `part_message_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `message_session_time_created_id_idx` ON `message` (`session_id`,`time_created`,`id`);--> statement-breakpoint
|
||||
CREATE INDEX `part_message_id_id_idx` ON `part` (`message_id`,`id`);
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user