Compare commits
71 Commits
v0.10.1
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
c7195469bd | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 | ||
|
|
ffe991bbe4 | ||
|
|
3047a1e602 | ||
|
|
e6c568988a | ||
|
|
45fab91e7f | ||
|
|
d3484ec3af | ||
|
|
cb0d601b09 | ||
|
|
9ea4f6b5ef | ||
|
|
bf9ee76de5 | ||
|
|
6ed1e09180 | ||
|
|
54d4cf6604 | ||
|
|
359e89971f | ||
|
|
7f833747b0 | ||
|
|
ab3f228d85 | ||
|
|
67a530a83b | ||
|
|
612ec6af1b | ||
|
|
3382736f05 | ||
|
|
fd5941fb36 | ||
|
|
9b76521a90 | ||
|
|
ea92c0609d | ||
|
|
612e50808a | ||
|
|
2c24402742 | ||
|
|
d7c4bf1e45 | ||
|
|
5bfb09c73b | ||
|
|
fd499d95e6 | ||
|
|
204b2e020b | ||
|
|
d34e0163e3 | ||
|
|
a93252621a | ||
|
|
8ce7a9b4ee | ||
|
|
63ffb86ea7 | ||
|
|
bd9a8d9788 | ||
|
|
d291c2f074 | ||
|
|
16c2eeca3e | ||
|
|
d9d281af8c | ||
|
|
56a6364f99 | ||
|
|
ba20dd6f2f | ||
|
|
0d96a9f9ff | ||
|
|
ee9da95044 | ||
|
|
0511d92cbf | ||
|
|
e666ac333c | ||
|
|
8495dcd021 | ||
|
|
01ab2f2794 | ||
|
|
b59e85abda | ||
|
|
4eded9e204 | ||
|
|
90164aa507 | ||
|
|
f87c83cadd | ||
|
|
01300a81de | ||
|
|
d143faf8eb | ||
|
|
8c29741830 | ||
|
|
d360089b80 | ||
|
|
4279b25ff4 | ||
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 |
35
.github/workflows/dev-release.yml
vendored
35
.github/workflows/dev-release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Dev CI
|
||||
name: Develop Pre-Release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,12 +7,35 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dev-prerelease
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dev-ci:
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
||||
steps:
|
||||
- name: Compute version suffix
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA8="${GITHUB_SHA::8}"
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prerelease:
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
upload: false
|
||||
set_versions: false
|
||||
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
prerelease: true
|
||||
release_ui: false
|
||||
secrets: inherit
|
||||
|
||||
34
.github/workflows/manual-npm-publish.yml
vendored
34
.github/workflows/manual-npm-publish.yml
vendored
@@ -12,6 +12,11 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
package_name:
|
||||
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||
required: false
|
||||
default: "@neuralnomads/codenomad"
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -21,6 +26,13 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
package_name:
|
||||
required: false
|
||||
type: string
|
||||
default: "@neuralnomads/codenomad"
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -51,7 +63,7 @@ jobs:
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
run: npm run build --workspace packages/server
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
@@ -62,13 +74,31 @@ jobs:
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Set server package name for publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
# Optional: when present, npm will use token auth.
|
||||
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||
shell: bash
|
||||
run: |
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
set -euo pipefail
|
||||
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
|
||||
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
|
||||
unset NODE_AUTH_TOKEN
|
||||
else
|
||||
echo "Using NPM_TOKEN authentication"
|
||||
fi
|
||||
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
dist_tag: latest
|
||||
npm_package_name: "@neuralnomads/codenomad"
|
||||
secrets: inherit
|
||||
|
||||
24
.github/workflows/reusable-release.yml
vendored
24
.github/workflows/reusable-release.yml
vendored
@@ -13,6 +13,21 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
npm_package_name:
|
||||
description: "npm package name to publish (defaults to server package name)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Create GitHub prerelease"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_ui:
|
||||
description: "Publish remote UI + manifest"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -53,11 +68,16 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
if [ "${IS_PRERELEASE}" = "true" ]; then
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
@@ -71,6 +91,7 @@ jobs:
|
||||
|
||||
release-ui:
|
||||
needs: prepare-release
|
||||
if: ${{ inputs.release_ui }}
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
@@ -84,4 +105,5 @@ jobs:
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
package_name: ${{ inputs.npm_package_name }}
|
||||
secrets: inherit
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -15,6 +15,35 @@
|
||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||
|
||||
## Multi-Language Support (i18n)
|
||||
|
||||
The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings.
|
||||
|
||||
- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code.
|
||||
- Implementation: `packages/ui/src/lib/i18n/index.tsx`
|
||||
- **Where messages live:** `packages/ui/src/lib/i18n/messages/<locale>/` as TypeScript objects (`"flat.dot.keys": "string"`).
|
||||
- Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time.
|
||||
- Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts`
|
||||
- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file.
|
||||
- Missing translations fall back to English (and finally to the key), so gaps can be easy to miss.
|
||||
- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`.
|
||||
- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code.
|
||||
- **Adding a new language:** add a new `messages/<locale>/` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`.
|
||||
- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`).
|
||||
- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply.
|
||||
|
||||
## File Length Guidelines (Highlight Only)
|
||||
|
||||
We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits.
|
||||
|
||||
- Source files: warn after ~500 lines; target limit ~800 lines
|
||||
- Test files: highlight after ~1000 lines
|
||||
|
||||
Behavior for agents:
|
||||
- Do not refactor solely to satisfy these thresholds.
|
||||
- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count.
|
||||
- When creating new files, aim to stay under the thresholds unless there's a clear reason.
|
||||
|
||||
## Tooling Preferences
|
||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||
- Use the `write` tool only when creating new files from scratch.
|
||||
|
||||
14
README.md
14
README.md
@@ -44,13 +44,21 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
For dev version
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad@dev --launch
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
This command starts the server and opens the web client in your default browser.
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -3305,6 +3305,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
@@ -7999,6 +8008,12 @@
|
||||
"obliterator": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"license": "MIT"
|
||||
@@ -11864,6 +11879,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"dev": true,
|
||||
@@ -11955,11 +11985,12 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -11990,7 +12021,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12002,6 +12033,7 @@
|
||||
"node-forge": "^1.3.3",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -12030,7 +12062,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12038,7 +12070,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -12048,12 +12080,14 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.10.1",
|
||||
"minServerVersion": "0.10.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
|
||||
|
||||
const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
@@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
function prepareMonacoPublicAssets() {
|
||||
return {
|
||||
name: "prepare-monaco-public-assets",
|
||||
configureServer(server: any) {
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: uiRendererRoot,
|
||||
warn: (msg: string) => server.config.logger.warn(msg),
|
||||
sourceRoots: [
|
||||
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||
],
|
||||
})
|
||||
},
|
||||
buildStart(this: any) {
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: uiRendererRoot,
|
||||
warn: (msg: string) => this.warn(msg),
|
||||
sourceRoots: [
|
||||
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||
],
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
@@ -40,7 +67,7 @@ export default defineConfig({
|
||||
},
|
||||
renderer: {
|
||||
root: uiRendererRoot,
|
||||
plugins: [solid()],
|
||||
plugins: [solid(), prepareMonacoPublicAssets()],
|
||||
css: {
|
||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -91,4 +91,23 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
}
|
||||
return { enabled: false }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
if (!Notification.isSupported()) {
|
||||
return { ok: false, reason: "unsupported" }
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
|
||||
const body = typeof payload?.body === "string" ? payload.body : ""
|
||||
try {
|
||||
const notification = new Notification({ title, body })
|
||||
notification.show()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -399,7 +399,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
// In desktop dev workflows we always want the CLI to run in dev mode so it:
|
||||
// - uses plain HTTP
|
||||
// - proxies UI requests to the renderer dev server
|
||||
// Monaco's AMD assets are served from that dev server.
|
||||
const devMode = !app.isPackaged
|
||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||
await cliManager.start({ dev: devMode })
|
||||
} catch (error) {
|
||||
@@ -473,6 +477,14 @@ if (isMac) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Required for Windows notifications / taskbar grouping.
|
||||
// Keep in sync with desktop app identifier.
|
||||
try {
|
||||
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { parse as parseYaml } from "yaml"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
@@ -39,6 +40,36 @@ interface CliEntryResolution {
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
function isJsonPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".json")
|
||||
}
|
||||
|
||||
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
|
||||
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
|
||||
const resolved = resolveConfigPath(target)
|
||||
|
||||
if (isYamlPath(resolved)) {
|
||||
const baseDir = path.dirname(resolved)
|
||||
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
|
||||
}
|
||||
|
||||
if (isJsonPath(resolved)) {
|
||||
const baseDir = path.dirname(resolved)
|
||||
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
|
||||
}
|
||||
|
||||
// Treat as directory.
|
||||
return {
|
||||
configYamlPath: path.join(resolved, "config.yaml"),
|
||||
legacyJsonPath: path.join(resolved, "config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(configPath?: string): string {
|
||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||
if (target.startsWith("~/")) {
|
||||
@@ -53,10 +84,19 @@ function resolveHostForMode(mode: ListeningMode): string {
|
||||
|
||||
function readListeningModeFromConfig(): ListeningMode {
|
||||
try {
|
||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
||||
if (!existsSync(configPath)) return "local"
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
||||
|
||||
let parsed: any = null
|
||||
if (existsSync(configYamlPath)) {
|
||||
const content = readFileSync(configYamlPath, "utf-8")
|
||||
parsed = parseYaml(content)
|
||||
} else if (existsSync(legacyJsonPath)) {
|
||||
const content = readFileSync(legacyJsonPath, "utf-8")
|
||||
parsed = JSON.parse(content)
|
||||
} else {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
@@ -381,13 +421,17 @@ export class CliProcessManager extends EventEmitter {
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
args.push("--https", "false", "--http", "true")
|
||||
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||
// by forcing an ephemeral port in dev.
|
||||
args.push("--http-port", "0")
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https", "true", "--http", "true")
|
||||
}
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
||||
}
|
||||
|
||||
return args
|
||||
|
||||
@@ -13,6 +13,7 @@ const electronAPI = {
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -36,7 +36,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui"
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import http from "http"
|
||||
import https from "https"
|
||||
import { Readable } from "stream"
|
||||
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||
// Use a single request implementation that tolerates custom/self-signed certs
|
||||
// without disabling TLS verification for the whole Node process.
|
||||
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeFetch(
|
||||
url: string,
|
||||
init: RequestInit & { headers?: Record<string, string> },
|
||||
tls: { rejectUnauthorized: boolean },
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url)
|
||||
const isHttps = parsed.protocol === "https:"
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
|
||||
const method = (init.method ?? "GET").toUpperCase()
|
||||
const headers = init.headers ?? {}
|
||||
const body = init.body
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
const req = requestFn(
|
||||
{
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||
},
|
||||
(res) => {
|
||||
const responseHeaders = new Headers()
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
responseHeaders.set(key, value.join(", "))
|
||||
} else {
|
||||
responseHeaders.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Node stream -> Web ReadableStream for Response.
|
||||
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||
},
|
||||
)
|
||||
|
||||
const signal = init.signal
|
||||
const abort = () => {
|
||||
const err = new Error("Request aborted")
|
||||
;(err as any).name = "AbortError"
|
||||
req.destroy(err)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||
}
|
||||
|
||||
req.once("error", reject)
|
||||
|
||||
if (body === undefined || body === null) {
|
||||
req.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
req.end(body)
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array) {
|
||||
req.end(Buffer.from(body))
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
req.end(Buffer.from(new Uint8Array(body)))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for less common BodyInit types.
|
||||
req.end(String(body))
|
||||
})
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
|
||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
||||
public/
|
||||
|
||||
# Local developer config (may contain secrets)
|
||||
config-*.json
|
||||
|
||||
@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
To list all CLI options:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Install Locally (per-project)
|
||||
If you prefer to install CodeNomad into a project and run the local binary:
|
||||
|
||||
```sh
|
||||
npm install @neuralnomads/codenomad
|
||||
npx codenomad --launch
|
||||
```
|
||||
|
||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||
|
||||
### Common Flags
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
|
||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||
|
||||
### Dev Releases (Advanced)
|
||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
These environment variables control how CodeNomad checks for dev updates:
|
||||
|
||||
| Env Variable | Description |
|
||||
|-------------|-------------|
|
||||
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
||||
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
||||
|
||||
### HTTP vs HTTPS
|
||||
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"node-forge": "^1.3.3",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -286,6 +286,8 @@ export interface ServerMeta {
|
||||
serverVersion?: string
|
||||
ui?: UiMeta
|
||||
support?: SupportMeta
|
||||
/** Optional update info (dev channel only). */
|
||||
update?: LatestReleaseInfo | null
|
||||
}
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
78
packages/server/src/config/location.ts
Normal file
78
packages/server/src/config/location.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
export interface ConfigLocation {
|
||||
/** Resolved absolute base directory containing all persisted server data. */
|
||||
baseDir: string
|
||||
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
|
||||
configYamlPath: string
|
||||
/** Canonical YAML state file path (always in baseDir). */
|
||||
stateYamlPath: string
|
||||
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
|
||||
legacyJsonPath: string
|
||||
/** Directory for per-instance persisted data (chat history etc.). */
|
||||
instancesDir: string
|
||||
}
|
||||
|
||||
function resolvePath(inputPath: string): string {
|
||||
if (inputPath.startsWith("~/")) {
|
||||
return path.join(os.homedir(), inputPath.slice(2))
|
||||
}
|
||||
return path.resolve(inputPath)
|
||||
}
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
function isJsonPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
|
||||
*
|
||||
* Supported inputs:
|
||||
* - Directory: "~/.config/codenomad"
|
||||
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
|
||||
* - Legacy JSON file: "~/.config/codenomad/config.json"
|
||||
*/
|
||||
export function resolveConfigLocation(raw: string): ConfigLocation {
|
||||
const trimmed = (raw ?? "").trim()
|
||||
const fallback = "~/.config/codenomad/config.json"
|
||||
const input = trimmed.length > 0 ? trimmed : fallback
|
||||
|
||||
const resolvedInput = resolvePath(input)
|
||||
|
||||
if (isYamlPath(resolvedInput)) {
|
||||
const baseDir = path.dirname(resolvedInput)
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: resolvedInput,
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonPath(resolvedInput)) {
|
||||
const baseDir = path.dirname(resolvedInput)
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: resolvedInput,
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
|
||||
const baseDir = resolvedInput
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({
|
||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
const PreferencesSchema = z
|
||||
.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
@@ -25,7 +26,15 @@ const PreferencesSchema = z.object({
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
})
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||
notifyOnNeedsInput: z.boolean().default(true),
|
||||
notifyOnIdle: z.boolean().default(true),
|
||||
})
|
||||
// Preserve unknown preference keys so newer configs survive older binaries.
|
||||
.passthrough()
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
@@ -39,14 +48,35 @@ const OpenCodeBinarySchema = z.object({
|
||||
label: z.string().optional(),
|
||||
})
|
||||
|
||||
const ConfigFileSchema = z.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
const ConfigFileSchema = z
|
||||
.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
// Preserve unknown top-level keys so optional future features survive downgrades.
|
||||
.passthrough()
|
||||
|
||||
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
|
||||
const ConfigYamlSchema = z
|
||||
.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
|
||||
const StateFileSchema = z
|
||||
.object({
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
|
||||
const DEFAULT_STATE = StateFileSchema.parse({})
|
||||
|
||||
export {
|
||||
ModelPreferenceSchema,
|
||||
@@ -56,7 +86,11 @@ export {
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
ConfigYamlSchema,
|
||||
StateFileSchema,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CONFIG_YAML,
|
||||
DEFAULT_STATE,
|
||||
}
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||
@@ -66,3 +100,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
|
||||
export type StateFile = z.infer<typeof StateFileSchema>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
||||
import {
|
||||
ConfigFile,
|
||||
ConfigFileSchema,
|
||||
ConfigYamlSchema,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CONFIG_YAML,
|
||||
DEFAULT_STATE,
|
||||
StateFile,
|
||||
StateFileSchema,
|
||||
} from "./schema"
|
||||
import type { ConfigLocation } from "./location"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
private state: StateFile = DEFAULT_STATE
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly configPath: string,
|
||||
private readonly location: ConfigLocation,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
@@ -20,19 +32,37 @@ export class ConfigStore {
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
const content = fs.readFileSync(resolved, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
this.cache = ConfigFileSchema.parse(parsed)
|
||||
this.logger.debug({ resolved }, "Loaded existing config file")
|
||||
const configYamlPath = this.location.configYamlPath
|
||||
const stateYamlPath = this.location.stateYamlPath
|
||||
const legacyJsonPath = this.location.legacyJsonPath
|
||||
|
||||
if (fs.existsSync(configYamlPath)) {
|
||||
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
||||
const stateDoc = fs.existsSync(stateYamlPath)
|
||||
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
||||
: DEFAULT_STATE
|
||||
|
||||
this.state = stateDoc
|
||||
this.cache = this.mergeDocs(configDoc, stateDoc)
|
||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
||||
} else if (fs.existsSync(legacyJsonPath)) {
|
||||
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
||||
this.state = migrated.state
|
||||
this.cache = migrated.config
|
||||
} else {
|
||||
this.cache = DEFAULT_CONFIG
|
||||
this.logger.debug({ resolved }, "No config file found, using defaults")
|
||||
// Fresh install: write defaults.
|
||||
this.state = DEFAULT_STATE
|
||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||
this.persist()
|
||||
this.logger.debug(
|
||||
{ configYamlPath, stateYamlPath },
|
||||
"No config files found, created default YAML config/state",
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
||||
this.cache = DEFAULT_CONFIG
|
||||
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
||||
this.state = DEFAULT_STATE
|
||||
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
@@ -48,9 +78,30 @@ export class ConfigStore {
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a merge-patch update to the current config.
|
||||
* - Missing keys are preserved.
|
||||
* - Object values are merged recursively.
|
||||
* - Explicit `null` deletes keys.
|
||||
* - Arrays are replaced.
|
||||
*/
|
||||
mergePatch(patch: unknown) {
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||
throw new Error("Config patch must be a JSON object")
|
||||
}
|
||||
const current = this.get()
|
||||
const next = applyMergePatch(current as any, patch as any)
|
||||
const validated = ConfigFileSchema.parse(next)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
private commit(next: ConfigFile) {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.state = {
|
||||
...this.state,
|
||||
recentFolders: next.recentFolders,
|
||||
}
|
||||
this.persist()
|
||||
const published = Boolean(this.eventBus)
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
@@ -60,19 +111,134 @@ export class ConfigStore {
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||
this.logger.debug({ resolved }, "Persisted config file")
|
||||
const configYamlPath = this.location.configYamlPath
|
||||
const stateYamlPath = this.location.stateYamlPath
|
||||
|
||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
||||
|
||||
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
||||
const stateYaml = stringifyYaml(this.state as any)
|
||||
|
||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
||||
|
||||
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist config")
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
||||
const merged = {
|
||||
...(configDoc as any),
|
||||
// State wins for recent folders.
|
||||
recentFolders: stateDoc.recentFolders ?? [],
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
|
||||
return ConfigFileSchema.parse(merged)
|
||||
}
|
||||
|
||||
private readYamlFile<T>(
|
||||
filePath: string,
|
||||
fallback: T,
|
||||
schema: { parse: (value: unknown) => T },
|
||||
label: string,
|
||||
): T {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
const parsed = parseYaml(content)
|
||||
return schema.parse(parsed ?? {})
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
||||
const configYamlPath = this.location.configYamlPath
|
||||
const stateYamlPath = this.location.stateYamlPath
|
||||
|
||||
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const legacy = ConfigFileSchema.parse(parsed)
|
||||
|
||||
const state: StateFile = StateFileSchema.parse({
|
||||
...DEFAULT_STATE,
|
||||
recentFolders: legacy.recentFolders ?? [],
|
||||
})
|
||||
|
||||
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
||||
|
||||
// Persist YAML docs first, then move legacy aside.
|
||||
try {
|
||||
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
||||
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
||||
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
||||
}
|
||||
|
||||
try {
|
||||
const bakPath = pickBackupPath(legacyJsonPath)
|
||||
fs.renameSync(legacyJsonPath, bakPath)
|
||||
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
||||
}
|
||||
|
||||
return { config: merged, state }
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(content: string): string {
|
||||
if (!content) return "\n"
|
||||
return content.endsWith("\n") ? content : `${content}\n`
|
||||
}
|
||||
|
||||
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
||||
const clone: Record<string, unknown> = { ...(config as any) }
|
||||
delete clone.recentFolders
|
||||
return clone as any
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object") return false
|
||||
if (Array.isArray(value)) return false
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
function applyMergePatch(current: any, patch: any): any {
|
||||
// RFC 7396-ish merge patch with explicit null deletes.
|
||||
if (!isPlainObject(patch)) {
|
||||
return patch
|
||||
}
|
||||
|
||||
const base = isPlainObject(current) ? { ...current } : {}
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete base[key]
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPlainObject(value) && isPlainObject(base[key])) {
|
||||
base[key] = applyMergePatch(base[key], value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Arrays and scalars replace.
|
||||
base[key] = value
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function pickBackupPath(legacyJsonPath: string): string {
|
||||
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
||||
const preferred = `${base}.json.bak`
|
||||
if (!fs.existsSync(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
return `${base}.json.bak.${Date.now()}`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
import { resolveConfigLocation } from "./config/location"
|
||||
import { BinaryRegistry } from "./config/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
@@ -21,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -210,13 +212,6 @@ function resolveHost(input: string | undefined): string {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function programHasArg(argv: string[], flag: string): boolean {
|
||||
return argv.includes(flag)
|
||||
}
|
||||
@@ -245,7 +240,8 @@ async function main() {
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const configDir = path.dirname(resolvePath(options.configPath))
|
||||
const configLocation = resolveConfigLocation(options.configPath)
|
||||
const configDir = configLocation.baseDir
|
||||
|
||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||
@@ -266,7 +262,7 @@ async function main() {
|
||||
|
||||
const authManager = new AuthManager(
|
||||
{
|
||||
configPath: options.configPath,
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
generateToken: options.generateToken,
|
||||
@@ -295,7 +291,16 @@ async function main() {
|
||||
|
||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
||||
|
||||
// Eagerly load config at boot so migrations run immediately
|
||||
// (instead of waiting for the first /api/config request).
|
||||
try {
|
||||
configStore.get()
|
||||
} catch (error) {
|
||||
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
||||
}
|
||||
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
@@ -307,7 +312,7 @@ async function main() {
|
||||
nodeExtraCaCertsPath,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -344,6 +349,21 @@ async function main() {
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
|
||||
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
|
||||
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
|
||||
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
|
||||
const devReleaseMonitor = enableDevUpdateChecks
|
||||
? startDevReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
repo: githubRepo,
|
||||
logger: logger.child({ component: "updates" }),
|
||||
onUpdate: (release) => {
|
||||
serverMeta.update = release
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
if (uiResolution.uiDevServerUrl && options.https) {
|
||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||
}
|
||||
@@ -423,7 +443,11 @@ async function main() {
|
||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||
|
||||
const localUrl = `${localProtocol}://localhost:${localStart.port}`
|
||||
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
@@ -499,6 +523,8 @@ async function main() {
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
devReleaseMonitor?.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { fetch } from "undici"
|
||||
import type { LatestReleaseInfo } from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
|
||||
|
||||
interface DevReleaseMonitorOptions {
|
||||
/** Current running server version (from package.json). */
|
||||
currentVersion: string
|
||||
/** GitHub repo in the form "owner/name". */
|
||||
repo: string
|
||||
logger: Logger
|
||||
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
interface GithubReleaseListItem {
|
||||
tag_name?: string
|
||||
name?: string
|
||||
html_url?: string
|
||||
body?: string
|
||||
published_at?: string
|
||||
created_at?: string
|
||||
prerelease?: boolean
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
export interface DevReleaseMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
|
||||
|
||||
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const pollIntervalMs =
|
||||
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
|
||||
? (options.pollIntervalMs as number)
|
||||
: DEFAULT_POLL_INTERVAL_MS
|
||||
|
||||
const refresh = async () => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const release = await fetchLatestPrerelease({
|
||||
repo: options.repo,
|
||||
currentVersion: options.currentVersion,
|
||||
})
|
||||
options.onUpdate(release)
|
||||
} catch (error) {
|
||||
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
timer = setInterval(() => void refresh(), pollIntervalMs)
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestPrerelease(args: {
|
||||
repo: string
|
||||
currentVersion: string
|
||||
}): Promise<LatestReleaseInfo | null> {
|
||||
const normalizedRepo = args.repo.trim()
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
|
||||
throw new Error(`Invalid GitHub repo: ${args.repo}`)
|
||||
}
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub releases API responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const list = (await response.json()) as GithubReleaseListItem[]
|
||||
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
|
||||
if (!latest) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tag = latest.tag_name || latest.name
|
||||
if (!tag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedVersion = stripTagPrefix(tag)
|
||||
if (!normalizedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
tag,
|
||||
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
|
||||
channel: "dev",
|
||||
publishedAt: latest.published_at ?? latest.created_at,
|
||||
notes: latest.body,
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
|
||||
}
|
||||
}
|
||||
|
||||
export function compareVersionStrings(a: string, b: string): number {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
return compareVersions(left, right)
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||
const response = await fetch(RELEASES_API_URL, {
|
||||
headers: {
|
||||
@@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
|
||||
}
|
||||
}
|
||||
|
||||
function stripTagPrefix(tag: string | undefined): string | null {
|
||||
export function stripTagPrefix(tag: string | undefined): string | null {
|
||||
if (!tag) return null
|
||||
const trimmed = tag.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
|
||||
|
||||
function parseVersion(value: string): NormalizedVersion {
|
||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||
const [core, prerelease = null] = normalized.split("-", 2)
|
||||
const dashIndex = normalized.indexOf("-")
|
||||
const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized
|
||||
const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null
|
||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
|
||||
@@ -2,7 +2,6 @@ import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
import { ConfigFileSchema } from "../../config/schema"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
@@ -27,10 +26,25 @@ const BinaryValidateSchema = z.object({
|
||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/config/app", async () => deps.configStore.get())
|
||||
|
||||
app.put("/api/config/app", async (request) => {
|
||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||
deps.configStore.replace(body)
|
||||
return deps.configStore.get()
|
||||
app.put("/api/config/app", async (request, reply) => {
|
||||
// Backwards compatible: treat PUT as a merge-patch update.
|
||||
try {
|
||||
deps.configStore.mergePatch(request.body ?? {})
|
||||
return deps.configStore.get()
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/api/config/app", async (request, reply) => {
|
||||
try {
|
||||
deps.configStore.mergePatch(request.body ?? {})
|
||||
return deps.configStore.get()
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/api/config/binaries", async () => {
|
||||
|
||||
@@ -116,12 +116,26 @@ export class WorkspaceRuntime {
|
||||
folder: options.folder,
|
||||
binary: options.binaryPath,
|
||||
spawnCommand: spec.command,
|
||||
spawnArgs: spec.args,
|
||||
commandLine,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
spawnArgs: spec.args,
|
||||
},
|
||||
"OpenCode spawn args",
|
||||
)
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"OpenCode spawn environment",
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
|
||||
80
packages/tauri-app/Cargo.lock
generated
80
packages/tauri-app/Cargo.lock
generated
@@ -636,10 +636,12 @@ dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-keepawake",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
@@ -1033,7 +1035,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2336,6 +2338,18 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2 0.6.3",
|
||||
"objc2-foundation 0.3.2",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -2540,6 +2554,20 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
||||
dependencies = [
|
||||
"futures-lite 2.6.1",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus 5.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
@@ -3867,6 +3895,19 @@ dependencies = [
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.12.1",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -4390,6 +4431,25 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.2"
|
||||
@@ -4513,6 +4573,18 @@ dependencies = [
|
||||
"toml 0.9.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.17",
|
||||
"windows 0.61.3",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.23.0"
|
||||
@@ -4957,6 +5029,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.7"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
const { pathToFileURL } = require("url")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
async function ensureMonacoAssets() {
|
||||
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||
const helperUrl = pathToFileURL(helperPath).href
|
||||
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||
warn: (msg) => console.warn(`[dev-prep] ${msg}`),
|
||||
sourceRoots: [
|
||||
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
@@ -42,5 +57,11 @@ function copyUiLoadingAssets() {
|
||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
;(async () => {
|
||||
await ensureMonacoAssets()
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
})().catch((err) => {
|
||||
console.error("[dev-prep] failed:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
const { pathToFileURL } = require("url")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
@@ -37,6 +38,20 @@ const braceExpansionPath = path.join(
|
||||
|
||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||
|
||||
async function ensureMonacoAssets() {
|
||||
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||
const helperUrl = pathToFileURL(helperPath).href
|
||||
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||
warn: (msg) => console.warn(`[prebuild] ${msg}`),
|
||||
sourceRoots: [
|
||||
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
@@ -223,12 +238,18 @@ function copyUiLoadingAssets() {
|
||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
;(async () => {
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
await ensureMonacoAssets()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
})().catch((err) => {
|
||||
console.error("[prebuild] failed:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] }
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
@@ -23,3 +24,4 @@ dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-show",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2408,6 +2408,204 @@
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2408,6 +2408,204 @@
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
@@ -145,12 +145,33 @@ struct AppConfig {
|
||||
preferences: Option<PreferencesConfig>,
|
||||
}
|
||||
|
||||
fn resolve_config_path() -> PathBuf {
|
||||
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||
let raw = env::var("CLI_CONFIG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||
expand_home(&raw)
|
||||
|
||||
let expanded = expand_home(&raw);
|
||||
let lower = raw.trim().to_lowercase();
|
||||
|
||||
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
|
||||
let base = expanded
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| expanded.clone());
|
||||
return (expanded, base.join("config.json"));
|
||||
}
|
||||
|
||||
if lower.ends_with(".json") {
|
||||
let base = expanded
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| expanded.clone());
|
||||
return (base.join("config.yaml"), expanded);
|
||||
}
|
||||
|
||||
// Treat as directory.
|
||||
(expanded.join("config.yaml"), expanded.join("config.json"))
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
@@ -163,8 +184,27 @@ fn expand_home(path: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
fn resolve_listening_mode() -> String {
|
||||
let path = resolve_config_path();
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let (yaml_path, json_path) = resolve_config_locations();
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
if mode == "all" {
|
||||
return "all".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback.
|
||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
@@ -260,7 +300,14 @@ impl CliProcessManager {
|
||||
let ready_flag = self.ready.clone();
|
||||
let token_arc = self.bootstrap_token.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
|
||||
if let Err(err) = Self::spawn_cli(
|
||||
app.clone(),
|
||||
status_arc.clone(),
|
||||
child_arc,
|
||||
ready_flag,
|
||||
token_arc,
|
||||
dev,
|
||||
) {
|
||||
log_line(&format!("cli spawn failed: {err}"));
|
||||
let mut locked = status_arc.lock();
|
||||
locked.state = CliState::Error;
|
||||
@@ -369,7 +416,9 @@ impl CliProcessManager {
|
||||
|
||||
if !supports_user_shell() {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node binary not found. Make sure Node.js is installed."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +469,6 @@ impl CliProcessManager {
|
||||
let token_clone = bootstrap_token.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
|
||||
let stdout = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
@@ -433,10 +481,24 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -509,8 +571,14 @@ impl CliProcessManager {
|
||||
if locked.error.is_none() {
|
||||
locked.error = err_msg.clone();
|
||||
}
|
||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
||||
log_line(&format!(
|
||||
"cli process exited before ready: {:?}",
|
||||
locked.error
|
||||
));
|
||||
let _ = app_clone.emit(
|
||||
"cli:error",
|
||||
json!({"message": locked.error.clone().unwrap_or_default()}),
|
||||
);
|
||||
} else {
|
||||
locked.state = CliState::Stopped;
|
||||
log_line("cli process stopped cleanly");
|
||||
@@ -574,13 +642,25 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -719,7 +799,12 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
@@ -761,9 +846,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
||||
std::env::current_exe().ok().and_then(|ex| {
|
||||
ex.parent()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||
}),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
@@ -786,7 +872,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
let base = workspace_root();
|
||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
||||
base.as_ref()
|
||||
.map(|p| p.join("packages/server/dist/index.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||
];
|
||||
@@ -801,7 +888,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||
candidates.push(Some(
|
||||
resources.join("resources/server/dist/server/index.js"),
|
||||
));
|
||||
|
||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||
for root in linux_resource_roots {
|
||||
@@ -820,8 +909,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
||||
|
||||
fn build_shell_command_string(
|
||||
entry: &CliEntry,
|
||||
cli_args: &[String],
|
||||
) -> anyhow::Result<ShellCommand> {
|
||||
let shell = default_shell();
|
||||
let mut quoted: Vec<String> = Vec::new();
|
||||
quoted.push(shell_escape(&entry.node_binary));
|
||||
@@ -852,7 +943,7 @@ fn shell_escape(input: &str) -> String {
|
||||
"''".to_string()
|
||||
} else if !input
|
||||
.chars()
|
||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
|
||||
{
|
||||
input.to_string()
|
||||
} else {
|
||||
|
||||
@@ -75,6 +75,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
|
||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
src/renderer/public/logo.png
|
||||
src/renderer/public/monaco/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -19,11 +19,13 @@
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
|
||||
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export type CopyMonacoPublicAssetsParams = {
|
||||
uiRendererRoot: string
|
||||
warn?: (message: string) => void
|
||||
sourceRoots?: string[]
|
||||
}
|
||||
|
||||
export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void
|
||||
97
packages/ui/scripts/monaco-public-assets.js
Normal file
97
packages/ui/scripts/monaco-public-assets.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from "fs"
|
||||
import { resolve } from "path"
|
||||
|
||||
/**
|
||||
* Copy Monaco's AMD `min/vs` assets into the UI renderer public folder.
|
||||
*
|
||||
* Monaco is loaded at runtime via `/monaco/vs/loader.js`. These assets are gitignored
|
||||
* and generated on demand in dev/build so the repo stays clean.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.uiRendererRoot Absolute path to `packages/ui/src/renderer`.
|
||||
* @param {(message: string) => void} [params.warn] Warning logger.
|
||||
* @param {string[]} [params.sourceRoots] Optional override list of `.../monaco-editor/min/vs` roots.
|
||||
*/
|
||||
export function copyMonacoPublicAssets(params) {
|
||||
const uiRendererRoot = params?.uiRendererRoot
|
||||
if (!uiRendererRoot) {
|
||||
throw new Error("copyMonacoPublicAssets: uiRendererRoot is required")
|
||||
}
|
||||
|
||||
const warn = params?.warn ?? ((message) => console.warn(message))
|
||||
const publicDir = resolve(uiRendererRoot, "public")
|
||||
const destRoot = resolve(publicDir, "monaco/vs")
|
||||
|
||||
const candidates =
|
||||
params?.sourceRoots?.length > 0
|
||||
? params.sourceRoots
|
||||
: [
|
||||
// Workspace root hoisted deps.
|
||||
resolve(process.cwd(), "node_modules/monaco-editor/min/vs"),
|
||||
// UI package local deps (covers non-hoisted installs).
|
||||
resolve(process.cwd(), "packages/ui/node_modules/monaco-editor/min/vs"),
|
||||
]
|
||||
|
||||
const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js")))
|
||||
if (!sourceRoot) {
|
||||
warn("Monaco source directory not found; skipping copy")
|
||||
return
|
||||
}
|
||||
|
||||
const copyRecursive = (src, dest) => {
|
||||
const stat = fs.statSync(src)
|
||||
if (stat.isDirectory()) {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
for (const entry of fs.readdirSync(src)) {
|
||||
copyRecursive(resolve(src, entry), resolve(dest, entry))
|
||||
}
|
||||
return
|
||||
}
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
|
||||
// Keep the working tree clean; these assets are generated.
|
||||
try {
|
||||
fs.rmSync(destRoot, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fs.mkdirSync(destRoot, { recursive: true })
|
||||
|
||||
// Copy core Monaco runtime.
|
||||
for (const dir of ["base", "editor", "platform"]) {
|
||||
const src = resolve(sourceRoot, dir)
|
||||
if (fs.existsSync(src)) {
|
||||
copyRecursive(src, resolve(destRoot, dir))
|
||||
}
|
||||
}
|
||||
|
||||
// loader.js is required.
|
||||
copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js"))
|
||||
|
||||
// Copy baseline rich language packages + workers.
|
||||
for (const lang of ["typescript", "html", "json", "css"]) {
|
||||
const src = resolve(sourceRoot, "language", lang)
|
||||
if (fs.existsSync(src)) {
|
||||
copyRecursive(src, resolve(destRoot, "language", lang))
|
||||
}
|
||||
}
|
||||
|
||||
// Copy baseline basic tokenizers.
|
||||
for (const lang of ["python", "markdown", "cpp", "kotlin"]) {
|
||||
const src = resolve(sourceRoot, "basic-languages", lang)
|
||||
if (fs.existsSync(src)) {
|
||||
copyRecursive(src, resolve(destRoot, "basic-languages", lang))
|
||||
}
|
||||
}
|
||||
|
||||
// Copy monaco.contribution.js entrypoints (needed by some loads).
|
||||
const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
|
||||
if (fs.existsSync(monacoContribution)) {
|
||||
copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js"))
|
||||
}
|
||||
const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js")
|
||||
if (fs.existsSync(underscoreContribution)) {
|
||||
copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js"))
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const App: Component = () => {
|
||||
preferences,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
@@ -80,6 +81,13 @@ const App: Component = () => {
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||
})
|
||||
|
||||
const updateInstanceTabBarHeight = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
@@ -293,6 +301,7 @@ const App: Component = () => {
|
||||
preferences,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
@@ -354,32 +363,34 @@ const App: Component = () => {
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
{t("app.launchError.openAdvancedSettings")}
|
||||
|
||||
@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
|
||||
const groupedCommandList = () => processedCommands().groups
|
||||
const orderedCommands = () => processedCommands().ordered
|
||||
|
||||
const isCommandDisabled = (command: Command) => {
|
||||
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
||||
}
|
||||
const selectedIndex = createMemo(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) return -1
|
||||
@@ -138,10 +142,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const currentId = selectedCommandId()
|
||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||
setSelectedCommandId(ordered[0].id)
|
||||
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
||||
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
if (index < 0 || index >= ordered.length) return
|
||||
const command = ordered[index]
|
||||
if (!command) return
|
||||
if (isCommandDisabled(command)) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(command: Command) {
|
||||
if (isCommandDisabled(command)) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
<For each={group.commands}>
|
||||
{(command, localIndex) => {
|
||||
const commandIndex = group.startIndex + localIndex()
|
||||
const disabled = isCommandDisabled(command)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-command-index={commandIndex}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
disabled={disabled}
|
||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||
onPointerMove={(event) => {
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
|
||||
116
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
116
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { loadMonaco } from "../../lib/monaco/setup"
|
||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||
import { useTheme } from "../../lib/theme"
|
||||
|
||||
interface MonacoDiffViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
const { isDark } = useTheme()
|
||||
let host: HTMLDivElement | undefined
|
||||
|
||||
let diffEditor: any = null
|
||||
let monaco: any = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
|
||||
const disposeEditor = () => {
|
||||
try {
|
||||
diffEditor?.setModel(null as any)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
diffEditor?.dispose()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
diffEditor = null
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
monaco = await loadMonaco()
|
||||
if (cancelled) return
|
||||
if (!host || !monaco) return
|
||||
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
diffEditor = monaco.editor.createDiffEditor(host, {
|
||||
readOnly: true,
|
||||
automaticLayout: true,
|
||||
renderSideBySide: true,
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
renderMarginRevertIcon: false,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
wordWrap: "off",
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||
lineNumbersMinChars: 4,
|
||||
lineDecorationsWidth: 12,
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
setReady(false)
|
||||
disposeEditor()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: viewMode === "split",
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
hideUnchangedRegions:
|
||||
contextMode === "collapsed"
|
||||
? { enabled: true }
|
||||
: { enabled: false },
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||
|
||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
||||
diffEditor.setModel({ original, modified })
|
||||
|
||||
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||
try {
|
||||
monaco.editor.setModelLanguage(original, languageId)
|
||||
monaco.editor.setModelLanguage(modified, languageId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return <div class="monaco-viewer" ref={host} />
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { loadMonaco } from "../../lib/monaco/setup"
|
||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||
import { useTheme } from "../../lib/theme"
|
||||
|
||||
interface MonacoFileViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
const { isDark } = useTheme()
|
||||
let host: HTMLDivElement | undefined
|
||||
|
||||
let editor: any = null
|
||||
let monaco: any = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
|
||||
const disposeEditor = () => {
|
||||
try {
|
||||
editor?.setModel(null)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
editor?.dispose()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
editor = null
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
monaco = await loadMonaco()
|
||||
if (cancelled) return
|
||||
if (!host || !monaco) return
|
||||
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
editor = monaco.editor.create(host, {
|
||||
value: "",
|
||||
language: "plaintext",
|
||||
readOnly: true,
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "off",
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
setReady(false)
|
||||
disposeEditor()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !editor) return
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !editor) return
|
||||
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||
const cacheKey = `${props.scopeKey}:file:${props.path}`
|
||||
const model = getOrCreateTextModel({ monaco, cacheKey, value: props.content, languageId })
|
||||
editor.setModel(model)
|
||||
|
||||
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||
try {
|
||||
monaco.editor.setModelLanguage(model, languageId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return <div class="monaco-viewer" ref={host} />
|
||||
}
|
||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer keyboard-hints">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
|
||||
@@ -254,12 +254,63 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
|
||||
function getDisplayPath(path: string): string {
|
||||
if (!path) return path
|
||||
|
||||
// macOS: /Users/<name>/...
|
||||
if (path.startsWith("/Users/")) {
|
||||
return path.replace(/^\/Users\/[^/]+/, "~")
|
||||
}
|
||||
|
||||
// Linux: /home/<name>/...
|
||||
if (path.startsWith("/home/")) {
|
||||
return path.replace(/^\/home\/[^/]+/, "~")
|
||||
}
|
||||
|
||||
// Windows: C:\Users\<name>\... (and the forward-slash variant)
|
||||
if (/^[A-Za-z]:\\Users\\/.test(path)) {
|
||||
return path.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~")
|
||||
}
|
||||
if (/^[A-Za-z]:\/Users\//.test(path)) {
|
||||
return path.replace(/^[A-Za-z]:\/Users\/[^/]+/, "~")
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function looksLikeWindowsPath(value: string): boolean {
|
||||
if (!value) return false
|
||||
// Drive letter (C:\...) or UNC (\\server\share\...)
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)
|
||||
}
|
||||
|
||||
function splitFolderPath(rawPath: string): { baseName: string; dirName: string } {
|
||||
if (!rawPath) return { baseName: "", dirName: "" }
|
||||
|
||||
const isWindows = looksLikeWindowsPath(rawPath)
|
||||
const trimmed = rawPath.replace(/[\\/]+$/, "")
|
||||
|
||||
// Root edge-cases ("/", "C:\\", "\\\\server\\share\\")
|
||||
if (!trimmed) {
|
||||
return { baseName: rawPath, dirName: "" }
|
||||
}
|
||||
|
||||
if (isWindows && /^[A-Za-z]:$/.test(trimmed)) {
|
||||
return { baseName: `${trimmed}\\`, dirName: "" }
|
||||
}
|
||||
|
||||
const lastSlash = trimmed.lastIndexOf("/")
|
||||
const lastBackslash = isWindows ? trimmed.lastIndexOf("\\") : -1
|
||||
const lastSep = Math.max(lastSlash, lastBackslash)
|
||||
|
||||
if (lastSep < 0) {
|
||||
return { baseName: trimmed, dirName: "" }
|
||||
}
|
||||
|
||||
const baseName = trimmed.slice(lastSep + 1) || trimmed
|
||||
const dirName = trimmed.slice(0, lastSep)
|
||||
return { baseName, dirName }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -441,14 +492,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
@@ -497,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -522,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -540,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||
<span>{t("folderSelection.hints.browse")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
|
||||
interface HintRowProps {
|
||||
children: JSX.Element
|
||||
class?: string
|
||||
ariaHidden?: boolean
|
||||
}
|
||||
|
||||
const HintRow: Component<HintRowProps> = (props) => {
|
||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
||||
return (
|
||||
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default HintRow
|
||||
|
||||
@@ -11,20 +11,11 @@ interface InstanceTabProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
|
||||
const name = path.split("/").pop() || path
|
||||
|
||||
const duplicates = instances.filter((i) => {
|
||||
const iName = i.folder.split("/").pop() || i.folder
|
||||
return iName === name
|
||||
})
|
||||
|
||||
if (duplicates.length > 1) {
|
||||
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
|
||||
return `~/${name} (${index + 1})`
|
||||
}
|
||||
|
||||
return `~/${name}`
|
||||
function getPathBasename(path: string): string {
|
||||
// Instance folders can be POSIX-like (/Users/...) on macOS/Linux or Windows-like (C:\Users\...).
|
||||
// Normalize by trimming trailing separators and then splitting on both '/' and '\\'.
|
||||
const normalized = path.replace(/[\\/]+$/, "")
|
||||
return normalized.split(/[\\/]/).pop() || path
|
||||
}
|
||||
|
||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
@@ -58,7 +49,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
>
|
||||
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="tab-label">
|
||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
||||
{getPathBasename(props.instance.folder)}
|
||||
</span>
|
||||
<span
|
||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -18,6 +22,21 @@ interface InstanceTabsProps {
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||
|
||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||
const notificationIcon = createMemo(() => {
|
||||
if (!notificationsSupported()) return BellOff
|
||||
return notificationsEnabled() ? Bell : BellOff
|
||||
})
|
||||
|
||||
const notificationTitle = createMemo(() => {
|
||||
if (!notificationsSupported()) return "Notifications unsupported"
|
||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
@@ -54,6 +73,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
@@ -67,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
)}
|
||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||
</div>
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-footer hidden sm:block">
|
||||
<div class="panel-footer hidden sm:block keyboard-hints">
|
||||
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
187
packages/ui/src/components/instance/shell/SessionSidebar.tsx
Normal file
187
packages/ui/src/components/instance/shell/SessionSidebar.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Show, type Accessor, type Component } from "solid-js"
|
||||
import type { SessionThread } from "../../../stores/session-state"
|
||||
import type { Session } from "../../../types/session"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||
import type { DrawerViewState } from "./types"
|
||||
|
||||
import { PlusSquare, Search } from "lucide-solid"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||
|
||||
import SessionList from "../../session-list"
|
||||
import KeyboardHint from "../../keyboard-hint"
|
||||
import WorktreeSelector from "../../worktree-selector"
|
||||
import AgentSelector from "../../agent-selector"
|
||||
import ModelSelector from "../../model-selector"
|
||||
import ThinkingSelector from "../../thinking-selector"
|
||||
import { getLogger } from "../../../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface SessionSidebarProps {
|
||||
t: (key: string) => string
|
||||
instanceId: string
|
||||
threads: Accessor<SessionThread[]>
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSession: Accessor<Session | null>
|
||||
|
||||
showSearch: Accessor<boolean>
|
||||
onToggleSearch: () => void
|
||||
|
||||
keyboardShortcuts: Accessor<KeyboardShortcut[]>
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
drawerState: Accessor<DrawerViewState>
|
||||
leftPinned: Accessor<boolean>
|
||||
|
||||
onSelectSession: (sessionId: string) => void
|
||||
onNewSession: () => Promise<void> | void
|
||||
onSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||
onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onPinLeftDrawer: () => void
|
||||
onUnpinLeftDrawer: () => void
|
||||
onCloseLeftDrawer: () => void
|
||||
|
||||
setContentEl: (el: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||
|
||||
<AgentSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.onSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.onSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||
|
||||
<KeyboardHint
|
||||
class="session-sidebar-selector-hints"
|
||||
ariaHidden={true}
|
||||
shortcuts={[
|
||||
keyboardRegistry.get("open-agent-selector"),
|
||||
keyboardRegistry.get("focus-model"),
|
||||
keyboardRegistry.get("focus-variant"),
|
||||
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
|
||||
separator=" "
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default SessionSidebar
|
||||
@@ -0,0 +1,829 @@
|
||||
import {
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
type Component,
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
|
||||
import type { Instance } from "../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||
import type { Session } from "../../../../types/session"
|
||||
import type { DrawerViewState } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
|
||||
|
||||
import ChangesTab from "./tabs/ChangesTab"
|
||||
import FilesTab from "./tabs/FilesTab"
|
||||
import GitChangesTab from "./tabs/GitChangesTab"
|
||||
import StatusTab from "./tabs/StatusTab"
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||
readStoredBool,
|
||||
readStoredEnum,
|
||||
readStoredPanelWidth,
|
||||
readStoredRightPanelTab,
|
||||
} from "../storage"
|
||||
|
||||
interface RightPanelProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
instanceId: string
|
||||
instance: Instance
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSession: Accessor<Session | null>
|
||||
activeSessionDiffs: Accessor<any[] | undefined>
|
||||
|
||||
latestTodoState: Accessor<ToolState | null>
|
||||
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
rightDrawerWidth: Accessor<number>
|
||||
rightDrawerWidthInitialized: Accessor<boolean>
|
||||
rightDrawerState: Accessor<DrawerViewState>
|
||||
rightPinned: Accessor<boolean>
|
||||
onCloseRightDrawer: () => void
|
||||
onPinRightDrawer: () => void
|
||||
onUnpinRightDrawer: () => void
|
||||
|
||||
setContentEl: (el: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||
"plan",
|
||||
"background-processes",
|
||||
"mcp",
|
||||
"lsp",
|
||||
"plugins",
|
||||
])
|
||||
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
|
||||
|
||||
const [browserPath, setBrowserPath] = createSignal(".")
|
||||
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
|
||||
const [browserLoading, setBrowserLoading] = createSignal(false)
|
||||
const [browserError, setBrowserError] = createSignal<string | null>(null)
|
||||
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||
)
|
||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||
)
|
||||
|
||||
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||
const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
|
||||
const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null)
|
||||
const [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
|
||||
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
|
||||
|
||||
const [filesListOpen, setFilesListOpen] = createSignal(true)
|
||||
const [filesListTouched, setFilesListTouched] = createSignal(false)
|
||||
const [changesListOpen, setChangesListOpen] = createSignal(true)
|
||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||
|
||||
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
||||
|
||||
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
|
||||
const layout = listLayoutKey()
|
||||
if (tab === "changes") {
|
||||
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
|
||||
}
|
||||
if (tab === "git-changes") {
|
||||
return layout === "phone"
|
||||
? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY
|
||||
: RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY
|
||||
}
|
||||
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
|
||||
}
|
||||
|
||||
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||
const layout = listLayoutKey()
|
||||
layout
|
||||
|
||||
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
|
||||
if (filesPersisted !== null) {
|
||||
setFilesListOpen(filesPersisted)
|
||||
setFilesListTouched(true)
|
||||
} else {
|
||||
setFilesListOpen(true)
|
||||
setFilesListTouched(false)
|
||||
}
|
||||
|
||||
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
|
||||
if (changesPersisted !== null) {
|
||||
setChangesListOpen(changesPersisted)
|
||||
setChangesListTouched(true)
|
||||
} else {
|
||||
setChangesListOpen(true)
|
||||
setChangesListTouched(false)
|
||||
}
|
||||
|
||||
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
|
||||
if (gitPersisted !== null) {
|
||||
setGitChangesListOpen(gitPersisted)
|
||||
setGitChangesListTouched(true)
|
||||
} else {
|
||||
setGitChangesListOpen(true)
|
||||
setGitChangesListTouched(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Default behavior: when nothing is selected, keep the file list open.
|
||||
// Once the user explicitly toggles it, we stop auto-opening.
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (filesListTouched()) return
|
||||
if (!browserSelectedPath()) {
|
||||
setFilesListOpen(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
||||
})
|
||||
|
||||
const clampSplitWidth = (value: number) => {
|
||||
const min = 200
|
||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||
const max = Math.min(560, maxByDrawer)
|
||||
return Math.min(max, Math.max(min, Math.floor(value)))
|
||||
}
|
||||
|
||||
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (splitWidthsInitialized()) return
|
||||
if (!props.rightDrawerWidthInitialized()) return
|
||||
setSplitWidthsInitialized(true)
|
||||
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
|
||||
setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||
})
|
||||
|
||||
const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
|
||||
if (typeof window === "undefined") return
|
||||
const key =
|
||||
mode === "changes"
|
||||
? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY
|
||||
: mode === "git-changes"
|
||||
? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY
|
||||
: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
|
||||
window.localStorage.setItem(key, String(width))
|
||||
}
|
||||
|
||||
function stopSplitResize() {
|
||||
setActiveSplitResize(null)
|
||||
if (typeof document === "undefined") return
|
||||
splitPointerDrag.stop()
|
||||
}
|
||||
|
||||
function splitMouseMove(event: MouseEvent) {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
event.preventDefault()
|
||||
const delta = event.clientX - splitResizeStartX()
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
else setFilesSplitWidth(next)
|
||||
}
|
||||
|
||||
function splitMouseUp() {
|
||||
const mode = activeSplitResize()
|
||||
if (mode) {
|
||||
const width =
|
||||
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||
persistSplitWidth(mode, width)
|
||||
}
|
||||
stopSplitResize()
|
||||
}
|
||||
|
||||
function splitTouchMove(event: TouchEvent) {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
const delta = touch.clientX - splitResizeStartX()
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
else setFilesSplitWidth(next)
|
||||
}
|
||||
|
||||
function splitTouchEnd() {
|
||||
const mode = activeSplitResize()
|
||||
if (mode) {
|
||||
const width =
|
||||
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||
persistSplitWidth(mode, width)
|
||||
}
|
||||
stopSplitResize()
|
||||
}
|
||||
|
||||
const splitPointerDrag = useGlobalPointerDrag({
|
||||
onMouseMove: splitMouseMove,
|
||||
onMouseUp: splitMouseUp,
|
||||
onTouchMove: splitTouchMove,
|
||||
onTouchEnd: splitTouchEnd,
|
||||
})
|
||||
|
||||
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
|
||||
if (typeof document === "undefined") return
|
||||
setActiveSplitResize(mode)
|
||||
setSplitResizeStartX(clientX)
|
||||
setSplitResizeStartWidth(
|
||||
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
|
||||
)
|
||||
splitPointerDrag.start()
|
||||
}
|
||||
|
||||
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
startSplitResize(mode, event.clientX)
|
||||
}
|
||||
|
||||
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
startSplitResize(mode, touch.clientX)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stopSplitResize()
|
||||
})
|
||||
|
||||
const worktreeSlugForViewer = createMemo(() => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (sessionId && sessionId !== "info") {
|
||||
return getWorktreeSlugForSession(props.instanceId, sessionId)
|
||||
}
|
||||
return getDefaultWorktreeSlug(props.instanceId)
|
||||
})
|
||||
|
||||
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||
|
||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||
|
||||
const gitMostChangedPath = createMemo<string | null>(() => {
|
||||
const entries = gitStatusEntries()
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null
|
||||
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
||||
if (candidates.length === 0) return null
|
||||
const best = candidates.reduce((currentBest, item) => {
|
||||
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
||||
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return currentBest
|
||||
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
||||
}, candidates[0])
|
||||
return typeof best?.path === "string" ? best.path : null
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Reset tab state when worktree context changes.
|
||||
worktreeSlugForViewer()
|
||||
setBrowserPath(".")
|
||||
setBrowserEntries(null)
|
||||
setBrowserError(null)
|
||||
setBrowserSelectedPath(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
|
||||
setGitStatusEntries(null)
|
||||
setGitStatusError(null)
|
||||
setGitStatusLoading(false)
|
||||
setGitSelectedPath(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
})
|
||||
|
||||
const loadGitStatus = async (force = false) => {
|
||||
if (!force && gitStatusEntries() !== null) return
|
||||
setGitStatusLoading(true)
|
||||
setGitStatusError(null)
|
||||
try {
|
||||
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
|
||||
setGitStatusEntries(Array.isArray(list) ? list : [])
|
||||
} catch (error) {
|
||||
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
|
||||
setGitStatusEntries([])
|
||||
} finally {
|
||||
setGitStatusLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openGitFile(path: string) {
|
||||
setGitSelectedPath(path)
|
||||
setGitSelectedLoading(true)
|
||||
setGitSelectedError(null)
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
|
||||
const list = gitStatusEntries() || []
|
||||
const entry = list.find((item) => item.path === path) || null
|
||||
if (entry?.status === "deleted") {
|
||||
setGitSelectedError("Deleted file diff is not available yet")
|
||||
setGitSelectedLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
setGitChangesListOpen(false)
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||
const type = (content as any)?.type
|
||||
const encoding = (content as any)?.encoding
|
||||
if (type && type !== "text") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
if (encoding === "base64") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
|
||||
if (afterText === null) {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
|
||||
setGitSelectedAfter(afterText)
|
||||
|
||||
if (entry?.status === "added") {
|
||||
setGitSelectedBefore("")
|
||||
return
|
||||
}
|
||||
|
||||
const diffText =
|
||||
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
|
||||
? String((content as any).diff)
|
||||
: (content as any)?.patch
|
||||
? buildUnifiedDiffFromSdkPatch((content as any).patch)
|
||||
: ""
|
||||
|
||||
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
|
||||
if (beforeText === null) {
|
||||
throw new Error("Unable to calculate diff for this file")
|
||||
}
|
||||
setGitSelectedBefore(beforeText)
|
||||
} catch (error) {
|
||||
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
|
||||
} finally {
|
||||
setGitSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
const entries = gitStatusEntries()
|
||||
if (entries === null) return
|
||||
if (gitSelectedPath()) return
|
||||
const next = gitMostChangedPath()
|
||||
if (!next) return
|
||||
void openGitFile(next)
|
||||
})
|
||||
|
||||
const refreshGitStatus = async () => {
|
||||
await loadGitStatus(true)
|
||||
const selected = gitSelectedPath()
|
||||
if (selected) {
|
||||
void openGitFile(selected)
|
||||
}
|
||||
}
|
||||
|
||||
const bestDiffFile = createMemo<string | null>(() => {
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||
const best = diffs.reduce((currentBest, item) => {
|
||||
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
|
||||
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
|
||||
const bestScore = bestAdd + bestDel
|
||||
|
||||
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||
const score = add + del
|
||||
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return currentBest
|
||||
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
|
||||
}, diffs[0])
|
||||
return typeof (best as any)?.file === "string" ? (best as any).file : null
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const next = bestDiffFile()
|
||||
if (!next) return
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return
|
||||
|
||||
const current = selectedFile()
|
||||
if (current && diffs.some((d) => d.file === current)) return
|
||||
setSelectedFile(next)
|
||||
})
|
||||
|
||||
const normalizeBrowserPath = (input: string) => {
|
||||
const raw = String(input || ".").trim()
|
||||
if (!raw || raw === "./") return "."
|
||||
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
return cleaned === "" ? "." : cleaned
|
||||
}
|
||||
|
||||
const getParentPath = (path: string): string | null => {
|
||||
const current = normalizeBrowserPath(path)
|
||||
if (current === ".") return null
|
||||
const parts = current.split("/").filter(Boolean)
|
||||
parts.pop()
|
||||
return parts.length ? parts.join("/") : "."
|
||||
}
|
||||
|
||||
const loadBrowserEntries = async (path: string) => {
|
||||
const normalized = normalizeBrowserPath(path)
|
||||
setBrowserLoading(true)
|
||||
setBrowserError(null)
|
||||
try {
|
||||
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
|
||||
setBrowserPath(normalized)
|
||||
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
|
||||
} catch (error) {
|
||||
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
|
||||
setBrowserEntries([])
|
||||
} finally {
|
||||
setBrowserLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openBrowserFile = async (path: string) => {
|
||||
setBrowserSelectedPath(path)
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedContent(null)
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
setFilesListOpen(false)
|
||||
}
|
||||
try {
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||
const type = (content as any)?.type
|
||||
const encoding = (content as any)?.encoding
|
||||
if (type && type !== "text") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
if (encoding === "base64") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
const text = (content as any)?.content
|
||||
if (typeof text !== "string") {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
setBrowserSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (browserLoading()) return
|
||||
if (browserEntries() !== null) return
|
||||
void loadBrowserEntries(browserPath())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
if (gitStatusLoading()) return
|
||||
if (gitStatusEntries() !== null) return
|
||||
void loadGitStatus()
|
||||
})
|
||||
|
||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||
setSelectedFile(file)
|
||||
if (closeList) {
|
||||
setChangesListOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChangesList = () => {
|
||||
setChangesListTouched(true)
|
||||
setChangesListOpen((current) => {
|
||||
const next = !current
|
||||
persistListOpen("changes", next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFilesList = () => {
|
||||
setFilesListTouched(true)
|
||||
setFilesListOpen((current) => {
|
||||
const next = !current
|
||||
persistListOpen("files", next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleGitList = () => {
|
||||
setGitChangesListTouched(true)
|
||||
setGitChangesListOpen((current) => {
|
||||
const next = !current
|
||||
persistListOpen("git-changes", next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const refreshFilesTab = async () => {
|
||||
void loadBrowserEntries(browserPath())
|
||||
const selected = browserSelectedPath()
|
||||
if (selected) {
|
||||
// Refresh file content without altering overlay state.
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
try {
|
||||
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
|
||||
const type = (content as any)?.type
|
||||
const encoding = (content as any)?.encoding
|
||||
if (type && type !== "text") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
if (encoding === "base64") {
|
||||
throw new Error("Binary file cannot be displayed")
|
||||
}
|
||||
const text = (content as any)?.content
|
||||
if (typeof text !== "string") {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
setBrowserSelectedLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const browserParentPath = createMemo(() => getParentPath(browserPath()))
|
||||
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
|
||||
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
|
||||
|
||||
const openChangesTabFromStatus = (file?: string) => {
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
}
|
||||
setRightPanelTab("changes")
|
||||
}
|
||||
|
||||
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
|
||||
createEffect(() => {
|
||||
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
|
||||
setRightPanelExpandedItems(statusSectionIds)
|
||||
})
|
||||
|
||||
const handleAccordionChange = (values: string[]) => {
|
||||
setRightPanelExpandedItems(values)
|
||||
}
|
||||
|
||||
const tabClass = (tab: RightPanelTab) =>
|
||||
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full" ref={props.setContentEl}>
|
||||
<div class="right-panel-tab-bar">
|
||||
<div class="tab-container">
|
||||
<div class="tab-strip-shortcuts text-primary">
|
||||
<Show when={props.rightDrawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||
onClick={props.onCloseRightDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
|
||||
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
|
||||
>
|
||||
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("changes")}
|
||||
aria-selected={rightPanelTab() === "changes"}
|
||||
onClick={() => setRightPanelTab("changes")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("git-changes")}
|
||||
aria-selected={rightPanelTab() === "git-changes"}
|
||||
onClick={() => setRightPanelTab("git-changes")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("files")}
|
||||
aria-selected={rightPanelTab() === "files"}
|
||||
onClick={() => setRightPanelTab("files")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class={tabClass("status")}
|
||||
aria-selected={rightPanelTab() === "status"}
|
||||
onClick={() => setRightPanelTab("status")}
|
||||
>
|
||||
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip-spacer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={rightPanelTab() === "changes"}>
|
||||
<ChangesTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "git-changes"}>
|
||||
<GitChangesTab
|
||||
t={props.t}
|
||||
activeSessionId={props.activeSessionId}
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onOpenFile={(path) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "files"}>
|
||||
<FilesTab
|
||||
t={props.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path) => void openBrowserFile(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "status"}>
|
||||
<StatusTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
instance={props.instance}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSession={props.activeSession}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
latestTodoState={props.latestTodoState}
|
||||
backgroundProcessList={props.backgroundProcessList}
|
||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||
expandedItems={rightPanelExpandedItems}
|
||||
onExpandedItemsChange={handleAccordionChange}
|
||||
onOpenChangesTab={openChangesTabFromStatus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RightPanel
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
viewMode: DiffViewMode
|
||||
contextMode: DiffContextMode
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
}
|
||||
|
||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||
return (
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "split"}
|
||||
onClick={() => props.onViewModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "unified"}
|
||||
onClick={() => props.onViewModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "collapsed"}
|
||||
onClick={() => props.onContextModeChange("collapsed")}
|
||||
title="Hide unchanged regions"
|
||||
>
|
||||
Collapsed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "expanded"}
|
||||
onClick={() => props.onContextModeChange("expanded")}
|
||||
title="Show full file"
|
||||
>
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiffToolbar
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
|
||||
interface OverlayListProps {
|
||||
ariaLabel: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const OverlayList: Component<OverlayListProps> = (props) => {
|
||||
return (
|
||||
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
|
||||
<div class="file-list-scroll">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverlayList
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
|
||||
import OverlayList from "./OverlayList"
|
||||
|
||||
type SplitFilePanelList = {
|
||||
panel: () => JSX.Element
|
||||
overlay: () => JSX.Element
|
||||
}
|
||||
|
||||
interface SplitFilePanelProps {
|
||||
header: JSX.Element
|
||||
list: SplitFilePanelList
|
||||
viewer: JSX.Element
|
||||
|
||||
listOpen: boolean
|
||||
onToggleList: () => void
|
||||
|
||||
splitWidth: number
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
|
||||
isPhoneLayout: boolean
|
||||
overlayAriaLabel: string
|
||||
}
|
||||
|
||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||
return (
|
||||
<div class="files-tab-container">
|
||||
<div class="files-tab-header">
|
||||
<div class="files-tab-header-row">
|
||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||
{props.listOpen ? "Hide files" : "Show files"}
|
||||
</button>
|
||||
|
||||
{props.header}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files-tab-body">
|
||||
<Show
|
||||
when={!props.isPhoneLayout && props.listOpen}
|
||||
fallback={props.viewer}
|
||||
>
|
||||
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
|
||||
<div class="file-list-panel">
|
||||
<div class="file-list-scroll">{props.list.panel()}</div>
|
||||
</div>
|
||||
<div
|
||||
class="file-split-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize file list"
|
||||
onMouseDown={props.onResizeMouseDown}
|
||||
onTouchStart={props.onResizeTouchStart}
|
||||
/>
|
||||
{props.viewer}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.isPhoneLayout}>
|
||||
<Show when={props.listOpen}>
|
||||
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitFilePanel
|
||||
@@ -0,0 +1,203 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
instanceId: string
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSessionDiffs: Accessor<any[] | undefined>
|
||||
|
||||
selectedFile: Accessor<string | null>
|
||||
onSelectFile: (file: string, closeList: boolean) => void
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
|
||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||
const diffs = hasSession ? props.activeSessionDiffs() : null
|
||||
|
||||
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
const mostChanged = sorted.length
|
||||
? sorted.reduce((best, item) => {
|
||||
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
||||
const bestScore = bestAdd + bestDel
|
||||
|
||||
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||
const score = add + del
|
||||
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return best
|
||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||
}, sorted[0])
|
||||
: null
|
||||
|
||||
// Auto-select the most-changed file if none selected.
|
||||
const currentSelected = props.selectedFile()
|
||||
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
||||
|
||||
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderEmptyList = () => (
|
||||
<div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
)
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||
}}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.file}>
|
||||
<span class="file-path-text">{item.file}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="file-list-item-additions">+{item.additions}</span>
|
||||
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onSelectFile(item.file, true)
|
||||
}}
|
||||
title={item.file}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.file}>
|
||||
<span class="file-path-text">{item.file}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="file-list-item-additions">+{item.additions}</span>
|
||||
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={headerPath()}>
|
||||
<span class="file-path-text">{headerPath()}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
<span class="files-tab-stat files-tab-stat-additions">
|
||||
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||
</span>
|
||||
<span class="files-tab-stat files-tab-stat-deletions">
|
||||
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Changes"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default ChangesTab
|
||||
@@ -0,0 +1,191 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
browserPath: Accessor<string>
|
||||
browserEntries: Accessor<FileNode[] | null>
|
||||
browserLoading: Accessor<boolean>
|
||||
browserError: Accessor<string | null>
|
||||
|
||||
browserSelectedPath: Accessor<string | null>
|
||||
browserSelectedContent: Accessor<string | null>
|
||||
browserSelectedLoading: Accessor<boolean>
|
||||
browserSelectedError: Accessor<string | null>
|
||||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const entriesValue = props.browserEntries()
|
||||
const entries = entriesValue || []
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
const aDir = a.type === "directory" ? 0 : 1
|
||||
const bDir = b.type === "directory" ? 0 : 1
|
||||
if (aDir !== bDir) return aDir - bDir
|
||||
return String(a.name || "").localeCompare(String(b.name || ""))
|
||||
})
|
||||
|
||||
const parent = props.parentPath()
|
||||
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||
return "Select a file to preview"
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={props.browserSelectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.browserSelectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
props.browserSelectedPath() && props.browserSelectedContent() !== null
|
||||
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
|
||||
: null
|
||||
}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(err) => (
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{err()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderList = () => (
|
||||
<>
|
||||
<Show when={parent}>
|
||||
{(p) => (
|
||||
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={p()}>
|
||||
<span class="file-path-text">..</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={props.browserLoading() && entriesValue === null}>
|
||||
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||
</Show>
|
||||
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
if (item.type === "directory") {
|
||||
props.onLoadEntries(item.path)
|
||||
return
|
||||
}
|
||||
props.onOpenFile(item.path)
|
||||
}}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.name}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<div class="files-tab-stats">
|
||||
<span class="files-tab-stat">
|
||||
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
|
||||
<span class="file-path-text">{headerDisplayedPath()}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>Loading…</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-left": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderList, overlay: renderList }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Files"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default FilesTab
|
||||
@@ -0,0 +1,258 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
|
||||
entries: Accessor<GitFileStatus[] | null>
|
||||
statusLoading: Accessor<boolean>
|
||||
statusError: Accessor<string | null>
|
||||
|
||||
selectedPath: Accessor<string | null>
|
||||
selectedLoading: Accessor<boolean>
|
||||
selectedError: Accessor<string | null>
|
||||
selectedBefore: Accessor<string | null>
|
||||
selectedAfter: Accessor<string | null>
|
||||
mostChangedPath: Accessor<string | null>
|
||||
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
|
||||
onOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
splitWidth: Accessor<number>
|
||||
onResizeMouseDown: (event: MouseEvent) => void
|
||||
onResizeTouchStart: (event: TouchEvent) => void
|
||||
isPhoneLayout: Accessor<boolean>
|
||||
}
|
||||
|
||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
|
||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||
const entries = hasSession ? props.entries() : null
|
||||
|
||||
const sorted = Array.isArray(entries)
|
||||
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||
: []
|
||||
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (!hasSession) return "Select a session to view changes."
|
||||
if (entries === null) return "Loading git changes…"
|
||||
if (nonDeleted.length === 0) return "No git changes yet."
|
||||
return "No file selected."
|
||||
}
|
||||
|
||||
const selectedPath = props.selectedPath()
|
||||
const fallbackPath = props.mostChangedPath()
|
||||
const selectedEntry =
|
||||
sorted.find((item) => item.path === selectedPath) ||
|
||||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
<div class="file-viewer-header">
|
||||
<DiffToolbar
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
onViewModeChange={props.onViewModeChange}
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="file-viewer-content file-viewer-content--monaco">
|
||||
<Show
|
||||
when={props.selectedLoading()}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.selectedError()}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
selectedEntry &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
selectedEntry.status !== "deleted"
|
||||
? {
|
||||
path: selectedEntry.path,
|
||||
before: props.selectedBefore() as string,
|
||||
after: props.selectedAfter() as string,
|
||||
}
|
||||
: null
|
||||
}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={String(file().path || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(err) => (
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{err()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => {
|
||||
props.onOpenFile(item.path)
|
||||
}}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
<span class="file-list-item-additions">+{item.added}</span>
|
||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
onClick={() => props.onOpenFile(item.path)}
|
||||
title={item.path}
|
||||
>
|
||||
<div class="file-list-item-content">
|
||||
<div class="file-list-item-path" title={item.path}>
|
||||
<span class="file-path-text">{item.path}</span>
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
<span class="file-list-item-additions">+{item.added}</span>
|
||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
<span class="files-tab-stat files-tab-stat-additions">
|
||||
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||
</span>
|
||||
<span class="files-tab-stat files-tab-stat-deletions">
|
||||
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||
</span>
|
||||
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={!hasSession || props.statusLoading() || entries === null}
|
||||
style={{ "margin-left": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
splitWidth={props.splitWidth()}
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Git Changes"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default GitChangesTab
|
||||
@@ -0,0 +1,294 @@
|
||||
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
|
||||
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||
import type { Session } from "../../../../../types/session"
|
||||
|
||||
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||
|
||||
interface StatusTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
instanceId: string
|
||||
instance: Instance
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
activeSession: Accessor<Session | null>
|
||||
activeSessionDiffs: Accessor<any[] | undefined>
|
||||
|
||||
latestTodoState: Accessor<ToolState | null>
|
||||
|
||||
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||
|
||||
expandedItems: Accessor<string[]>
|
||||
onExpandedItemsChange: (values: string[]) => void
|
||||
|
||||
onOpenChangesTab: (file?: string) => void
|
||||
}
|
||||
|
||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||
|
||||
const renderStatusSessionChanges = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const diffs = props.activeSessionDiffs()
|
||||
if (diffs === undefined) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
return acc
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3 min-h-0">
|
||||
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
|
||||
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
|
||||
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
|
||||
<div class="flex flex-col">
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
|
||||
onClick={() => props.onOpenChangesTab(item.file)}
|
||||
title={props.t("instanceShell.sessionChanges.actions.show")}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div
|
||||
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
||||
title={item.file}
|
||||
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
|
||||
>
|
||||
{item.file}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
|
||||
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
|
||||
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPlanSectionContent = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const todoState = props.latestTodoState()
|
||||
if (!todoState) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
|
||||
}
|
||||
|
||||
const renderBackgroundProcesses = () => {
|
||||
const processes = props.backgroundProcessList()
|
||||
if (processes.length === 0) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={processes}>
|
||||
{(process) => (
|
||||
<div class="status-process-card">
|
||||
<div class="status-process-header">
|
||||
<span class="status-process-title">{process.title}</span>
|
||||
<div class="status-process-meta">
|
||||
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||
<span>
|
||||
{props.t("instanceShell.backgroundProcesses.output", {
|
||||
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
||||
})}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-process-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenBackgroundOutput(process)}
|
||||
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||
title={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||
>
|
||||
<TerminalSquare class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
disabled={process.status !== "running"}
|
||||
onClick={() => props.onStopBackgroundProcess(process.id)}
|
||||
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||
>
|
||||
<XOctagon class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => props.onTerminateBackgroundProcess(process.id)}
|
||||
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusSections = [
|
||||
{
|
||||
id: "session-changes",
|
||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||
render: renderStatusSessionChanges,
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||
render: renderPlanSectionContent,
|
||||
},
|
||||
{
|
||||
id: "background-processes",
|
||||
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||
render: renderBackgroundProcesses,
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["mcp"]}
|
||||
showSectionHeadings={false}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lsp",
|
||||
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["lsp"]}
|
||||
showSectionHeadings={false}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "plugins",
|
||||
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
sections={["plugins"]}
|
||||
showSectionHeadings={false}
|
||||
class="space-y-2"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div class="status-tab-container">
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Accordion.Root
|
||||
class="right-panel-accordion"
|
||||
collapsible
|
||||
multiple
|
||||
value={props.expandedItems()}
|
||||
onChange={props.onExpandedItemsChange}
|
||||
>
|
||||
<For each={statusSections}>
|
||||
{(section) => (
|
||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span>{props.t(section.labelKey)}</span>
|
||||
<ChevronDown
|
||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusTab
|
||||
@@ -0,0 +1,5 @@
|
||||
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
|
||||
export type DiffContextMode = "expanded" | "collapsed"
|
||||
92
packages/ui/src/components/instance/shell/storage.ts
Normal file
92
packages/ui/src/components/instance/shell/storage.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
|
||||
export const MIN_SESSION_SIDEBAR_WIDTH = 220
|
||||
export const MAX_SESSION_SIDEBAR_WIDTH = 400
|
||||
|
||||
export const RIGHT_DRAWER_WIDTH = 260
|
||||
export const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||
export const MAX_RIGHT_DRAWER_WIDTH = 1200
|
||||
|
||||
export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||
export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||
export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
|
||||
export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
|
||||
export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
|
||||
export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
|
||||
export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1"
|
||||
export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1"
|
||||
export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1"
|
||||
export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1"
|
||||
export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
|
||||
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
|
||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
||||
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||
|
||||
export const clampWidth = (value: number) =>
|
||||
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||
|
||||
export const clampRightWidth = (value: number) => {
|
||||
const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH
|
||||
const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax)
|
||||
return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value))
|
||||
}
|
||||
|
||||
const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY)
|
||||
|
||||
export function readStoredPinState(side: "left" | "right", defaultValue: boolean) {
|
||||
if (typeof window === "undefined") return defaultValue
|
||||
const stored = window.localStorage.getItem(getPinStorageKey(side))
|
||||
if (stored === "true") return true
|
||||
if (stored === "false") return false
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
export function persistPinState(side: "left" | "right", value: boolean) {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false")
|
||||
}
|
||||
|
||||
export function readStoredRightPanelTab(
|
||||
defaultValue: "changes" | "git-changes" | "files" | "status",
|
||||
): "changes" | "git-changes" | "files" | "status" {
|
||||
if (typeof window === "undefined") return defaultValue
|
||||
|
||||
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||
if (stored === "status") return "status"
|
||||
if (stored === "changes") return "changes"
|
||||
if (stored === "git-changes") return "git-changes"
|
||||
if (stored === "files") return "files"
|
||||
|
||||
// Migrate from v1 (where the stored values were the internal tab ids).
|
||||
const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||
if (legacy === "status") return "status"
|
||||
if (legacy === "browser") return "files"
|
||||
if (legacy === "files") return "changes"
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
export function readStoredPanelWidth(key: string, fallback: number) {
|
||||
if (typeof window === "undefined") return fallback
|
||||
const stored = window.localStorage.getItem(key)
|
||||
if (!stored) return fallback
|
||||
const parsed = Number.parseInt(stored, 10)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
export function readStoredBool(key: string): boolean | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const stored = window.localStorage.getItem(key)
|
||||
if (stored === "true") return true
|
||||
if (stored === "false") return false
|
||||
return null
|
||||
}
|
||||
|
||||
export function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const stored = window.localStorage.getItem(key)
|
||||
if (!stored) return null
|
||||
return (allowed as readonly string[]).includes(stored) ? (stored as T) : null
|
||||
}
|
||||
3
packages/ui/src/components/instance/shell/types.ts
Normal file
3
packages/ui/src/components/instance/shell/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type LayoutMode = "desktop" | "tablet" | "phone"
|
||||
|
||||
export type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
|
||||
260
packages/ui/src/components/instance/shell/useDrawerChrome.ts
Normal file
260
packages/ui/src/components/instance/shell/useDrawerChrome.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
batch,
|
||||
createComponent,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
type Setter,
|
||||
} from "solid-js"
|
||||
import MenuIcon from "@suid/icons-material/Menu"
|
||||
|
||||
import type { TranslateParams } from "../../../lib/i18n"
|
||||
|
||||
import type { DrawerViewState, LayoutMode } from "./types"
|
||||
import { persistPinState, readStoredPinState } from "./storage"
|
||||
|
||||
export interface UseDrawerChromeOptions {
|
||||
t: (key: string, params?: TranslateParams) => string
|
||||
layoutMode: Accessor<LayoutMode>
|
||||
leftPinningSupported: Accessor<boolean>
|
||||
rightPinningSupported: Accessor<boolean>
|
||||
leftDrawerContentEl: Accessor<HTMLElement | null>
|
||||
rightDrawerContentEl: Accessor<HTMLElement | null>
|
||||
leftToggleButtonEl: Accessor<HTMLElement | null>
|
||||
rightToggleButtonEl: Accessor<HTMLElement | null>
|
||||
measureDrawerHost?: () => void
|
||||
}
|
||||
|
||||
export interface DrawerChromeApi {
|
||||
leftPinned: Accessor<boolean>
|
||||
leftOpen: Accessor<boolean>
|
||||
rightPinned: Accessor<boolean>
|
||||
rightOpen: Accessor<boolean>
|
||||
setLeftOpen: Setter<boolean>
|
||||
setRightOpen: Setter<boolean>
|
||||
leftDrawerState: Accessor<DrawerViewState>
|
||||
rightDrawerState: Accessor<DrawerViewState>
|
||||
pinLeft: () => void
|
||||
unpinLeft: () => void
|
||||
pinRight: () => void
|
||||
unpinRight: () => void
|
||||
closeLeft: () => void
|
||||
closeRight: () => void
|
||||
leftAppBarButtonLabel: Accessor<string>
|
||||
rightAppBarButtonLabel: Accessor<string>
|
||||
leftAppBarButtonIcon: Accessor<JSX.Element>
|
||||
rightAppBarButtonIcon: Accessor<JSX.Element>
|
||||
handleLeftAppBarButtonClick: () => void
|
||||
handleRightAppBarButtonClick: () => void
|
||||
}
|
||||
|
||||
export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi {
|
||||
const [leftPinned, setLeftPinned] = createSignal(true)
|
||||
const [leftOpen, setLeftOpen] = createSignal(true)
|
||||
const [rightPinned, setRightPinned] = createSignal(true)
|
||||
const [rightOpen, setRightOpen] = createSignal(true)
|
||||
|
||||
const measureDrawerHost = () => options.measureDrawerHost?.()
|
||||
|
||||
const focusTarget = (element: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
requestAnimationFrame(() => {
|
||||
element.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const blurIfInside = (element: HTMLElement | null) => {
|
||||
if (typeof document === "undefined" || !element) return
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active && element.contains(active)) {
|
||||
active.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
|
||||
if (side === "left" && !options.leftPinningSupported()) return
|
||||
if (side === "right" && !options.rightPinningSupported()) return
|
||||
persistPinState(side, value)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
switch (options.layoutMode()) {
|
||||
case "desktop": {
|
||||
const leftSaved = readStoredPinState("left", true)
|
||||
const rightSaved = readStoredPinState("right", true)
|
||||
setLeftPinned(leftSaved)
|
||||
setLeftOpen(leftSaved)
|
||||
setRightPinned(rightSaved)
|
||||
setRightOpen(rightSaved)
|
||||
break
|
||||
}
|
||||
case "tablet": {
|
||||
setLeftPinned(true)
|
||||
setLeftOpen(true)
|
||||
setRightPinned(false)
|
||||
setRightOpen(false)
|
||||
break
|
||||
}
|
||||
default:
|
||||
setLeftPinned(false)
|
||||
setLeftOpen(false)
|
||||
setRightPinned(false)
|
||||
setRightOpen(false)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const leftDrawerState = createMemo<DrawerViewState>(() => {
|
||||
if (leftPinned()) return "pinned"
|
||||
return leftOpen() ? "floating-open" : "floating-closed"
|
||||
})
|
||||
|
||||
const rightDrawerState = createMemo<DrawerViewState>(() => {
|
||||
if (rightPinned()) return "pinned"
|
||||
return rightOpen() ? "floating-open" : "floating-closed"
|
||||
})
|
||||
|
||||
const leftAppBarButtonLabel = () => {
|
||||
const state = leftDrawerState()
|
||||
if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned")
|
||||
return options.t("instanceShell.leftDrawer.toggle.open")
|
||||
}
|
||||
|
||||
const rightAppBarButtonLabel = () => {
|
||||
const state = rightDrawerState()
|
||||
if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned")
|
||||
return options.t("instanceShell.rightDrawer.toggle.open")
|
||||
}
|
||||
|
||||
const leftAppBarButtonIcon = () => {
|
||||
return createComponent(MenuIcon, { fontSize: "small" })
|
||||
}
|
||||
|
||||
const rightAppBarButtonIcon = () => {
|
||||
return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } })
|
||||
}
|
||||
|
||||
const pinLeft = () => {
|
||||
blurIfInside(options.leftDrawerContentEl())
|
||||
batch(() => {
|
||||
setLeftPinned(true)
|
||||
setLeftOpen(true)
|
||||
})
|
||||
persistPinIfSupported("left", true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const unpinLeft = () => {
|
||||
blurIfInside(options.leftDrawerContentEl())
|
||||
batch(() => {
|
||||
setLeftPinned(false)
|
||||
setLeftOpen(true)
|
||||
})
|
||||
persistPinIfSupported("left", false)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const pinRight = () => {
|
||||
blurIfInside(options.rightDrawerContentEl())
|
||||
batch(() => {
|
||||
setRightPinned(true)
|
||||
setRightOpen(true)
|
||||
})
|
||||
persistPinIfSupported("right", true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const unpinRight = () => {
|
||||
blurIfInside(options.rightDrawerContentEl())
|
||||
batch(() => {
|
||||
setRightPinned(false)
|
||||
setRightOpen(true)
|
||||
})
|
||||
persistPinIfSupported("right", false)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const handleLeftAppBarButtonClick = () => {
|
||||
const state = leftDrawerState()
|
||||
if (state !== "floating-closed") return
|
||||
setLeftOpen(true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const handleRightAppBarButtonClick = () => {
|
||||
const state = rightDrawerState()
|
||||
if (state !== "floating-closed") return
|
||||
setRightOpen(true)
|
||||
measureDrawerHost()
|
||||
}
|
||||
|
||||
const closeLeft = () => {
|
||||
if (leftDrawerState() === "pinned") return
|
||||
blurIfInside(options.leftDrawerContentEl())
|
||||
setLeftOpen(false)
|
||||
focusTarget(options.leftToggleButtonEl())
|
||||
}
|
||||
|
||||
const closeRight = () => {
|
||||
if (rightDrawerState() === "pinned") return
|
||||
blurIfInside(options.rightDrawerContentEl())
|
||||
setRightOpen(false)
|
||||
focusTarget(options.rightToggleButtonEl())
|
||||
}
|
||||
|
||||
const closeFloatingDrawersIfAny = () => {
|
||||
let handled = false
|
||||
if (!leftPinned() && leftOpen()) {
|
||||
setLeftOpen(false)
|
||||
blurIfInside(options.leftDrawerContentEl())
|
||||
focusTarget(options.leftToggleButtonEl())
|
||||
handled = true
|
||||
}
|
||||
if (!rightPinned() && rightOpen()) {
|
||||
setRightOpen(false)
|
||||
blurIfInside(options.rightDrawerContentEl())
|
||||
focusTarget(options.rightToggleButtonEl())
|
||||
handled = true
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return
|
||||
if (!closeFloatingDrawersIfAny()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
window.addEventListener("keydown", handleEscape, true)
|
||||
onCleanup(() => window.removeEventListener("keydown", handleEscape, true))
|
||||
})
|
||||
|
||||
return {
|
||||
leftPinned,
|
||||
leftOpen,
|
||||
rightPinned,
|
||||
rightOpen,
|
||||
setLeftOpen,
|
||||
setRightOpen,
|
||||
leftDrawerState,
|
||||
rightDrawerState,
|
||||
pinLeft,
|
||||
unpinLeft,
|
||||
pinRight,
|
||||
unpinRight,
|
||||
closeLeft,
|
||||
closeRight,
|
||||
leftAppBarButtonLabel,
|
||||
rightAppBarButtonLabel,
|
||||
leftAppBarButtonIcon,
|
||||
rightAppBarButtonIcon,
|
||||
handleLeftAppBarButtonClick,
|
||||
handleRightAppBarButtonClick,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||
|
||||
type DrawerHostMeasure = {
|
||||
setDrawerHost: (element: HTMLElement) => void
|
||||
drawerContainer: () => HTMLElement | undefined
|
||||
measureDrawerHost: () => void
|
||||
floatingTopPx: () => string
|
||||
floatingHeight: () => string
|
||||
}
|
||||
|
||||
export function useDrawerHostMeasure(tabBarOffset: Accessor<number>): DrawerHostMeasure {
|
||||
const [drawerHost, setDrawerHost] = createSignal<HTMLElement | null>(null)
|
||||
const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0)
|
||||
const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0)
|
||||
|
||||
const storeDrawerHost = (element: HTMLElement) => {
|
||||
setDrawerHost(element)
|
||||
}
|
||||
|
||||
const measureDrawerHost = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const host = drawerHost()
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
setFloatingDrawerTop(rect.top)
|
||||
setFloatingDrawerHeight(Math.max(0, rect.height))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
tabBarOffset()
|
||||
if (typeof window === "undefined") return
|
||||
requestAnimationFrame(() => measureDrawerHost())
|
||||
})
|
||||
|
||||
const drawerContainer = () => {
|
||||
const host = drawerHost()
|
||||
if (host) return host
|
||||
if (typeof document !== "undefined") {
|
||||
return document.body
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fallbackDrawerTop = () => tabBarOffset()
|
||||
const floatingTop = () => {
|
||||
const measured = floatingDrawerTop()
|
||||
if (measured > 0) return measured
|
||||
return fallbackDrawerTop()
|
||||
}
|
||||
|
||||
const floatingTopPx = () => `${floatingTop()}px`
|
||||
const floatingHeight = () => {
|
||||
const measured = floatingDrawerHeight()
|
||||
if (measured > 0) return `${measured}px`
|
||||
return `calc(100% - ${floatingTop()}px)`
|
||||
}
|
||||
|
||||
return {
|
||||
setDrawerHost: storeDrawerHost,
|
||||
drawerContainer,
|
||||
measureDrawerHost,
|
||||
floatingTopPx,
|
||||
floatingHeight,
|
||||
}
|
||||
}
|
||||
113
packages/ui/src/components/instance/shell/useDrawerResize.ts
Normal file
113
packages/ui/src/components/instance/shell/useDrawerResize.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js"
|
||||
|
||||
import { useGlobalPointerDrag } from "./useGlobalPointerDrag"
|
||||
|
||||
type DrawerResizeSide = "left" | "right"
|
||||
|
||||
type DrawerResizeOptions = {
|
||||
sessionSidebarWidth: Accessor<number>
|
||||
rightDrawerWidth: Accessor<number>
|
||||
setSessionSidebarWidth: Setter<number>
|
||||
setRightDrawerWidth: Setter<number>
|
||||
clampLeft: (width: number) => number
|
||||
clampRight: (width: number) => number
|
||||
measureDrawerHost: () => void
|
||||
}
|
||||
|
||||
type DrawerResizeApi = {
|
||||
handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void
|
||||
handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void
|
||||
}
|
||||
|
||||
export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
||||
const [activeResizeSide, setActiveResizeSide] = createSignal<DrawerResizeSide | null>(null)
|
||||
const [resizeStartX, setResizeStartX] = createSignal(0)
|
||||
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
||||
|
||||
const scheduleDrawerMeasure = () => {
|
||||
if (typeof window === "undefined") {
|
||||
options.measureDrawerHost()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => options.measureDrawerHost())
|
||||
}
|
||||
|
||||
const applyDrawerWidth = (side: DrawerResizeSide, width: number) => {
|
||||
if (side === "left") {
|
||||
options.setSessionSidebarWidth(width)
|
||||
} else {
|
||||
options.setRightDrawerWidth(width)
|
||||
}
|
||||
scheduleDrawerMeasure()
|
||||
}
|
||||
|
||||
const handleDrawerPointerMove = (clientX: number) => {
|
||||
const side = activeResizeSide()
|
||||
if (!side) return
|
||||
const startWidth = resizeStartWidth()
|
||||
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const nextWidth = clamp(startWidth + delta)
|
||||
applyDrawerWidth(side, nextWidth)
|
||||
}
|
||||
|
||||
function drawerMouseMove(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
handleDrawerPointerMove(event.clientX)
|
||||
}
|
||||
|
||||
function drawerMouseUp() {
|
||||
stopDrawerResize()
|
||||
}
|
||||
|
||||
function drawerTouchMove(event: TouchEvent) {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
handleDrawerPointerMove(touch.clientX)
|
||||
}
|
||||
|
||||
function drawerTouchEnd() {
|
||||
stopDrawerResize()
|
||||
}
|
||||
|
||||
const drawerPointerDrag = useGlobalPointerDrag({
|
||||
onMouseMove: drawerMouseMove,
|
||||
onMouseUp: drawerMouseUp,
|
||||
onTouchMove: drawerTouchMove,
|
||||
onTouchEnd: drawerTouchEnd,
|
||||
})
|
||||
|
||||
function stopDrawerResize() {
|
||||
setActiveResizeSide(null)
|
||||
drawerPointerDrag.stop()
|
||||
}
|
||||
|
||||
const startDrawerResize = (side: DrawerResizeSide, clientX: number) => {
|
||||
setActiveResizeSide(side)
|
||||
setResizeStartX(clientX)
|
||||
setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth())
|
||||
drawerPointerDrag.start()
|
||||
}
|
||||
|
||||
const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
startDrawerResize(side, event.clientX)
|
||||
}
|
||||
|
||||
const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
startDrawerResize(side, touch.clientX)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stopDrawerResize()
|
||||
})
|
||||
|
||||
return {
|
||||
handleDrawerResizeMouseDown,
|
||||
handleDrawerResizeTouchStart,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type GlobalPointerDragHandlers = {
|
||||
onMouseMove: (event: MouseEvent) => void
|
||||
onMouseUp: (event: MouseEvent) => void
|
||||
onTouchMove: (event: TouchEvent) => void
|
||||
onTouchEnd: (event: TouchEvent) => void
|
||||
}
|
||||
|
||||
type GlobalPointerDrag = {
|
||||
start: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag {
|
||||
const start = () => {
|
||||
document.addEventListener("mousemove", handlers.onMouseMove)
|
||||
document.addEventListener("mouseup", handlers.onMouseUp)
|
||||
document.addEventListener("touchmove", handlers.onTouchMove, { passive: false })
|
||||
document.addEventListener("touchend", handlers.onTouchEnd)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
document.removeEventListener("mousemove", handlers.onMouseMove)
|
||||
document.removeEventListener("mouseup", handlers.onMouseUp)
|
||||
document.removeEventListener("touchmove", handlers.onTouchMove)
|
||||
document.removeEventListener("touchend", handlers.onTouchEnd)
|
||||
}
|
||||
|
||||
return { start, stop }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { Session } from "../../../types/session"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
} from "../../../stores/sessions"
|
||||
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||
import { getBackgroundProcesses } from "../../../stores/background-processes"
|
||||
import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types"
|
||||
|
||||
type InstanceSessionContextOptions = {
|
||||
instanceId: Accessor<string>
|
||||
}
|
||||
|
||||
type InstanceSessionContextState = {
|
||||
// Session collections and selections
|
||||
allInstanceSessions: Accessor<Map<string, Session>>
|
||||
sessionThreads: Accessor<ReturnType<typeof getSessionThreads>>
|
||||
activeSessions: Accessor<Map<string, SessionFamilyMember>>
|
||||
activeSessionIdForInstance: Accessor<string | null>
|
||||
parentSessionIdForInstance: Accessor<string | null>
|
||||
activeSessionForInstance: Accessor<SessionFamilyMember | null>
|
||||
activeSessionDiffs: Accessor<SessionFamilyMember["diff"] | undefined>
|
||||
|
||||
// Usage / info summaries
|
||||
activeSessionUsage: Accessor<SessionUsageState | null>
|
||||
activeSessionInfoDetails: Accessor<ReturnType<typeof getSessionInfo> | null>
|
||||
tokenStats: Accessor<{ used: number; avail: number | null }>
|
||||
|
||||
// Todo state
|
||||
latestTodoSnapshot: Accessor<LatestTodoSnapshot | null>
|
||||
latestTodoState: Accessor<ToolState | null>
|
||||
|
||||
// Background processes
|
||||
backgroundProcessList: Accessor<ReturnType<typeof getBackgroundProcesses>>
|
||||
|
||||
// Controller
|
||||
handleSessionSelect: (sessionId: string) => void
|
||||
}
|
||||
|
||||
type SessionFamilyMember = ReturnType<typeof getSessionFamily>[number]
|
||||
|
||||
export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState {
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId()))
|
||||
|
||||
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
|
||||
return sessions().get(options.instanceId()) ?? new Map()
|
||||
})
|
||||
|
||||
const sessionThreads = createMemo(() => getSessionThreads(options.instanceId()))
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(options.instanceId())
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
const sessionFamily = getSessionFamily(options.instanceId(), parentId)
|
||||
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||
})
|
||||
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
return activeSessionMap().get(options.instanceId()) || null
|
||||
})
|
||||
|
||||
const parentSessionIdForInstance = createMemo(() => {
|
||||
return activeParentSessionId().get(options.instanceId()) || null
|
||||
})
|
||||
|
||||
const activeSessionForInstance = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
return activeSessions().get(sessionId) ?? null
|
||||
})
|
||||
|
||||
const activeSessionDiffs = createMemo(() => {
|
||||
const session = activeSessionForInstance()
|
||||
return session?.diff
|
||||
})
|
||||
|
||||
const activeSessionUsage = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId) return null
|
||||
const store = messageStore()
|
||||
return store?.getSessionUsage(sessionId) ?? null
|
||||
})
|
||||
|
||||
const activeSessionInfoDetails = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId) return null
|
||||
return getSessionInfo(options.instanceId(), sessionId) ?? null
|
||||
})
|
||||
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = activeSessionUsage()
|
||||
const info = activeSessionInfoDetails()
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0,
|
||||
avail: info?.contextAvailableTokens ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const latestTodoSnapshot = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
const store = messageStore()
|
||||
if (!store) return null
|
||||
const snapshot = store.state.latestTodos[sessionId]
|
||||
return snapshot ?? null
|
||||
})
|
||||
|
||||
const latestTodoState = createMemo<ToolState | null>(() => {
|
||||
const snapshot = latestTodoSnapshot()
|
||||
if (!snapshot) return null
|
||||
const store = messageStore()
|
||||
if (!store) return null
|
||||
const message = store.getMessage(snapshot.messageId)
|
||||
if (!message) return null
|
||||
const partRecord = message.parts?.[snapshot.partId]
|
||||
const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState }
|
||||
if (!part || part.type !== "tool" || part.tool !== "todowrite") return null
|
||||
const state = part.state
|
||||
if (!state || state.status !== "completed") return null
|
||||
return state
|
||||
})
|
||||
|
||||
const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId()))
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
const instanceId = options.instanceId()
|
||||
if (sessionId === "info") {
|
||||
setActiveSession(instanceId, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const session = allInstanceSessions().get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (session.parentId === null) {
|
||||
setActiveParentSession(instanceId, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) return
|
||||
|
||||
batch(() => {
|
||||
setActiveParentSession(instanceId, parentId)
|
||||
setActiveSession(instanceId, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
allInstanceSessions,
|
||||
sessionThreads,
|
||||
activeSessions,
|
||||
activeSessionIdForInstance,
|
||||
parentSessionIdForInstance,
|
||||
activeSessionForInstance,
|
||||
activeSessionDiffs,
|
||||
activeSessionUsage,
|
||||
activeSessionInfoDetails,
|
||||
tokenStats,
|
||||
latestTodoSnapshot,
|
||||
latestTodoState,
|
||||
backgroundProcessList,
|
||||
handleSessionSelect,
|
||||
}
|
||||
}
|
||||
99
packages/ui/src/components/instance/shell/useSessionCache.ts
Normal file
99
packages/ui/src/components/instance/shell/useSessionCache.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||
import { clearSessionRenderCache } from "../../message-block"
|
||||
import { getLogger } from "../../../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
const SESSION_CACHE_LIMIT = 5
|
||||
|
||||
type SessionCacheOptions = {
|
||||
instanceId: Accessor<string>
|
||||
instanceSessions: Accessor<Map<string, unknown>>
|
||||
activeSessionId: Accessor<string | null>
|
||||
}
|
||||
|
||||
type SessionCacheState = {
|
||||
cachedSessionIds: Accessor<string[]>
|
||||
}
|
||||
|
||||
export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
|
||||
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
|
||||
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
|
||||
|
||||
const evictSession = (sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
const instanceId = options.instanceId()
|
||||
log.info("Evicting cached session", { instanceId, sessionId })
|
||||
const store = messageStoreBus.getInstance(instanceId)
|
||||
store?.clearSession(sessionId)
|
||||
clearSessionRenderCache(instanceId, sessionId)
|
||||
}
|
||||
|
||||
const scheduleEvictions = (ids: string[]) => {
|
||||
if (!ids.length) return
|
||||
setPendingEvictions((current) => {
|
||||
const existing = new Set(current)
|
||||
const next = [...current]
|
||||
ids.forEach((id) => {
|
||||
if (!existing.has(id)) {
|
||||
next.push(id)
|
||||
existing.add(id)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingEvictions()
|
||||
if (!pending.length) return
|
||||
const cached = new Set(cachedSessionIds())
|
||||
const remaining: string[] = []
|
||||
pending.forEach((id) => {
|
||||
if (cached.has(id)) {
|
||||
remaining.push(id)
|
||||
} else {
|
||||
evictSession(id)
|
||||
}
|
||||
})
|
||||
if (remaining.length !== pending.length) {
|
||||
setPendingEvictions(remaining)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const instanceSessions = options.instanceSessions()
|
||||
const activeId = options.activeSessionId()
|
||||
|
||||
setCachedSessionIds((current) => {
|
||||
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||
|
||||
const touch = (id: string | null) => {
|
||||
if (!id || id === "info") return
|
||||
if (!instanceSessions.has(id)) return
|
||||
|
||||
const index = next.indexOf(id)
|
||||
if (index !== -1) {
|
||||
next.splice(index, 1)
|
||||
}
|
||||
next.unshift(id)
|
||||
}
|
||||
|
||||
touch(activeId)
|
||||
|
||||
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
|
||||
|
||||
const trimmedSet = new Set(trimmed)
|
||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||
if (removed.length) {
|
||||
scheduleEvictions(removed)
|
||||
}
|
||||
return trimmed
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
cachedSessionIds,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import {
|
||||
SESSION_SIDEBAR_EVENT,
|
||||
type SessionSidebarRequestAction,
|
||||
type SessionSidebarRequestDetail,
|
||||
} from "../../../lib/session-sidebar-events"
|
||||
|
||||
interface PendingSidebarAction {
|
||||
action: SessionSidebarRequestAction
|
||||
id: number
|
||||
}
|
||||
|
||||
interface UseSessionSidebarRequestsOptions {
|
||||
instanceId: Accessor<string>
|
||||
sidebarContentEl: Accessor<HTMLElement | null>
|
||||
leftPinned: Accessor<boolean>
|
||||
leftOpen: Accessor<boolean>
|
||||
setLeftOpen: (next: boolean) => void
|
||||
measureDrawerHost: () => void
|
||||
}
|
||||
|
||||
export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) {
|
||||
let sidebarActionId = 0
|
||||
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
|
||||
|
||||
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: options.key,
|
||||
code: options.code,
|
||||
keyCode: options.keyCode,
|
||||
which: options.keyCode,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const focusAgentSelectorControl = () => {
|
||||
const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null
|
||||
if (!agentTrigger) return false
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
const focusModelSelectorControl = () => {
|
||||
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-model-selector]")
|
||||
if (!input) return false
|
||||
input.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
const focusVariantSelectorControl = () => {
|
||||
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||
if (!input) return false
|
||||
input.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingSidebarAction()
|
||||
if (!pending) return
|
||||
const action = pending.action
|
||||
const contentReady = Boolean(options.sidebarContentEl())
|
||||
if (!contentReady) {
|
||||
return
|
||||
}
|
||||
if (action === "show-session-list") {
|
||||
setPendingSidebarAction(null)
|
||||
return
|
||||
}
|
||||
const handled =
|
||||
action === "focus-agent-selector"
|
||||
? focusAgentSelectorControl()
|
||||
: action === "focus-model-selector"
|
||||
? focusModelSelectorControl()
|
||||
: focusVariantSelectorControl()
|
||||
if (handled) {
|
||||
setPendingSidebarAction(null)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
|
||||
setPendingSidebarAction({ action, id: sidebarActionId++ })
|
||||
if (!options.leftPinned() && !options.leftOpen()) {
|
||||
options.setLeftOpen(true)
|
||||
options.measureDrawerHost()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<SessionSidebarRequestDetail>).detail
|
||||
if (!detail || detail.instanceId !== options.instanceId()) return
|
||||
handleSidebarRequest(detail.action)
|
||||
}
|
||||
window.addEventListener(SESSION_SIDEBAR_EVENT, handler)
|
||||
onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler))
|
||||
})
|
||||
|
||||
return {
|
||||
handleSidebarRequest,
|
||||
pendingSidebarAction,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, JSX, For } from "solid-js"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
|
||||
interface KbdProps {
|
||||
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const Kbd: Component<KbdProps> = (props) => {
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
if (!desktopQuery()) return null
|
||||
|
||||
const parts = () => {
|
||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||
if (!props.shortcut) return []
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Component, For } from "solid-js"
|
||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import Kbd from "./kbd"
|
||||
import HintRow from "./hint-row"
|
||||
|
||||
const KeyboardHint: Component<{
|
||||
shortcuts: KeyboardShortcut[]
|
||||
separator?: string
|
||||
separator?: string | null
|
||||
showDescription?: boolean
|
||||
class?: string
|
||||
ariaHidden?: boolean
|
||||
}> = (props) => {
|
||||
// Centralize layout gating here so call sites don't need to.
|
||||
// We only show keyboard hint UI on desktop layouts.
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
|
||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
if (!desktopQuery()) return null
|
||||
|
||||
return (
|
||||
<HintRow>
|
||||
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
|
||||
<For each={props.shortcuts}>
|
||||
{(shortcut, i) => (
|
||||
<>
|
||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
||||
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
|
||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -1010,10 +1010,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev)
|
||||
|
||||
const viewHideLabel = () =>
|
||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||
|
||||
const hasDeleteTarget = () => Boolean(props.partId)
|
||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
||||
|
||||
const handleDelete = async (event: Event) => {
|
||||
const handleDelete = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
@@ -1033,56 +1036,66 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<button
|
||||
type="button"
|
||||
class="message-reasoning-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||
>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="message-reasoning-meta">
|
||||
<span class="message-reasoning-indicator">
|
||||
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
||||
<div class="message-reasoning-header">
|
||||
<button
|
||||
type="button"
|
||||
class="message-reasoning-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||
>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="message-reasoning-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={viewHideLabel()}
|
||||
title={viewHideLabel()}
|
||||
>
|
||||
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
|
||||
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={hasDeleteTarget()}>
|
||||
<span
|
||||
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
onClick={handleDelete}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
handleDelete(event)
|
||||
}
|
||||
}}
|
||||
disabled={!canDelete()}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</span>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<span class="message-reasoning-time">{timestamp()}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
|
||||
@@ -268,87 +268,84 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return (
|
||||
<div class={containerClass()}>
|
||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||
<div class="message-speaker">
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
||||
</div>
|
||||
<div class="message-item-actions">
|
||||
<Show when={isUser()}>
|
||||
<div class="message-action-group">
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title={t("messageItem.actions.fork")}
|
||||
aria-label={t("messageItem.actions.fork")}
|
||||
>
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<div class="message-action-group">
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="message-item-header-row message-item-header-row--top">
|
||||
<div class="message-speaker">
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<div class="message-item-actions">
|
||||
<Show when={isUser()}>
|
||||
<div class="message-action-group">
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title={t("messageItem.actions.fork")}
|
||||
aria-label={t("messageItem.actions.fork")}
|
||||
>
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<div class="message-action-group">
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={agentMeta()}>
|
||||
{(meta) => (
|
||||
<div class="message-item-header-row message-item-header-row--bottom">
|
||||
<span class="message-agent-meta">{meta()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
{t("messageListHeader.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
const timelinePartCountsByMessageId = new Map<string, number>()
|
||||
let pendingTimelineMessagePartUpdates = new Set<string>()
|
||||
let pendingTimelinePartUpdateFrame: number | null = null
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
const ids = untrack(messageIds)
|
||||
const resolvedStore = untrack(store)
|
||||
const segments: TimelineSegment[] = []
|
||||
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
previousTimelineIds = ids.slice()
|
||||
})
|
||||
|
||||
function clearPendingTimelinePartUpdateFrame() {
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTimelinePartUpdateFlush() {
|
||||
if (pendingTimelinePartUpdateFrame !== null) return
|
||||
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
if (pendingTimelineMessagePartUpdates.size === 0) return
|
||||
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
|
||||
pendingTimelineMessagePartUpdates = new Set<string>()
|
||||
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
|
||||
setTimelineSegments((prev) => {
|
||||
let next = prev
|
||||
|
||||
for (const changedId of changedIds) {
|
||||
// Remove old segments for this message.
|
||||
next = next.filter((segment) => segment.messageId !== changedId)
|
||||
|
||||
const record = resolvedStore.getMessage(changedId)
|
||||
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
|
||||
|
||||
// Insert rebuilt segments in the correct place based on session message order.
|
||||
if (rebuilt.length > 0) {
|
||||
let insertAt = next.length
|
||||
const changedIndex = ids.indexOf(changedId)
|
||||
if (changedIndex >= 0) {
|
||||
for (let i = changedIndex + 1; i < ids.length; i++) {
|
||||
const followingId = ids[i]
|
||||
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
|
||||
if (existingIndex >= 0) {
|
||||
insertAt = existingIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the segment key set since we may have removed/replaced segments.
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Keep timeline segments in sync when message parts are added/removed.
|
||||
// Part deletion does not remove message ids from the session, so we must
|
||||
// explicitly replace segments for messages whose part count changed.
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
if (ids.length === 0) return
|
||||
const lastId = ids[ids.length - 1]
|
||||
if (!lastId) return
|
||||
const record = store().getMessage(lastId)
|
||||
if (!record) return
|
||||
const partCount = record.partIds.length
|
||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||
return
|
||||
const resolvedStore = store()
|
||||
|
||||
let hasChanges = false
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
|
||||
// Drop tracking for ids that are no longer present.
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!ids.includes(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
clearPendingTimelinePartUpdateFrame()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
@@ -785,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<ul>
|
||||
<li>
|
||||
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
|
||||
</li>
|
||||
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||
<li>
|
||||
|
||||
@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||
let hoverTimer: number | null = null
|
||||
let closeTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
@@ -292,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
hoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}, 160)
|
||||
}
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
const target = event.currentTarget as HTMLButtonElement
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearHoverTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||
})
|
||||
|
||||
onCleanup(() => clearHoverTimer())
|
||||
onCleanup(() => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeMessageId
|
||||
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
|
||||
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createResource } from "solid-js"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import {
|
||||
getOsNotificationCapability,
|
||||
requestOsNotificationPermission,
|
||||
type OsNotificationPermission,
|
||||
} from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface NotificationsSettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatPermissionLabel(permission: OsNotificationPermission): string {
|
||||
switch (permission) {
|
||||
case "granted":
|
||||
return "Granted"
|
||||
case "denied":
|
||||
return "Denied"
|
||||
case "default":
|
||||
return "Not granted"
|
||||
case "unsupported":
|
||||
return "Unsupported"
|
||||
default:
|
||||
return String(permission)
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
|
||||
const { preferences, updatePreferences } = useConfig()
|
||||
|
||||
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
void refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleEnableToggle = async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
const cap = capability()
|
||||
if (cap && !cap.supported) {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message: cap.info ?? "OS notifications are not supported in this environment.",
|
||||
variant: "warning",
|
||||
})
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await requestOsNotificationPermission()
|
||||
if (permission !== "granted") {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message:
|
||||
permission === "denied"
|
||||
? "Notification permission denied. Enable notifications in your system/browser settings."
|
||||
: "Notification permission not granted.",
|
||||
variant: "warning",
|
||||
})
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
updatePreferences({ osNotificationsEnabled: true })
|
||||
void refetch()
|
||||
}
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
const cap = capability()
|
||||
if (cap && !cap.supported) {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message: cap.info ?? "Notifications are not supported in this environment.",
|
||||
variant: "warning",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await requestOsNotificationPermission()
|
||||
if (permission === "granted") {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message: "Permission granted. You can now enable notifications.",
|
||||
variant: "success",
|
||||
duration: 6000,
|
||||
})
|
||||
void refetch()
|
||||
return
|
||||
}
|
||||
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message:
|
||||
permission === "denied"
|
||||
? "Permission denied. You may need to enable notifications in your system/browser settings."
|
||||
: "Permission not granted.",
|
||||
variant: "warning",
|
||||
})
|
||||
void refetch()
|
||||
}
|
||||
|
||||
const supported = () => capability()?.supported ?? false
|
||||
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
|
||||
const infoMessage = () => capability()?.info
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Session Status Notifications</h3>
|
||||
</div>
|
||||
|
||||
<div class="panel-body space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-primary">Enable</div>
|
||||
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||
disabled={!supported() && capability.state === "ready"}
|
||||
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-primary">Request permission</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||
onClick={() => void handleRequestPermission()}
|
||||
>
|
||||
Request
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={Boolean(infoMessage())}>
|
||||
<div class="text-xs text-secondary">{infoMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!supported() && capability.state === "ready"}>
|
||||
<div class="text-xs text-secondary">
|
||||
Notifications are not supported in this environment. The bell icon stays disabled.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
|
||||
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-primary">Session needs input</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-primary">Session becomes idle</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().notifyOnIdle)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationsSettingsModal
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
interface PromptAttachmentsBarProps {
|
||||
attachments: Attachment[]
|
||||
onRemoveAttachment: (attachmentId: string) => void
|
||||
onExpandTextAttachment: (attachmentId: string) => void
|
||||
}
|
||||
|
||||
const PromptAttachmentsBar: Component<PromptAttachmentsBarProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => {
|
||||
const isText = attachment.source.type === "text"
|
||||
return (
|
||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||
<span class="font-mono">{attachment.display}</span>
|
||||
<Show when={isText}>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => props.onExpandTextAttachment(attachment.id)}
|
||||
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => props.onRemoveAttachment(attachment.id)}
|
||||
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptAttachmentsBar
|
||||
@@ -0,0 +1,49 @@
|
||||
export function formatPastedPlaceholder(value: string | number) {
|
||||
return `[pasted #${value}]`
|
||||
}
|
||||
|
||||
export function formatImagePlaceholder(value: string | number) {
|
||||
return `[Image #${value}]`
|
||||
}
|
||||
|
||||
export function createPastedPlaceholderRegex() {
|
||||
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
|
||||
}
|
||||
|
||||
export function createImagePlaceholderRegex() {
|
||||
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
|
||||
}
|
||||
|
||||
export function createMentionRegex() {
|
||||
return /@(\S+)/g
|
||||
}
|
||||
|
||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
|
||||
export const imageDisplayCounterRegex = /Image #(\d+)/i
|
||||
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
|
||||
|
||||
export function parseCounter(value: string) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export function findHighestAttachmentCounters(currentPrompt: string) {
|
||||
let highestPaste = 0
|
||||
let highestImage = 0
|
||||
|
||||
for (const match of currentPrompt.matchAll(createPastedPlaceholderRegex())) {
|
||||
const parsed = parseCounter(match[1])
|
||||
if (parsed !== null) {
|
||||
highestPaste = Math.max(highestPaste, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||
const parsed = parseCounter(match[1])
|
||||
if (parsed !== null) {
|
||||
highestImage = Math.max(highestImage, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return { highestPaste, highestImage }
|
||||
}
|
||||
27
packages/ui/src/components/prompt-input/types.ts
Normal file
27
packages/ui/src/components/prompt-input/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
|
||||
export type PromptMode = "normal" | "shell"
|
||||
export type ExpandState = "normal" | "expanded"
|
||||
export type PickerMode = "mention" | "command"
|
||||
export type PromptInsertMode = "quote" | "code"
|
||||
|
||||
export interface PromptInputApi {
|
||||
insertSelection(text: string, mode: PromptInsertMode): void
|
||||
expandTextAttachment(attachmentId: string): void
|
||||
removeAttachment(attachmentId: string): void
|
||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
escapeInDebounce?: boolean
|
||||
isSessionBusy?: boolean
|
||||
onAbortSession?: () => Promise<void>
|
||||
registerPromptInputApi?: (api: PromptInputApi) => void | (() => void)
|
||||
}
|
||||
364
packages/ui/src/components/prompt-input/usePromptAttachments.ts
Normal file
364
packages/ui/src/components/prompt-input/usePromptAttachments.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import {
|
||||
bracketedImageDisplayCounterRegex,
|
||||
findHighestAttachmentCounters,
|
||||
formatImagePlaceholder,
|
||||
formatPastedPlaceholder,
|
||||
imageDisplayCounterRegex,
|
||||
pastedDisplayCounterRegex,
|
||||
} from "./attachmentPlaceholders"
|
||||
|
||||
type PromptAttachmentsOptions = {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
instanceFolder: Accessor<string>
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (value: string) => void
|
||||
getTextarea: () => HTMLTextAreaElement | null
|
||||
}
|
||||
|
||||
type PromptAttachments = {
|
||||
attachments: Accessor<Attachment[]>
|
||||
pasteCount: Accessor<number>
|
||||
imageCount: Accessor<number>
|
||||
syncAttachmentCounters: (promptText: string) => void
|
||||
|
||||
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||
isDragging: Accessor<boolean>
|
||||
handleDragOver: (e: DragEvent) => void
|
||||
handleDragLeave: (e: DragEvent) => void
|
||||
handleDrop: (e: DragEvent) => void
|
||||
|
||||
handleRemoveAttachment: (attachmentId: string) => void
|
||||
handleExpandTextAttachment: (attachment: Attachment) => void
|
||||
}
|
||||
|
||||
export function usePromptAttachments(options: PromptAttachmentsOptions): PromptAttachments {
|
||||
const attachments = () => getAttachments(options.instanceId(), options.sessionId())
|
||||
const [isDragging, setIsDragging] = createSignal(false)
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
|
||||
function syncAttachmentCounters(currentPrompt: string) {
|
||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
|
||||
setPasteCount(highestPaste)
|
||||
setImageCount(highestImage)
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
|
||||
const next = currentPrompt.replace(tokenRegex, "")
|
||||
if (next === currentPrompt) return currentPrompt
|
||||
|
||||
return next
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n[ \t]+/g, "\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const createLooseImagePlaceholderRegex = (counter: string | number) =>
|
||||
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
|
||||
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||
|
||||
// Keep placeholder-backed attachments in sync with prompt text.
|
||||
// If the placeholder token disappears from the prompt, the attachment should disappear too.
|
||||
createEffect(() => {
|
||||
const currentPrompt = options.prompt()
|
||||
const currentAttachments = attachments()
|
||||
|
||||
const toRemove: string[] = []
|
||||
|
||||
for (const attachment of currentAttachments) {
|
||||
if (attachment.source.type === "text") {
|
||||
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (!match) continue
|
||||
const counter = match[1]
|
||||
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
|
||||
toRemove.push(attachment.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||
const match =
|
||||
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||
if (!match) continue
|
||||
const counter = match[1]
|
||||
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
|
||||
toRemove.push(attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachmentId of toRemove) {
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
}
|
||||
})
|
||||
|
||||
function handleRemoveAttachment(attachmentId: string) {
|
||||
const currentAttachments = attachments()
|
||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||
|
||||
// Always remove from store.
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||
|
||||
if (!attachment) return
|
||||
|
||||
const currentPrompt = options.prompt()
|
||||
let nextPrompt = currentPrompt
|
||||
|
||||
if (attachment.source.type === "file") {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
const imageMatch =
|
||||
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||
if (imageMatch) {
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
|
||||
}
|
||||
} else {
|
||||
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
|
||||
const candidates = [attachment.source.path, attachment.filename]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
|
||||
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
|
||||
}
|
||||
}
|
||||
} else if (attachment.source.type === "agent") {
|
||||
const agentName = attachment.filename
|
||||
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
|
||||
} else if (attachment.source.type === "text") {
|
||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||
if (placeholderMatch) {
|
||||
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPrompt !== currentPrompt) {
|
||||
options.setPrompt(nextPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpandTextAttachment(attachment: Attachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = options.getTextarea()
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||
const placeholder = match ? formatPastedPlaceholder(match[1]) : null
|
||||
const currentText = options.prompt()
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
options.setPrompt(nextText)
|
||||
removeAttachment(options.instanceId(), options.sessionId(), attachment.id)
|
||||
|
||||
if (textarea) {
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault()
|
||||
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
|
||||
const { highestImage } = findHighestAttachmentCounters(options.prompt())
|
||||
const count = highestImage + 1
|
||||
setImageCount(count)
|
||||
|
||||
const placeholder = formatImagePlaceholder(count)
|
||||
const textarea = options.getTextarea()
|
||||
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
options.setPrompt(options.prompt() + placeholder)
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64Data = (reader.result as string).split(",")[1]
|
||||
const filename = `image-${count}.png`
|
||||
|
||||
const attachment = createFileAttachment(
|
||||
filename,
|
||||
filename,
|
||||
"image/png",
|
||||
new TextEncoder().encode(base64Data),
|
||||
options.instanceFolder(),
|
||||
)
|
||||
attachment.url = `data:image/png;base64,${base64Data}`
|
||||
attachment.display = placeholder
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const pastedText = e.clipboardData?.getData("text/plain")
|
||||
if (!pastedText) return
|
||||
|
||||
const lineCount = pastedText.split("\n").length
|
||||
const charCount = pastedText.length
|
||||
|
||||
const isLongPaste = charCount > 150 || lineCount > 3
|
||||
|
||||
if (isLongPaste) {
|
||||
e.preventDefault()
|
||||
|
||||
const { highestPaste } = findHighestAttachmentCounters(options.prompt())
|
||||
const count = highestPaste + 1
|
||||
setPasteCount(count)
|
||||
|
||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||
const display = `pasted #${count} (${summary})`
|
||||
const filename = `paste-${count}.txt`
|
||||
|
||||
const attachment = createTextAttachment(pastedText, display, filename)
|
||||
const placeholder = formatPastedPlaceholder(count)
|
||||
const textarea = options.getTextarea()
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const currentText = options.prompt()
|
||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = start + placeholder.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
} else {
|
||||
options.setPrompt(options.prompt() + placeholder)
|
||||
}
|
||||
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const path = (file as File & { path?: string }).path || file.name
|
||||
const filename = file.name
|
||||
const mime = file.type || "text/plain"
|
||||
|
||||
const createAndStoreAttachment = (previewUrl?: string) => {
|
||||
const attachment = createFileAttachment(path, filename, mime, undefined, options.instanceFolder())
|
||||
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
|
||||
attachment.url = previewUrl
|
||||
}
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === "string" ? reader.result : undefined
|
||||
createAndStoreAttachment(result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
|
||||
createAndStoreAttachment(dataUrl)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else {
|
||||
createAndStoreAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
options.getTextarea()?.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
attachments,
|
||||
pasteCount,
|
||||
imageCount,
|
||||
syncAttachmentCounters,
|
||||
handlePaste,
|
||||
isDragging,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleRemoveAttachment,
|
||||
handleExpandTextAttachment,
|
||||
}
|
||||
}
|
||||
272
packages/ui/src/components/prompt-input/usePromptKeyDown.ts
Normal file
272
packages/ui/src/components/prompt-input/usePromptKeyDown.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { PromptMode } from "./types"
|
||||
import {
|
||||
createImagePlaceholderRegex,
|
||||
createMentionRegex,
|
||||
createPastedPlaceholderRegex,
|
||||
} from "./attachmentPlaceholders"
|
||||
|
||||
export type UsePromptKeyDownOptions = {
|
||||
getTextarea: () => HTMLTextAreaElement | null
|
||||
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (v: string) => void
|
||||
|
||||
mode: Accessor<PromptMode>
|
||||
setMode: (m: PromptMode) => void
|
||||
|
||||
isPickerOpen: Accessor<boolean>
|
||||
closePicker: () => void
|
||||
|
||||
ignoredAtPositions: Accessor<Set<number>>
|
||||
setIgnoredAtPositions: (next: Set<number> | ((s: Set<number>) => Set<number>)) => void
|
||||
|
||||
getAttachments: Accessor<Attachment[]>
|
||||
removeAttachment: (attachmentId: string) => void
|
||||
|
||||
submitOnEnter: Accessor<boolean>
|
||||
onSend: () => void
|
||||
|
||||
selectPreviousHistory: (force?: boolean) => boolean
|
||||
selectNextHistory: (force?: boolean) => boolean
|
||||
}
|
||||
|
||||
export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
||||
const insertNewlineAtCursor = () => {
|
||||
const textarea = options.getTextarea()
|
||||
const current = options.prompt()
|
||||
const start = textarea ? textarea.selectionStart : current.length
|
||||
const end = textarea ? textarea.selectionEnd : current.length
|
||||
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
|
||||
const nextCursor = start + 1
|
||||
|
||||
options.setPrompt(nextValue)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (!nextTextarea) return
|
||||
nextTextarea.focus()
|
||||
nextTextarea.setSelectionRange(nextCursor, nextCursor)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return function handleKeyDown(e: KeyboardEvent) {
|
||||
const textarea = options.getTextarea()
|
||||
if (!textarea) return
|
||||
|
||||
const currentText = options.prompt()
|
||||
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const isShellMode = options.mode() === "shell"
|
||||
|
||||
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) {
|
||||
e.preventDefault()
|
||||
options.setMode("shell")
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isPickerOpen() && e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
options.closePicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (isShellMode) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
options.setMode("normal")
|
||||
return
|
||||
}
|
||||
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
|
||||
e.preventDefault()
|
||||
options.setMode("normal")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const cursorPos = textarea.selectionStart
|
||||
const text = currentText
|
||||
|
||||
const pastePlaceholderRegex = createPastedPlaceholderRegex()
|
||||
let pasteMatch
|
||||
|
||||
while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) {
|
||||
const placeholderStart = pasteMatch.index
|
||||
const placeholderEnd = pasteMatch.index + pasteMatch[0].length
|
||||
const pasteNumber = pasteMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= placeholderStart &&
|
||||
textarea.selectionEnd >= placeholderEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
e.preventDefault()
|
||||
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`),
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
options.removeAttachment(attachment.id)
|
||||
}
|
||||
|
||||
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const imagePlaceholderRegex = createImagePlaceholderRegex()
|
||||
let imageMatch
|
||||
|
||||
while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) {
|
||||
const placeholderStart = imageMatch.index
|
||||
const placeholderEnd = imageMatch.index + imageMatch[0].length
|
||||
const imageNumber = imageMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= placeholderStart &&
|
||||
textarea.selectionEnd >= placeholderEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
e.preventDefault()
|
||||
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`),
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
options.removeAttachment(attachment.id)
|
||||
}
|
||||
|
||||
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(placeholderStart, placeholderStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const mentionRegex = createMentionRegex()
|
||||
let mentionMatch
|
||||
|
||||
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
|
||||
const mentionStart = mentionMatch.index
|
||||
const mentionEnd = mentionMatch.index + mentionMatch[0].length
|
||||
const name = mentionMatch[1]
|
||||
|
||||
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd
|
||||
const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart
|
||||
const isSelected =
|
||||
textarea.selectionStart <= mentionStart &&
|
||||
textarea.selectionEnd >= mentionEnd &&
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
|
||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||
const currentAttachments = options.getAttachments()
|
||||
const attachment = currentAttachments.find(
|
||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
||||
)
|
||||
|
||||
if (attachment) {
|
||||
e.preventDefault()
|
||||
|
||||
options.removeAttachment(attachment.id)
|
||||
|
||||
options.setIgnoredAtPositions((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(mentionStart)
|
||||
return next
|
||||
})
|
||||
|
||||
const newText = text.substring(0, mentionStart) + text.substring(mentionEnd)
|
||||
options.setPrompt(newText)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||
}, 0)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
const isModified = e.metaKey || e.ctrlKey
|
||||
|
||||
// If the picker is open, Enter should select from it.
|
||||
if (!isModified && options.isPickerOpen()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.submitOnEnter()) {
|
||||
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
insertNewlineAtCursor()
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+Enter always inserts a newline.
|
||||
if (e.shiftKey) {
|
||||
// If the picker is open, avoid selecting an item on Enter.
|
||||
if (options.isPickerOpen()) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
options.onSend()
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Cmd/Ctrl+Enter submits.
|
||||
if (isModified) {
|
||||
e.preventDefault()
|
||||
if (options.isPickerOpen()) {
|
||||
options.closePicker()
|
||||
}
|
||||
options.onSend()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
const handled = options.selectPreviousHistory()
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const handled = options.selectNextHistory()
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
272
packages/ui/src/components/prompt-input/usePromptPicker.ts
Normal file
272
packages/ui/src/components/prompt-input/usePromptPicker.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||
import type { Agent } from "../../types/session"
|
||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||
import type { PickerMode } from "./types"
|
||||
|
||||
type PickerItem =
|
||||
| { type: "agent"; agent: Agent }
|
||||
| { type: "file"; file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } }
|
||||
| { type: "command"; command: SDKCommand }
|
||||
|
||||
type PromptPickerOptions = {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
instanceFolder: Accessor<string>
|
||||
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (value: string) => void
|
||||
getTextarea: () => HTMLTextAreaElement | null
|
||||
|
||||
instanceAgents: Accessor<Agent[]>
|
||||
commands: Accessor<SDKCommand[]>
|
||||
}
|
||||
|
||||
type PromptPickerController = {
|
||||
showPicker: Accessor<boolean>
|
||||
pickerMode: Accessor<PickerMode>
|
||||
searchQuery: Accessor<string>
|
||||
atPosition: Accessor<number | null>
|
||||
ignoredAtPositions: Accessor<Set<number>>
|
||||
|
||||
setShowPicker: Setter<boolean>
|
||||
setPickerMode: Setter<PickerMode>
|
||||
setSearchQuery: Setter<string>
|
||||
setAtPosition: Setter<number | null>
|
||||
setIgnoredAtPositions: Setter<Set<number>>
|
||||
|
||||
handleInput: (e: Event) => void
|
||||
handlePickerSelect: (item: PickerItem) => void
|
||||
handlePickerClose: () => void
|
||||
}
|
||||
|
||||
export function usePromptPicker(options: PromptPickerOptions): PromptPickerController {
|
||||
const [showPicker, setShowPicker] = createSignal(false)
|
||||
const [pickerMode, setPickerMode] = createSignal<PickerMode>("mention")
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const value = target.value
|
||||
options.setPrompt(value)
|
||||
|
||||
const cursorPos = target.selectionStart
|
||||
|
||||
// Slash command picker (only when editing the command token: "/<query>")
|
||||
if (value.startsWith("/") && cursorPos >= 1) {
|
||||
const firstWhitespaceIndex = value.slice(1).search(/\s/)
|
||||
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
|
||||
|
||||
if (cursorPos <= tokenEnd) {
|
||||
setPickerMode("command")
|
||||
setAtPosition(0)
|
||||
setSearchQuery(value.substring(1, cursorPos))
|
||||
setShowPicker(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const textBeforeCursor = value.substring(0, cursorPos)
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
||||
|
||||
const previousAtPosition = atPosition()
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
||||
setIgnoredAtPositions((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(previousAtPosition)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
|
||||
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
|
||||
|
||||
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
||||
if (!ignoredAtPositions().has(lastAtIndex)) {
|
||||
setPickerMode("mention")
|
||||
setAtPosition(lastAtIndex)
|
||||
setSearchQuery(textAfterAt)
|
||||
setShowPicker(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
}
|
||||
|
||||
function handlePickerSelect(item: PickerItem) {
|
||||
const textarea = options.getTextarea()
|
||||
|
||||
if (item.type === "command") {
|
||||
const name = item.command.name
|
||||
const currentPrompt = options.prompt()
|
||||
|
||||
const afterSlash = currentPrompt.slice(1)
|
||||
const firstWhitespaceIndex = afterSlash.search(/\s/)
|
||||
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
|
||||
|
||||
const before = ""
|
||||
const after = currentPrompt.substring(tokenEnd)
|
||||
const newPrompt = before + `/${name} ` + after
|
||||
options.setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = `/${name} `.length
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
nextTextarea.focus()
|
||||
}
|
||||
}, 0)
|
||||
} else if (item.type === "agent") {
|
||||
const agentName = item.agent.name
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "agent" && att.source.name === agentName,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createAgentAttachment(agentName)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
const currentPrompt = options.prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textarea?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${agentName}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
options.setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
} else if (item.type === "file") {
|
||||
const displayPath = item.file.path
|
||||
const relativePath = item.file.relativePath ?? displayPath
|
||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||
|
||||
if (isFolder) {
|
||||
const currentPrompt = options.prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textarea?.selectionStart || 0
|
||||
const folderMention =
|
||||
relativePath === "." || relativePath === ""
|
||||
? "/"
|
||||
: relativePath.replace(/\/+$/, "") + "/"
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos + 1)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const newPrompt = before + folderMention + after
|
||||
options.setPrompt(newPrompt)
|
||||
setSearchQuery(folderMention)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + 1 + folderMention.length
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
||||
const pathSegments = normalizedPath.split("/")
|
||||
const filename = (() => {
|
||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||
return candidate === "." ? "/" : candidate
|
||||
})()
|
||||
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createFileAttachment(
|
||||
normalizedPath,
|
||||
filename,
|
||||
"text/plain",
|
||||
undefined,
|
||||
options.instanceFolder(),
|
||||
)
|
||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||
}
|
||||
|
||||
const currentPrompt = options.prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textarea?.selectionStart || 0
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${normalizedPath}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
options.setPrompt(newPrompt)
|
||||
|
||||
setTimeout(() => {
|
||||
const nextTextarea = options.getTextarea()
|
||||
if (nextTextarea) {
|
||||
const newCursorPos = pos + attachmentText.length + 1
|
||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
textarea?.focus()
|
||||
}
|
||||
|
||||
function handlePickerClose() {
|
||||
const pos = atPosition()
|
||||
if (pickerMode() === "mention" && pos !== null) {
|
||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||
}
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
setTimeout(() => options.getTextarea()?.focus(), 0)
|
||||
}
|
||||
|
||||
return {
|
||||
showPicker,
|
||||
pickerMode,
|
||||
searchQuery,
|
||||
atPosition,
|
||||
ignoredAtPositions,
|
||||
|
||||
setShowPicker,
|
||||
setPickerMode,
|
||||
setSearchQuery,
|
||||
setAtPosition,
|
||||
setIgnoredAtPositions,
|
||||
|
||||
handleInput,
|
||||
handlePickerSelect,
|
||||
handlePickerClose,
|
||||
}
|
||||
}
|
||||
203
packages/ui/src/components/prompt-input/usePromptState.ts
Normal file
203
packages/ui/src/components/prompt-input/usePromptState.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { addToHistory, getHistory } from "../../stores/message-history"
|
||||
import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type GetTextarea = () => HTMLTextAreaElement | undefined | null
|
||||
|
||||
type PromptStateOptions = {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
instanceFolder: Accessor<string>
|
||||
onSessionDraftLoaded?: (draft: string) => void
|
||||
}
|
||||
|
||||
type HistorySelectOptions = {
|
||||
force?: boolean
|
||||
isPickerOpen: boolean
|
||||
getTextarea: GetTextarea
|
||||
}
|
||||
|
||||
type PromptState = {
|
||||
prompt: Accessor<string>
|
||||
setPrompt: (value: string) => void
|
||||
clearPrompt: () => void
|
||||
|
||||
draftLoadedNonce: Accessor<number>
|
||||
|
||||
history: Accessor<string[]>
|
||||
historyIndex: Accessor<number>
|
||||
historyDraft: Accessor<string | null>
|
||||
|
||||
resetHistoryNavigation: () => void
|
||||
clearHistoryDraft: () => void
|
||||
recordHistoryEntry: (entry: string) => Promise<void>
|
||||
|
||||
selectPreviousHistory: (options: HistorySelectOptions) => boolean
|
||||
selectNextHistory: (options: HistorySelectOptions) => boolean
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 100
|
||||
|
||||
export function usePromptState(options: PromptStateOptions): PromptState {
|
||||
const [prompt, setPromptInternal] = createSignal("")
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||
const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0)
|
||||
|
||||
const setPrompt = (value: string) => {
|
||||
setPromptInternal(value)
|
||||
// Persist drafts only when the user is at the "fresh" position (not browsing history).
|
||||
// This keeps the bottom-of-history draft stable even if the user edits recalled history entries.
|
||||
if (historyIndex() === -1) {
|
||||
setSessionDraftPrompt(options.instanceId(), options.sessionId(), value)
|
||||
}
|
||||
}
|
||||
|
||||
const clearPrompt = () => {
|
||||
clearSessionDraftPrompt(options.instanceId(), options.sessionId())
|
||||
setPromptInternal("")
|
||||
}
|
||||
|
||||
const resetHistoryNavigation = () => {
|
||||
setHistoryIndex(-1)
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
|
||||
const clearHistoryDraft = () => {
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => `${options.instanceId()}:${options.sessionId()}`,
|
||||
() => {
|
||||
const instanceId = options.instanceId()
|
||||
const sessionId = options.sessionId()
|
||||
|
||||
onCleanup(() => {
|
||||
// Persist the previous session's draft when switching sessions.
|
||||
setSessionDraftPrompt(instanceId, sessionId, prompt())
|
||||
})
|
||||
|
||||
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
setPromptInternal(storedPrompt)
|
||||
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
|
||||
|
||||
resetHistoryNavigation()
|
||||
|
||||
setDraftLoadedNonce((prev) => prev + 1)
|
||||
options.onSessionDraftLoaded?.(storedPrompt)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const loaded = await getHistory(options.instanceFolder())
|
||||
setHistory(loaded)
|
||||
})()
|
||||
})
|
||||
|
||||
const recordHistoryEntry = async (entry: string) => {
|
||||
try {
|
||||
await addToHistory(options.instanceFolder(), entry)
|
||||
setHistory((prev) => {
|
||||
const next = [entry, ...prev]
|
||||
if (next.length > HISTORY_LIMIT) {
|
||||
next.length = HISTORY_LIMIT
|
||||
}
|
||||
return next
|
||||
})
|
||||
setHistoryIndex(-1)
|
||||
} catch (historyError) {
|
||||
log.error("Failed to update prompt history:", historyError)
|
||||
}
|
||||
}
|
||||
|
||||
const canUseHistory = (selectOptions: HistorySelectOptions) => {
|
||||
if (selectOptions.force) return true
|
||||
if (selectOptions.isPickerOpen) return false
|
||||
|
||||
const textarea = selectOptions.getTextarea()
|
||||
if (!textarea) return false
|
||||
|
||||
// Only require the cursor to be at the buffer start when *entering* history navigation.
|
||||
// Once we're already navigating history (historyIndex >= 0), allow ArrowUp/ArrowDown
|
||||
// regardless of cursor position (we focus the end of the entry).
|
||||
if (historyIndex() !== -1) return true
|
||||
|
||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
}
|
||||
|
||||
const focusTextareaEnd = (getTextarea: GetTextarea) => {
|
||||
const textarea = getTextarea()
|
||||
if (!textarea) return
|
||||
setTimeout(() => {
|
||||
const next = getTextarea()
|
||||
if (!next) return
|
||||
const pos = next.value.length
|
||||
next.setSelectionRange(pos, pos)
|
||||
next.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const selectPreviousHistory = (selectOptions: HistorySelectOptions) => {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(selectOptions)) return false
|
||||
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
}
|
||||
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
focusTextareaEnd(selectOptions.getTextarea)
|
||||
return true
|
||||
}
|
||||
|
||||
const selectNextHistory = (selectOptions: HistorySelectOptions) => {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(selectOptions)) return false
|
||||
if (historyIndex() === -1) return false
|
||||
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
const draft = historyDraft() ?? getSessionDraftPrompt(options.instanceId(), options.sessionId())
|
||||
setPrompt(draft ?? "")
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
focusTextareaEnd(selectOptions.getTextarea)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
setPrompt,
|
||||
clearPrompt,
|
||||
|
||||
draftLoadedNonce,
|
||||
|
||||
history,
|
||||
historyIndex,
|
||||
historyDraft,
|
||||
|
||||
resetHistoryNavigation,
|
||||
clearHistoryDraft,
|
||||
recordHistoryEntry,
|
||||
|
||||
selectPreviousHistory,
|
||||
selectNextHistory,
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<kbd class="kbd ml-2">
|
||||
<kbd class="kbd ml-2 kbd-hint">
|
||||
Cmd+Enter
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import { Show, createMemo, createEffect, on, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageSection from "../message-section"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
|
||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
@@ -15,6 +14,7 @@ import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -53,52 +53,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||
|
||||
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(/pasted #(\d+)/)
|
||||
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||
|
||||
const currentText = textarea?.value ?? ""
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = nextText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}
|
||||
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
}
|
||||
let promptInputApi: PromptInputApi | null = null
|
||||
let pendingPromptText: string | null = null
|
||||
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
||||
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
@@ -135,6 +92,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
// Defer until the session pane is visible and the textarea is mounted.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
|
||||
if (!textarea) return
|
||||
if (textarea.disabled) return
|
||||
@@ -149,8 +111,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
},
|
||||
),
|
||||
)
|
||||
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
@@ -158,18 +119,31 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
|
||||
quoteHandler = handler
|
||||
function registerPromptInputApi(api: PromptInputApi) {
|
||||
promptInputApi = api
|
||||
|
||||
if (pendingPromptText) {
|
||||
api.setPromptText(pendingPromptText, { focus: true })
|
||||
pendingPromptText = null
|
||||
}
|
||||
|
||||
if (pendingSelectionInsert) {
|
||||
api.insertSelection(pendingSelectionInsert.text, pendingSelectionInsert.mode)
|
||||
pendingSelectionInsert = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (quoteHandler === handler) {
|
||||
quoteHandler = null
|
||||
if (promptInputApi === api) {
|
||||
promptInputApi = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuoteSelection(text: string, mode: "quote" | "code") {
|
||||
if (quoteHandler) {
|
||||
quoteHandler(text, mode)
|
||||
function handleQuoteSelection(text: string, mode: PromptInsertMode) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.insertSelection(text, mode)
|
||||
} else {
|
||||
pendingSelectionInsert = { text, mode }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,14 +204,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
)
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
if (restoredText) {
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
if (restoredText) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||
} else {
|
||||
pendingPromptText = restoredText
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
|
||||
@@ -271,14 +244,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
if (restoredText) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||
} else {
|
||||
pendingPromptText = restoredText
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fork session", error)
|
||||
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
|
||||
@@ -327,39 +299,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => {
|
||||
const isText = attachment.source.type === "text"
|
||||
return (
|
||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||
<span class="font-mono">{attachment.display}</span>
|
||||
<Show when={isText}>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
<Show when={attachments().length > 0}>
|
||||
<PromptAttachmentsBar
|
||||
attachments={attachments()}
|
||||
onRemoveAttachment={(attachmentId) => {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.removeAttachment(attachmentId)
|
||||
return
|
||||
}
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
@@ -371,11 +323,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerQuoteHandler={registerQuoteHandler}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Command {
|
||||
description: Resolvable<string>
|
||||
keywords?: Resolvable<string[]>
|
||||
shortcut?: KeyboardShortcut
|
||||
disabled?: Resolvable<boolean>
|
||||
action: () => void | Promise<void>
|
||||
category?: Resolvable<string>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
|
||||
import { requestData } from "../opencode-api"
|
||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleKeyboardShortcutHints: () => void
|
||||
toggleShowTimelineTools: () => void
|
||||
toggleUsageMetrics: () => void
|
||||
toggleAutoCleanupBlankSessions: () => void
|
||||
@@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
action: options.toggleShowTimelineTools,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "keyboard-shortcut-hints",
|
||||
label: () =>
|
||||
tGlobal(
|
||||
options.preferences().showKeyboardShortcutHints
|
||||
? "commands.keyboardShortcutHints.label.hide"
|
||||
: "commands.keyboardShortcutHints.label.show",
|
||||
),
|
||||
description: () =>
|
||||
tGlobal(
|
||||
runtimeEnv.host === "web"
|
||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||
: "commands.keyboardShortcutHints.description",
|
||||
),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
action: options.toggleKeyboardShortcutHints,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking-default-visibility",
|
||||
label: () => {
|
||||
|
||||
@@ -30,6 +30,10 @@ export const appMessages = {
|
||||
"releases.uiUpdated.title": "UI updated",
|
||||
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||
|
||||
"releases.devUpdateAvailable.title": "Dev build available",
|
||||
"releases.devUpdateAvailable.message": "A new dev build is available: {version}.",
|
||||
"releases.devUpdateAvailable.action": "View release",
|
||||
|
||||
"theme.mode.system": "System",
|
||||
"theme.mode.light": "Light",
|
||||
"theme.mode.dark": "Dark",
|
||||
|
||||
@@ -97,6 +97,12 @@ export const commandMessages = {
|
||||
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
||||
|
||||
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
|
||||
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
|
||||
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
|
||||
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
|
||||
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
|
||||
|
||||
"commands.common.expanded": "Expanded",
|
||||
"commands.common.collapsed": "Collapsed",
|
||||
"commands.common.visible": "Visible",
|
||||
|
||||
@@ -86,12 +86,32 @@ export const instanceMessages = {
|
||||
"instanceShell.empty.description": "Select a session to view messages",
|
||||
|
||||
"instanceShell.rightPanel.title": "Status Panel",
|
||||
"instanceShell.rightPanel.tabs.changes": "Session Changes",
|
||||
"instanceShell.rightPanel.tabs.gitChanges": "Git Changes",
|
||||
"instanceShell.rightPanel.tabs.files": "Files",
|
||||
"instanceShell.rightPanel.tabs.status": "Status",
|
||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
|
||||
"instanceShell.rightPanel.sections.mcp": "MCP Servers",
|
||||
"instanceShell.rightPanel.sections.lsp": "LSP Servers",
|
||||
"instanceShell.rightPanel.sections.plugins": "Plugins",
|
||||
|
||||
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
|
||||
"instanceShell.sessionChanges.loading": "Fetching session changes...",
|
||||
"instanceShell.sessionChanges.empty": "No session changes yet.",
|
||||
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||
|
||||
"instanceShell.filesShell.fileListTitle": "File list",
|
||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||
"instanceShell.filesShell.viewerTitle": "Change viewer",
|
||||
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
|
||||
"instanceShell.filesShell.viewerEmpty": "No file selected.",
|
||||
|
||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export const sessionMessages = {
|
||||
"sessionList.expand.expandAriaLabel": "Expand session",
|
||||
"sessionList.expand.collapseTitle": "Collapse",
|
||||
"sessionList.expand.expandTitle": "Expand",
|
||||
"sessionList.actions.newSession.ariaLabel": "New session",
|
||||
"sessionList.actions.newSession.title": "New session",
|
||||
"sessionList.actions.copyId.ariaLabel": "Copy session ID",
|
||||
"sessionList.actions.copyId.title": "Copy session ID",
|
||||
"sessionList.actions.rename.ariaLabel": "Rename session",
|
||||
|
||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
||||
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
|
||||
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
|
||||
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
|
||||
|
||||
"releases.uiUpdated.title": "UI actualizada",
|
||||
"releases.uiUpdated.message": "La UI ahora está actualizada a {version}.",
|
||||
|
||||
"releases.devUpdateAvailable.title": "Compilación dev disponible",
|
||||
"releases.devUpdateAvailable.message": "Hay una nueva compilación dev disponible: {version}.",
|
||||
"releases.devUpdateAvailable.action": "Ver release",
|
||||
} as const
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user