refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
This commit is contained in:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
@@ -3,6 +3,11 @@
|
|||||||
## Core purpose
|
## Core purpose
|
||||||
OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via `@opencode-ai/sdk`.
|
OpenChamber provides UI runtimes (web/desktop/VS Code) for interacting with an OpenCode server (local auto-start or remote URL). UI uses HTTP + SSE via `@opencode-ai/sdk`.
|
||||||
|
|
||||||
|
## Runtime architecture (IMPORTANT)
|
||||||
|
- `Desktop` is a thin Tauri shell that starts the web server sidecar and loads the web UI from `http://127.0.0.1:<port>`.
|
||||||
|
- All backend logic lives in `packages/web/server/*` (and `packages/vscode/*` for the VS Code runtime). Desktop Rust is not a feature backend.
|
||||||
|
- Tauri is used only for stable native integrations: menu, dialog (open folder), notifications, updater, deep-links.
|
||||||
|
|
||||||
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
|
## Tech stack (source of truth: `package.json`, resolved: `bun.lock`)
|
||||||
- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
|
- Runtime/tooling: Bun (`package.json` `packageManager`), Node >=20 (`package.json` `engines`)
|
||||||
- UI: React, TypeScript, Vite, Tailwind v4
|
- UI: React, TypeScript, Vite, Tailwind v4
|
||||||
@@ -31,7 +36,7 @@ All scripts are in `package.json`.
|
|||||||
- Web bootstrap: `packages/web/src/main.tsx`
|
- Web bootstrap: `packages/web/src/main.tsx`
|
||||||
- Web server: `packages/web/server/index.js`
|
- Web server: `packages/web/server/index.js`
|
||||||
- Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`)
|
- Web CLI: `packages/web/bin/cli.js` (package bin: `packages/web/package.json`)
|
||||||
- Desktop bootstrap: `packages/desktop/src/main.tsx`
|
- Desktop: Tauri entry `packages/desktop/src-tauri/src/main.rs` (spawns web server sidecar + loads web UI)
|
||||||
- Tauri backend: `packages/desktop/src-tauri/src/main.rs`
|
- Tauri backend: `packages/desktop/src-tauri/src/main.rs`
|
||||||
- VS Code extension host: `packages/vscode/src/extension.ts`
|
- VS Code extension host: `packages/vscode/src/extension.ts`
|
||||||
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
|
- VS Code webview bootstrap: `packages/vscode/webview/main.tsx`
|
||||||
|
|||||||
@@ -97,24 +97,10 @@
|
|||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.6.3",
|
"version": "1.6.3",
|
||||||
"dependencies": {
|
|
||||||
"@openchamber/ui": "workspace:*",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
|
||||||
"@tauri-apps/plugin-process": "^2",
|
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/api": "^2.9.1",
|
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/react": "^19.1.10",
|
|
||||||
"@types/react-dom": "^19.1.7",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.2",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
@@ -1131,14 +1117,6 @@
|
|||||||
|
|
||||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="],
|
|
||||||
|
|
||||||
"@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.0", "", {}, "sha512-K0LEuuTo4rza8yDrlYkRdXLao8Iz/QBMsQdIxRrOOrLYb4HAtZaypZ78c+J6rDA1UlGxadZVLmkkiv4KV5fMKQ=="],
|
"@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.0", "", {}, "sha512-K0LEuuTo4rza8yDrlYkRdXLao8Iz/QBMsQdIxRrOOrLYb4HAtZaypZ78c+J6rDA1UlGxadZVLmkkiv4KV5fMKQ=="],
|
||||||
|
|
||||||
"@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.0", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.0", "@textlint/resolver": "15.5.0", "@textlint/types": "15.5.0", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.21", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-DPTm2+VXKID41qKQWagg/4JynM6hEEpvbq0PlGsEoC4Xm7IqXIxFym3mSf5+ued0cuiIV1hR9kgXjqGdP035tw=="],
|
"@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.0", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.0", "@textlint/resolver": "15.5.0", "@textlint/types": "15.5.0", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.21", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-DPTm2+VXKID41qKQWagg/4JynM6hEEpvbq0PlGsEoC4Xm7IqXIxFym3mSf5+ued0cuiIV1hR9kgXjqGdP035tw=="],
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@
|
|||||||
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||||
"desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
|
"desktop:stop-cli": "node ./packages/desktop/scripts/opencode-cli.mjs stop",
|
||||||
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
|
"desktop:dev": "node ./packages/desktop/scripts/desktop-dev.mjs",
|
||||||
"desktop:build": "bun run --cwd packages/desktop build && bun run --cwd packages/desktop tauri build",
|
"desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build",
|
||||||
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||||
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||||
"vscode:dev": "bun run --cwd packages/vscode dev",
|
"vscode:dev": "bun run --cwd packages/vscode dev",
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ src-tauri/target/
|
|||||||
# Tauri generated code
|
# Tauri generated code
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
|
# Desktop sidecar + bundled web assets (generated)
|
||||||
|
src-tauri/resources/web-dist/
|
||||||
|
src-tauri/sidecars/openchamber-server-*
|
||||||
|
src-tauri/sidecars/*.exe
|
||||||
|
!src-tauri/resources/.gitkeep
|
||||||
|
!src-tauri/sidecars/.gitkeep
|
||||||
|
|
||||||
# OpenCode CLI state tracking
|
# OpenCode CLI state tracking
|
||||||
.opencode-cli-state.json
|
.opencode-cli-state.json
|
||||||
|
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="h-full">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#151313" />
|
|
||||||
<link rel="preload" href="/ibm-plex-mono-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin />
|
|
||||||
<title>OpenChamber Desktop</title>
|
|
||||||
<script>
|
|
||||||
// Blocking script to detect and apply theme before first paint
|
|
||||||
(function() {
|
|
||||||
var themeMode = localStorage.getItem('themeMode');
|
|
||||||
var isDark;
|
|
||||||
if (themeMode === 'dark') {
|
|
||||||
isDark = true;
|
|
||||||
} else if (themeMode === 'light') {
|
|
||||||
isDark = false;
|
|
||||||
} else {
|
|
||||||
// 'system' or not set - use system preference
|
|
||||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
|
||||||
document.documentElement.classList.add(isDark ? 'dark' : 'light');
|
|
||||||
document.documentElement.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
|
|
||||||
// Store for use in inline styles
|
|
||||||
window.__INITIAL_THEME_DARK__ = isDark;
|
|
||||||
|
|
||||||
// Splash colors persisted by the app theme system
|
|
||||||
var splashBgLight = localStorage.getItem('splashBgLight');
|
|
||||||
var splashFgLight = localStorage.getItem('splashFgLight');
|
|
||||||
var splashBgDark = localStorage.getItem('splashBgDark');
|
|
||||||
var splashFgDark = localStorage.getItem('splashFgDark');
|
|
||||||
|
|
||||||
if (splashBgLight) document.documentElement.style.setProperty('--splash-background-light', splashBgLight);
|
|
||||||
if (splashFgLight) document.documentElement.style.setProperty('--splash-stroke-light', splashFgLight);
|
|
||||||
if (splashBgDark) document.documentElement.style.setProperty('--splash-background-dark', splashBgDark);
|
|
||||||
if (splashFgDark) document.documentElement.style.setProperty('--splash-stroke-dark', splashFgDark);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
@font-face {
|
|
||||||
font-family: 'IBM Plex Mono';
|
|
||||||
font-weight: 600;
|
|
||||||
font-style: normal;
|
|
||||||
src: url('/ibm-plex-mono-latin-600-normal.woff2') format('woff2');
|
|
||||||
font-display: block;
|
|
||||||
}
|
|
||||||
/* Theme-aware color scheme */
|
|
||||||
html.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
html.light {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
:root {
|
|
||||||
--splash-background-dark: #151313;
|
|
||||||
--splash-stroke-dark: white;
|
|
||||||
--splash-background-light: #F6F4EF;
|
|
||||||
--splash-stroke-light: black;
|
|
||||||
|
|
||||||
--splash-background: var(--splash-background-dark);
|
|
||||||
--splash-stroke: var(--splash-stroke-dark);
|
|
||||||
|
|
||||||
/* Fallback fills (overridden below when supported) */
|
|
||||||
--splash-face-fill: rgba(255, 255, 255, 0.15);
|
|
||||||
--splash-cell-fill: rgba(255, 255, 255, 0.35);
|
|
||||||
--splash-logo-fill: var(--splash-stroke);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.light {
|
|
||||||
--splash-background: var(--splash-background-light);
|
|
||||||
--splash-stroke: var(--splash-stroke-light);
|
|
||||||
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
|
||||||
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
|
||||||
--splash-logo-fill: var(--splash-stroke);
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
--splash-background: var(--splash-background-dark);
|
|
||||||
--splash-stroke: var(--splash-stroke-dark);
|
|
||||||
--splash-logo-fill: var(--splash-stroke);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--splash-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (color: color-mix(in srgb, white 50%, transparent)) {
|
|
||||||
:root {
|
|
||||||
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
|
|
||||||
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
@keyframes logo-pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
.logo-pulse {
|
|
||||||
animation: logo-pulse 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="h-full">
|
|
||||||
<div id="root" class="h-full">
|
|
||||||
<div class="loading">
|
|
||||||
<svg width="180" height="180" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading">
|
|
||||||
<!-- Left face with stroke -->
|
|
||||||
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
|
||||||
<!-- Left face grid cells -->
|
|
||||||
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
||||||
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
||||||
<path d="M29.216 38 L18.824 32 L18.824 44 L29.216 50 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
||||||
<path d="M18.824 32 L8.432 26 L8.432 38 L18.824 44 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
|
||||||
<path d="M50 62 L39.608 56 L39.608 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
|
||||||
<path d="M39.608 56 L29.216 50 L29.216 62 L39.608 68 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
|
||||||
<path d="M29.216 50 L18.824 44 L18.824 56 L29.216 62 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
|
||||||
<path d="M18.824 44 L8.432 38 L8.432 50 L18.824 56 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
|
||||||
<path d="M50 74 L39.608 68 L39.608 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
|
||||||
<path d="M39.608 68 L29.216 62 L29.216 74 L39.608 80 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
|
||||||
<path d="M29.216 62 L18.824 56 L18.824 68 L29.216 74 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
||||||
<path d="M18.824 56 L8.432 50 L8.432 62 L18.824 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
||||||
<path d="M50 86 L39.608 80 L39.608 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
|
||||||
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
||||||
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
|
||||||
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
|
||||||
<!-- Right face with stroke -->
|
|
||||||
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
|
||||||
<!-- Right face grid cells -->
|
|
||||||
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
|
||||||
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
||||||
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
||||||
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
|
||||||
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
|
||||||
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
|
||||||
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
|
||||||
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
|
||||||
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
||||||
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
|
||||||
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
|
||||||
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
||||||
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
||||||
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
|
||||||
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
|
||||||
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
||||||
<!-- Top face - open -->
|
|
||||||
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
|
||||||
<!-- OpenCode logo on top face -->
|
|
||||||
<g class="logo-pulse" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
|
|
||||||
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OpenChamber</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>OpenChamber requires JavaScript.</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -12,29 +12,15 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev --features devtools",
|
"tauri:dev": "tauri dev --features devtools",
|
||||||
"tauri:build": "tauri build",
|
"tauri:build": "tauri build",
|
||||||
"dev": "vite dev --host 127.0.0.1 --port 1421",
|
"build:sidecar": "node ./scripts/build-sidecar.mjs",
|
||||||
"build": "vite build",
|
"build": "bun -e \"process.exit(0)\"",
|
||||||
"preview": "vite preview --host 127.0.0.1 --port 5051",
|
"type-check": "bun -e \"process.exit(0)\"",
|
||||||
"type-check": "tsc --noEmit",
|
"lint": "bun -e \"process.exit(0)\""
|
||||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
|
||||||
"@tauri-apps/plugin-process": "^2",
|
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
|
||||||
"@openchamber/ui": "workspace:*",
|
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1"
|
|
||||||
},
|
},
|
||||||
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/react": "^19.1.10",
|
"typescript": "~5.8.3"
|
||||||
"@types/react-dom": "^19.1.7",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"typescript": "~5.8.3",
|
|
||||||
"vite": "^7.1.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const webDir = path.join(repoRoot, 'packages', 'web');
|
||||||
|
const desktopTauriDir = path.join(repoRoot, 'packages', 'desktop', 'src-tauri');
|
||||||
|
|
||||||
|
const resourcesDir = path.join(desktopTauriDir, 'resources');
|
||||||
|
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
|
||||||
|
const webDistDir = path.join(webDir, 'dist');
|
||||||
|
|
||||||
|
const sidecarsDir = path.join(desktopTauriDir, 'sidecars');
|
||||||
|
|
||||||
|
const inferTargetTriple = () => {
|
||||||
|
if (typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' && process.env.TAURI_ENV_TARGET_TRIPLE.trim()) {
|
||||||
|
return process.env.TAURI_ENV_TARGET_TRIPLE.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return 'x86_64-pc-windows-msvc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${process.arch}-${process.platform}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetTriple = inferTargetTriple();
|
||||||
|
const sidecarBaseName = process.platform === 'win32'
|
||||||
|
? `openchamber-server-${targetTriple}.exe`
|
||||||
|
: `openchamber-server-${targetTriple}`;
|
||||||
|
const sidecarOutPath = path.join(sidecarsDir, sidecarBaseName);
|
||||||
|
|
||||||
|
|
||||||
|
const run = (cmd, args, cwd) => {
|
||||||
|
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveBun = () => {
|
||||||
|
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
|
||||||
|
return process.env.BUN.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
|
||||||
|
const resolved = (result.stdout || '').trim();
|
||||||
|
if (resolved) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bun';
|
||||||
|
};
|
||||||
|
|
||||||
|
const bunExe = resolveBun();
|
||||||
|
|
||||||
|
|
||||||
|
const copyDir = async (src, dst) => {
|
||||||
|
await fs.mkdir(dst, { recursive: true });
|
||||||
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const from = path.join(src, entry.name);
|
||||||
|
const to = path.join(dst, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDir(from, to);
|
||||||
|
} else if (entry.isSymbolicLink()) {
|
||||||
|
const link = await fs.readlink(from);
|
||||||
|
await fs.symlink(link, to);
|
||||||
|
} else {
|
||||||
|
await fs.copyFile(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[desktop] building web UI dist...');
|
||||||
|
run(bunExe, ['run', 'build'], webDir);
|
||||||
|
|
||||||
|
console.log('[desktop] preparing tauri resources...');
|
||||||
|
await fs.mkdir(resourcesDir, { recursive: true });
|
||||||
|
await fs.rm(resourcesWebDistDir, { recursive: true, force: true });
|
||||||
|
await copyDir(webDistDir, resourcesWebDistDir);
|
||||||
|
|
||||||
|
console.log('[desktop] building openchamber-server sidecar...');
|
||||||
|
await fs.mkdir(sidecarsDir, { recursive: true });
|
||||||
|
|
||||||
|
run(bunExe, [
|
||||||
|
'build',
|
||||||
|
'--compile',
|
||||||
|
path.join(webDir, 'server', 'index.js'),
|
||||||
|
'--outfile',
|
||||||
|
sidecarOutPath,
|
||||||
|
], repoRoot);
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
await fs.chmod(sidecarOutPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[desktop] sidecar ready: ${sidecarOutPath}`);
|
||||||
|
console.log(`[desktop] web assets ready: ${resourcesWebDistDir}`);
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { startCli, stopCli } from './opencode-cli.mjs';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -19,8 +18,6 @@ function spawnProcess(command, args, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await startCli();
|
|
||||||
|
|
||||||
const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']);
|
const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']);
|
||||||
|
|
||||||
let cleaning = false;
|
let cleaning = false;
|
||||||
@@ -44,10 +41,6 @@ async function main() {
|
|||||||
|
|
||||||
stopChild(tauriProcess, 'Tauri dev process');
|
stopChild(tauriProcess, 'Tauri dev process');
|
||||||
|
|
||||||
await stopCli({ silent: true }).catch((error) => {
|
|
||||||
console.warn('[desktop:dev] Failed to stop OpenCode CLI:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.exit(typeof code === 'number' ? code : 0);
|
process.exit(typeof code === 'number' ? code : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||||
|
const desktopDir = path.join(repoRoot, 'packages', 'desktop');
|
||||||
|
const tauriDir = path.join(desktopDir, 'src-tauri');
|
||||||
|
|
||||||
|
const inferTargetTriple = () => {
|
||||||
|
const fromEnv = typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' ? process.env.TAURI_ENV_TARGET_TRIPLE.trim() : '';
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return 'x86_64-pc-windows-msvc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${process.arch}-${process.platform}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetTriple = inferTargetTriple();
|
||||||
|
const sidecarName = process.platform === 'win32'
|
||||||
|
? `openchamber-server-${targetTriple}.exe`
|
||||||
|
: `openchamber-server-${targetTriple}`;
|
||||||
|
|
||||||
|
const sidecarPath = path.join(tauriDir, 'sidecars', sidecarName);
|
||||||
|
const distDir = path.join(tauriDir, 'resources', 'web-dist');
|
||||||
|
|
||||||
|
const run = (cmd, args, cwd) => {
|
||||||
|
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[desktop] ensuring sidecar + web-dist...');
|
||||||
|
run('node', ['./scripts/build-sidecar.mjs'], desktopDir);
|
||||||
|
|
||||||
|
console.log('[desktop] starting dev server on http://127.0.0.1:3001 ...');
|
||||||
|
|
||||||
|
const child = spawn(sidecarPath, ['--port', '3001'], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
OPENCHAMBER_HOST: '127.0.0.1',
|
||||||
|
OPENCHAMBER_DIST_DIR: distDir,
|
||||||
|
NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1',
|
||||||
|
no_proxy: process.env.no_proxy || 'localhost,127.0.0.1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('exit', shutdown);
|
||||||
Generated
+17
-751
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,6 @@ version = "1.6.3"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "openchamber_desktop"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "openchamber-desktop"
|
name = "openchamber-desktop"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
@@ -18,46 +14,18 @@ devtools = ["tauri/devtools"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.86"
|
anyhow = "1.0.86"
|
||||||
axum = { version = "0.8.4", features = ["macros"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
dirs = "5.0"
|
|
||||||
fastrand = "2.0"
|
|
||||||
futures-util = "0.3"
|
|
||||||
log = "0.4.28"
|
log = "0.4.28"
|
||||||
nix = { version = "0.28", features = ["signal"] }
|
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] }
|
||||||
objc = "0.2.7"
|
|
||||||
objc2 = "0.6.3"
|
|
||||||
objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString", "NSObjCRuntime"] }
|
|
||||||
once_cell = "1.19"
|
|
||||||
parking_lot = "0.12.3"
|
|
||||||
portable-pty = "0.9.0"
|
|
||||||
portpicker = "0.1.1"
|
|
||||||
regex = "1.10.4"
|
|
||||||
reqwest = { version = "0.12.4", default-features = false, features = ["json", "stream", "rustls-tls", "gzip", "brotli", "deflate"] }
|
|
||||||
serde = { version = "1.0.210", features = ["derive"] }
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
serde_yaml = "0.9"
|
|
||||||
json5 = "0.4"
|
|
||||||
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
tauri = { version = "2.9.4", features = ["macos-private-api"] }
|
||||||
tauri-plugin-dialog = "2.4.2"
|
tauri-plugin-dialog = "2.4.2"
|
||||||
tauri-plugin-fs = "2.4.4"
|
|
||||||
tauri-plugin-log = "2.7.1"
|
tauri-plugin-log = "2.7.1"
|
||||||
tauri-plugin-shell = "2.3.3"
|
tauri-plugin-shell = "2.3.3"
|
||||||
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] }
|
|
||||||
tower-http = { version = "0.5.2", features = ["cors"] }
|
|
||||||
url = "2.5"
|
|
||||||
uuid = { version = "1.18.1", features = ["v4"] }
|
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.3.3"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tokio = { version = "1.38", features = ["rt-multi-thread", "time"] }
|
||||||
base64 = "0.22.1"
|
url = "2.5"
|
||||||
urlencoding = "2.1"
|
|
||||||
zip = "2.1"
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.3", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
objc2-app-kit = { version = "0.3.2", features = ["NSView", "NSResponder"] }
|
|
||||||
objc2-quartz-core = { version = "0.3.2", features = ["CALayer"] }
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for OpenChamber desktop runtime",
|
"description": "Default capabilities for OpenChamber desktop runtime",
|
||||||
|
"remote": {
|
||||||
|
"urls": [
|
||||||
|
"http://127.0.0.1:*/*",
|
||||||
|
"http://localhost:*/*",
|
||||||
|
"http://*",
|
||||||
|
"http://*/*",
|
||||||
|
"https://*",
|
||||||
|
"https://*/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -20,24 +30,12 @@
|
|||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
"dialog:allow-ask",
|
"dialog:allow-ask",
|
||||||
"dialog:allow-confirm",
|
"dialog:allow-confirm",
|
||||||
"fs:allow-read-text-file",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"fs:allow-write-text-file",
|
|
||||||
"fs:allow-write-file",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-create",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:scope-app-index",
|
|
||||||
"fs:scope-home",
|
|
||||||
"notification:default",
|
"notification:default",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"updater:allow-check",
|
"updater:allow-check",
|
||||||
"updater:allow-download-and-install",
|
"updater:allow-download-and-install"
|
||||||
"process:allow-restart"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,631 +0,0 @@
|
|||||||
use std::{collections::{HashMap, HashSet}, path::PathBuf, time::Duration};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures_util::TryStreamExt;
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
use tauri_plugin_notification::NotificationExt;
|
|
||||||
use tokio::{io::AsyncBufReadExt, sync::Mutex};
|
|
||||||
use tokio_util::io::StreamReader;
|
|
||||||
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
|
||||||
use crate::DesktopRuntime;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct EventEnvelope {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
event_type: String,
|
|
||||||
#[serde(default)]
|
|
||||||
properties: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MultiplexedEventEnvelope {
|
|
||||||
#[serde(default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
directory: Option<String>,
|
|
||||||
payload: EventEnvelope,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_assistant_notifications(
|
|
||||||
app: AppHandle,
|
|
||||||
runtime: DesktopRuntime,
|
|
||||||
) -> tauri::async_runtime::JoinHandle<()> {
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let client = Client::builder()
|
|
||||||
// Give SSE a very long overall timeout so idle periods don't abort the stream.
|
|
||||||
.timeout(Duration::from_secs(24 * 60 * 60))
|
|
||||||
.tcp_keepalive(Some(Duration::from_secs(30)))
|
|
||||||
.build()
|
|
||||||
.expect("failed to build reqwest client");
|
|
||||||
|
|
||||||
let mut shutdown_rx = runtime.subscribe_shutdown();
|
|
||||||
let notified_messages = Mutex::new(HashSet::<String>::new());
|
|
||||||
let notified_questions = Mutex::new(HashSet::<String>::new());
|
|
||||||
let session_parent_cache = Mutex::new(HashMap::<String, Option<String>>::new());
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown_rx.recv() => {
|
|
||||||
info!("[desktop:notify] Shutdown received, stopping SSE listener");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ = async {
|
|
||||||
if let Err(err) = run_once(
|
|
||||||
&app,
|
|
||||||
&runtime,
|
|
||||||
&client,
|
|
||||||
¬ified_messages,
|
|
||||||
¬ified_questions,
|
|
||||||
&session_parent_cache,
|
|
||||||
).await {
|
|
||||||
warn!("[desktop:notify] SSE loop error: {err:?}");
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
} => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_once(
|
|
||||||
app: &AppHandle,
|
|
||||||
runtime: &DesktopRuntime,
|
|
||||||
client: &Client,
|
|
||||||
notified_messages: &Mutex<HashSet<String>>,
|
|
||||||
notified_questions: &Mutex<HashSet<String>>,
|
|
||||||
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let opencode = runtime.opencode_manager();
|
|
||||||
|
|
||||||
let port = match opencode.current_port() {
|
|
||||||
Some(port) => port,
|
|
||||||
None => {
|
|
||||||
warn!("[desktop:notify] OpenCode port unavailable; will retry");
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let prefix = opencode.api_prefix();
|
|
||||||
let base = format!("http://127.0.0.1:{port}{prefix}");
|
|
||||||
let response = connect_notifications_sse(runtime, client, &base).await?;
|
|
||||||
|
|
||||||
let stream = response
|
|
||||||
.bytes_stream()
|
|
||||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
|
|
||||||
let mut reader = StreamReader::new(stream);
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut data_lines: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
buf.clear();
|
|
||||||
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("[desktop:notify] Read error in SSE stream: {err:?}");
|
|
||||||
return Err(err.into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if bytes_read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let line = match std::str::from_utf8(&buf) {
|
|
||||||
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!("[desktop:notify] Non-UTF8 SSE chunk: {err}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if line.is_empty() {
|
|
||||||
if data_lines.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let raw = data_lines.join("\n");
|
|
||||||
data_lines.clear();
|
|
||||||
|
|
||||||
match parse_event_envelope(&raw) {
|
|
||||||
Ok(event) => {
|
|
||||||
handle_event(
|
|
||||||
app,
|
|
||||||
runtime,
|
|
||||||
client,
|
|
||||||
&base,
|
|
||||||
event,
|
|
||||||
notified_messages,
|
|
||||||
notified_questions,
|
|
||||||
session_parent_cache,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rest) = line.strip_prefix("data:") {
|
|
||||||
data_lines.push(rest.trim_start().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_event_envelope(raw: &str) -> Result<EventEnvelope> {
|
|
||||||
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
|
|
||||||
return Ok(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
|
|
||||||
Ok(multiplexed.payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
|
||||||
let settings = runtime.settings().load().await.ok()?;
|
|
||||||
|
|
||||||
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
|
||||||
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
|
||||||
if let Some(path) = projects.iter().find_map(|entry| {
|
|
||||||
let id = entry.get("id").and_then(Value::as_str)?;
|
|
||||||
if id != active_id {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
entry.get("path").and_then(Value::as_str)
|
|
||||||
}) {
|
|
||||||
return Some(expand_tilde_path(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings
|
|
||||||
.get("lastDirectory")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(expand_tilde_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_notifications_sse(
|
|
||||||
runtime: &DesktopRuntime,
|
|
||||||
client: &Client,
|
|
||||||
base: &str,
|
|
||||||
) -> Result<reqwest::Response> {
|
|
||||||
let global_url = format!("{base}/global/event");
|
|
||||||
match try_connect_sse(client, &global_url, "[desktop:notify]").await {
|
|
||||||
Ok(response) => {
|
|
||||||
debug!("[desktop:notify] Using SSE endpoint: {global_url}");
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let event_url = format!("{base}/event");
|
|
||||||
match try_connect_sse(client, &event_url, "[desktop:notify]").await {
|
|
||||||
Ok(response) => {
|
|
||||||
debug!("[desktop:notify] Using SSE endpoint: {event_url}");
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
|
||||||
anyhow::bail!("No project directory available for SSE fallback");
|
|
||||||
};
|
|
||||||
let directory = working_dir.to_string_lossy().to_string();
|
|
||||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
|
||||||
parsed
|
|
||||||
.query_pairs_mut()
|
|
||||||
.append_pair("directory", &directory);
|
|
||||||
let directory_url = parsed.to_string();
|
|
||||||
|
|
||||||
let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?;
|
|
||||||
debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}");
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_connect_sse(
|
|
||||||
client: &Client,
|
|
||||||
url: &str,
|
|
||||||
log_prefix: &str,
|
|
||||||
) -> Result<reqwest::Response> {
|
|
||||||
debug!("{log_prefix} Connecting SSE: {url}");
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(url)
|
|
||||||
.header("accept", "text/event-stream")
|
|
||||||
.header("accept-encoding", "identity")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"{log_prefix} SSE response status={} headers={:?}",
|
|
||||||
response.status(),
|
|
||||||
response.headers()
|
|
||||||
);
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("SSE connect failed with status {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_event(
|
|
||||||
app: &AppHandle,
|
|
||||||
runtime: &DesktopRuntime,
|
|
||||||
client: &Client,
|
|
||||||
base: &str,
|
|
||||||
event: EventEnvelope,
|
|
||||||
notified_messages: &Mutex<HashSet<String>>,
|
|
||||||
notified_questions: &Mutex<HashSet<String>>,
|
|
||||||
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
|
|
||||||
) {
|
|
||||||
match event.event_type.as_str() {
|
|
||||||
"message.updated" => {
|
|
||||||
handle_message_updated(
|
|
||||||
app,
|
|
||||||
runtime,
|
|
||||||
client,
|
|
||||||
base,
|
|
||||||
&event.properties,
|
|
||||||
notified_messages,
|
|
||||||
session_parent_cache,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
"question.asked" => {
|
|
||||||
handle_question_asked(app, &event.properties, notified_questions).await;
|
|
||||||
}
|
|
||||||
"permission.asked" => {
|
|
||||||
handle_permission_asked(app, &event.properties, notified_questions).await;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_session_parent_id(
|
|
||||||
client: &Client,
|
|
||||||
base: &str,
|
|
||||||
session_id: &str,
|
|
||||||
cache: &Mutex<HashMap<String, Option<String>>>,
|
|
||||||
) -> Option<Option<String>> {
|
|
||||||
{
|
|
||||||
let locked = cache.lock().await;
|
|
||||||
if let Some(existing) = locked.get(session_id) {
|
|
||||||
return Some(existing.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail open: on any error, return None (unknown)
|
|
||||||
let sessions_url = format!("{base}/session");
|
|
||||||
let response = match tokio::time::timeout(
|
|
||||||
Duration::from_secs(2),
|
|
||||||
client.get(&sessions_url).header("accept", "application/json").send(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(resp)) => resp,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: Value = match response.json().await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let parent = data
|
|
||||||
.as_array()
|
|
||||||
.and_then(|arr| {
|
|
||||||
arr.iter().find_map(|entry| {
|
|
||||||
let id = entry.get("id").and_then(Value::as_str)?;
|
|
||||||
if id != session_id {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let parent = entry.get("parentID").and_then(Value::as_str);
|
|
||||||
Some(parent.filter(|s| !s.is_empty()).map(|s| s.to_string()))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut locked = cache.lock().await;
|
|
||||||
locked.insert(session_id.to_string(), parent.clone());
|
|
||||||
}
|
|
||||||
Some(parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_question_asked(
|
|
||||||
app: &AppHandle,
|
|
||||||
properties: &Value,
|
|
||||||
notified_questions: &Mutex<HashSet<String>>,
|
|
||||||
) {
|
|
||||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
|
||||||
let question_id = properties.get("id").and_then(Value::as_str);
|
|
||||||
|
|
||||||
let (session_id, question_id) = match (session_id, question_id) {
|
|
||||||
(Some(s), Some(q)) => (s, q),
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = format!("{}:{}", session_id, question_id);
|
|
||||||
{
|
|
||||||
let mut notified = notified_questions.lock().await;
|
|
||||||
if notified.contains(&key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notified.insert(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
let should_notify = app
|
|
||||||
.get_webview_window("main")
|
|
||||||
.map(|window| {
|
|
||||||
let focused = window.is_focused().unwrap_or(false);
|
|
||||||
let minimized = window.is_minimized().unwrap_or(false);
|
|
||||||
!focused || minimized
|
|
||||||
})
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if should_notify {
|
|
||||||
let (title, body) = properties
|
|
||||||
.get("questions")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.and_then(|questions| questions.first())
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.map(|first| {
|
|
||||||
let header = first
|
|
||||||
.get("header")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let question = first
|
|
||||||
.get("question")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
let title = if header.to_ascii_lowercase().contains("plan mode") {
|
|
||||||
"Switch to plan mode".to_string()
|
|
||||||
} else if header.to_ascii_lowercase().contains("build agent") {
|
|
||||||
"Switch to build mode".to_string()
|
|
||||||
} else if !header.is_empty() {
|
|
||||||
header.to_string()
|
|
||||||
} else {
|
|
||||||
"Input needed".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = if !question.is_empty() {
|
|
||||||
question.to_string()
|
|
||||||
} else {
|
|
||||||
"Agent is waiting for your response".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
(title, body)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
(
|
|
||||||
"Input needed".to_string(),
|
|
||||||
"Agent is waiting for your response".to_string(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = app
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title(title)
|
|
||||||
.body(body)
|
|
||||||
.sound("Glass")
|
|
||||||
.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_permission_asked(
|
|
||||||
app: &AppHandle,
|
|
||||||
properties: &Value,
|
|
||||||
notified_requests: &Mutex<HashSet<String>>,
|
|
||||||
) {
|
|
||||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
|
||||||
let request_id = properties.get("id").and_then(Value::as_str);
|
|
||||||
|
|
||||||
let (session_id, request_id) = match (session_id, request_id) {
|
|
||||||
(Some(s), Some(r)) => (s, r),
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = format!("{}:{}", session_id, request_id);
|
|
||||||
{
|
|
||||||
let mut notified = notified_requests.lock().await;
|
|
||||||
if notified.contains(&key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notified.insert(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
let permission = properties
|
|
||||||
.get("permission")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or("Agent requested permission");
|
|
||||||
|
|
||||||
let should_notify = app
|
|
||||||
.get_webview_window("main")
|
|
||||||
.map(|window| {
|
|
||||||
let focused = window.is_focused().unwrap_or(false);
|
|
||||||
let minimized = window.is_minimized().unwrap_or(false);
|
|
||||||
!focused || minimized
|
|
||||||
})
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if should_notify {
|
|
||||||
let _ = app
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title("Permission required")
|
|
||||||
.body(permission)
|
|
||||||
.sound("Glass")
|
|
||||||
.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_message_updated(
|
|
||||||
app: &AppHandle,
|
|
||||||
runtime: &DesktopRuntime,
|
|
||||||
client: &Client,
|
|
||||||
base: &str,
|
|
||||||
properties: &Value,
|
|
||||||
notified_messages: &Mutex<HashSet<String>>,
|
|
||||||
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
|
|
||||||
) {
|
|
||||||
let Some(info) = properties.get("info") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
if role != "assistant" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let finish = info.get("finish").and_then(Value::as_str);
|
|
||||||
if finish != Some("stop") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let message_id = match info.get("id").and_then(Value::as_str) {
|
|
||||||
Some(id) => id.to_string(),
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subtask filtering (fail open)
|
|
||||||
let notify_on_subtasks = runtime
|
|
||||||
.settings()
|
|
||||||
.load()
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(|settings| settings.get("notifyOnSubtasks").and_then(Value::as_bool))
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if !notify_on_subtasks {
|
|
||||||
let session_id = info
|
|
||||||
.get("sessionID")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| properties.get("sessionID").and_then(Value::as_str))
|
|
||||||
.or_else(|| properties.get("sessionId").and_then(Value::as_str));
|
|
||||||
|
|
||||||
if let Some(session_id) = session_id {
|
|
||||||
if let Some(parent) = resolve_session_parent_id(client, base, session_id, session_parent_cache).await {
|
|
||||||
if parent.is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut notified = notified_messages.lock().await;
|
|
||||||
if notified.contains(&message_id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notified.insert(message_id.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_mode = info
|
|
||||||
.get("mode")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or("agent");
|
|
||||||
let raw_model = info
|
|
||||||
.get("modelID")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or("assistant");
|
|
||||||
|
|
||||||
let title = format!("{} agent is ready", format_mode(raw_mode));
|
|
||||||
let body = format!("{} completed the task", format_model_id(raw_model));
|
|
||||||
|
|
||||||
let should_notify = app
|
|
||||||
.get_webview_window("main")
|
|
||||||
.map(|window| {
|
|
||||||
let focused = window.is_focused().unwrap_or(false);
|
|
||||||
let minimized = window.is_minimized().unwrap_or(false);
|
|
||||||
// Only notify when the app is not in the foreground or is minimized
|
|
||||||
!focused || minimized
|
|
||||||
})
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if should_notify {
|
|
||||||
let _ = app
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title(title)
|
|
||||||
.body(body)
|
|
||||||
.sound("Glass")
|
|
||||||
.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_mode(raw: &str) -> String {
|
|
||||||
if raw.is_empty() {
|
|
||||||
return "Agent".to_string();
|
|
||||||
}
|
|
||||||
raw.split(&['-', '_', ' '][..])
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(capitalize)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_model_id(raw: &str) -> String {
|
|
||||||
if raw.is_empty() {
|
|
||||||
return "Assistant".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokens: Vec<&str> = raw.split(&['-', '_'][..]).collect();
|
|
||||||
let mut result: Vec<String> = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while i < tokens.len() {
|
|
||||||
let current = tokens[i];
|
|
||||||
|
|
||||||
if current.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
if i + 1 < tokens.len() && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
let combined = format!("{}.{}", current, tokens[i + 1]);
|
|
||||||
result.push(combined);
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(current.to_string());
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
.into_iter()
|
|
||||||
.map(|part| capitalize(&part))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capitalize(s: &str) -> String {
|
|
||||||
let mut chars = s.chars();
|
|
||||||
match chars.next() {
|
|
||||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
use crate::logging::log_file_path;
|
|
||||||
use serde::Serialize;
|
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DesktopLogFile {
|
|
||||||
pub file_name: String,
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
|
|
||||||
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
|
|
||||||
let content = fs::read_to_string(&path)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("Failed to read log file: {err}"))?;
|
|
||||||
let file_name = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|value| value.to_str())
|
|
||||||
.unwrap_or("openchamber.log")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(DesktopLogFile { file_name, content })
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
pub mod files;
|
|
||||||
pub mod git;
|
|
||||||
pub mod github;
|
|
||||||
pub mod logs;
|
|
||||||
pub mod notifications;
|
|
||||||
pub mod permissions;
|
|
||||||
pub mod settings;
|
|
||||||
pub mod terminal;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
use tauri::{AppHandle, Runtime};
|
|
||||||
use tauri_plugin_notification::NotificationExt;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct NotificationPayload {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub body: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn desktop_notify<R: Runtime>(
|
|
||||||
app: AppHandle<R>,
|
|
||||||
payload: Option<NotificationPayload>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let title = payload
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.title.as_deref())
|
|
||||||
.unwrap_or("OpenChamber");
|
|
||||||
let body = payload
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.body.as_deref())
|
|
||||||
.unwrap_or("Task completed");
|
|
||||||
|
|
||||||
match app
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title(title)
|
|
||||||
.body(body)
|
|
||||||
.sound("Glass")
|
|
||||||
.show()
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(true),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use log::{info, warn};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
|
||||||
use tauri::AppHandle;
|
|
||||||
use tauri::State;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
|
||||||
use crate::DesktopRuntime;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DirectoryPermissionRequest {
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DirectoryPermissionResult {
|
|
||||||
success: bool,
|
|
||||||
path: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
project_id: Option<String>,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct StartAccessingResult {
|
|
||||||
success: bool,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process directory selection from frontend.
|
|
||||||
/// Updates settings (projects, activeProjectId, lastDirectory).
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn process_directory_selection(
|
|
||||||
path: String,
|
|
||||||
state: State<'_, DesktopRuntime>,
|
|
||||||
) -> Result<DirectoryPermissionResult, String> {
|
|
||||||
// Validate directory exists
|
|
||||||
let mut path_buf = expand_tilde_path(&path);
|
|
||||||
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
|
|
||||||
path_buf = canonicalized;
|
|
||||||
}
|
|
||||||
let normalized_path = path_buf.to_string_lossy().to_string();
|
|
||||||
if !path_buf.exists() {
|
|
||||||
return Ok(DirectoryPermissionResult {
|
|
||||||
success: false,
|
|
||||||
path: None,
|
|
||||||
project_id: None,
|
|
||||||
error: Some("Directory does not exist".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !path_buf.is_dir() {
|
|
||||||
return Ok(DirectoryPermissionResult {
|
|
||||||
success: false,
|
|
||||||
path: None,
|
|
||||||
project_id: None,
|
|
||||||
error: Some("Path is not a directory".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update settings with projects + activeProjectId + lastDirectory
|
|
||||||
let now = Utc::now().timestamp_millis();
|
|
||||||
let normalized_path_for_update = normalized_path.clone();
|
|
||||||
|
|
||||||
let (_, project_id) = state
|
|
||||||
.settings()
|
|
||||||
.update_with(|mut settings| {
|
|
||||||
if !settings.is_object() {
|
|
||||||
settings = json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
let project_id = {
|
|
||||||
let obj = settings.as_object_mut().unwrap();
|
|
||||||
|
|
||||||
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
|
||||||
if !projects_value.is_array() {
|
|
||||||
*projects_value = json!([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let projects = projects_value.as_array_mut().unwrap();
|
|
||||||
|
|
||||||
let existing_index = projects.iter().position(|entry| {
|
|
||||||
entry
|
|
||||||
.get("path")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(|value| value == normalized_path_for_update)
|
|
||||||
.unwrap_or(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(index) = existing_index {
|
|
||||||
let entry = projects
|
|
||||||
.get_mut(index)
|
|
||||||
.and_then(|value| value.as_object_mut());
|
|
||||||
if let Some(entry) = entry {
|
|
||||||
entry.insert("lastOpenedAt".to_string(), json!(now));
|
|
||||||
if let Some(id) = entry.get("id").and_then(|value| value.as_str()) {
|
|
||||||
id.to_string()
|
|
||||||
} else {
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
entry.insert("id".to_string(), json!(id));
|
|
||||||
id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
projects[index] = json!({
|
|
||||||
"id": id,
|
|
||||||
"path": normalized_path_for_update,
|
|
||||||
"addedAt": now,
|
|
||||||
"lastOpenedAt": now
|
|
||||||
});
|
|
||||||
id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
projects.push(json!({
|
|
||||||
"id": id,
|
|
||||||
"path": normalized_path_for_update,
|
|
||||||
"addedAt": now,
|
|
||||||
"lastOpenedAt": now
|
|
||||||
}));
|
|
||||||
id
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(obj) = settings.as_object_mut() {
|
|
||||||
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
|
|
||||||
obj.insert(
|
|
||||||
"lastDirectory".to_string(),
|
|
||||||
json!(normalized_path_for_update),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
(settings, project_id)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"[permissions] Updated settings with active project {}: {}",
|
|
||||||
project_id, normalized_path
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(DirectoryPermissionResult {
|
|
||||||
success: true,
|
|
||||||
path: Some(normalized_path),
|
|
||||||
project_id: Some(project_id),
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy directory picker command (frontend handles actual dialog)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn pick_directory(
|
|
||||||
_app_handle: AppHandle,
|
|
||||||
_state: State<'_, DesktopRuntime>,
|
|
||||||
) -> Result<DirectoryPermissionResult, String> {
|
|
||||||
Ok(DirectoryPermissionResult {
|
|
||||||
success: false,
|
|
||||||
path: None,
|
|
||||||
project_id: None,
|
|
||||||
error: Some(
|
|
||||||
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request directory access (desktop implementation)
|
|
||||||
/// For unsandboxed apps, just validates the path is accessible
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn request_directory_access(
|
|
||||||
request: DirectoryPermissionRequest,
|
|
||||||
_state: State<'_, DesktopRuntime>,
|
|
||||||
) -> Result<DirectoryPermissionResult, String> {
|
|
||||||
let path = request.path;
|
|
||||||
|
|
||||||
let mut path_buf = expand_tilde_path(&path);
|
|
||||||
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
|
|
||||||
path_buf = canonicalized;
|
|
||||||
}
|
|
||||||
let normalized_path = path_buf.to_string_lossy().to_string();
|
|
||||||
if !path_buf.exists() {
|
|
||||||
return Ok(DirectoryPermissionResult {
|
|
||||||
success: false,
|
|
||||||
path: None,
|
|
||||||
project_id: None,
|
|
||||||
error: Some("Directory does not exist".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !path_buf.is_dir() {
|
|
||||||
return Ok(DirectoryPermissionResult {
|
|
||||||
success: false,
|
|
||||||
path: None,
|
|
||||||
project_id: None,
|
|
||||||
error: Some("Path is not a directory".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// For unsandboxed apps, no bookmark needed - just verify access
|
|
||||||
match std::fs::read_dir(&path_buf) {
|
|
||||||
Ok(_) => Ok(DirectoryPermissionResult {
|
|
||||||
success: true,
|
|
||||||
path: Some(normalized_path),
|
|
||||||
project_id: None,
|
|
||||||
error: None,
|
|
||||||
}),
|
|
||||||
Err(e) => Ok(DirectoryPermissionResult {
|
|
||||||
success: false,
|
|
||||||
path: None,
|
|
||||||
project_id: None,
|
|
||||||
error: Some(format!("Cannot access directory: {}", e)),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start accessing directory (desktop implementation)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn start_accessing_directory(
|
|
||||||
path: String,
|
|
||||||
_state: State<'_, DesktopRuntime>,
|
|
||||||
) -> Result<StartAccessingResult, String> {
|
|
||||||
// Check if directory exists and is accessible
|
|
||||||
let path_buf = std::path::PathBuf::from(&path);
|
|
||||||
|
|
||||||
if !path_buf.exists() {
|
|
||||||
return Ok(StartAccessingResult {
|
|
||||||
success: false,
|
|
||||||
error: Some("Directory does not exist".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !path_buf.is_dir() {
|
|
||||||
return Ok(StartAccessingResult {
|
|
||||||
success: false,
|
|
||||||
error: Some("Path is not a directory".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to read the directory to verify access
|
|
||||||
match std::fs::read_dir(&path_buf) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("Successfully started accessing directory: {}", path);
|
|
||||||
Ok(StartAccessingResult {
|
|
||||||
success: true,
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to access directory {}: {}", path, e);
|
|
||||||
Ok(StartAccessingResult {
|
|
||||||
success: false,
|
|
||||||
error: Some(format!("Failed to access directory: {}", e)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop accessing directory (desktop implementation)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn stop_accessing_directory(
|
|
||||||
_path: String,
|
|
||||||
_state: State<'_, DesktopRuntime>,
|
|
||||||
) -> Result<StartAccessingResult, String> {
|
|
||||||
// For Stage 1, just confirm the operation
|
|
||||||
// Full implementation would call stopAccessingSecurityScopedResource
|
|
||||||
info!("Stopped accessing directory");
|
|
||||||
Ok(StartAccessingResult {
|
|
||||||
success: true,
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
|
|
||||||
// For unsandboxed apps, no bookmarks needed
|
|
||||||
// Directory access is restored from settings.lastDirectory
|
|
||||||
info!("[permissions] Bookmark restore not needed for unsandboxed app");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,926 +0,0 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use tauri::State;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
|
||||||
use crate::DesktopRuntime;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SettingsLoadResult {
|
|
||||||
settings: Value,
|
|
||||||
source: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RestartResult {
|
|
||||||
restarted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load settings from disk.
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
|
||||||
let (settings, _) = state
|
|
||||||
.settings()
|
|
||||||
.update_with(|mut settings| {
|
|
||||||
migrate_legacy_project_settings(&mut settings);
|
|
||||||
migrate_legacy_theme_settings(&mut settings);
|
|
||||||
normalize_project_selection(&mut settings);
|
|
||||||
(settings, ())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
|
||||||
|
|
||||||
Ok(SettingsLoadResult {
|
|
||||||
settings: format_settings_response(&settings),
|
|
||||||
source: "desktop".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save settings to disk with merge logic.
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_settings(
|
|
||||||
changes: Value,
|
|
||||||
state: State<'_, DesktopRuntime>,
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
let sanitized_changes = sanitize_settings_update(&changes);
|
|
||||||
|
|
||||||
let (merged, _) = state
|
|
||||||
.settings()
|
|
||||||
.update_with(|current| {
|
|
||||||
let mut merged = merge_persisted_settings(¤t, &sanitized_changes);
|
|
||||||
migrate_legacy_theme_settings(&mut merged);
|
|
||||||
normalize_project_selection(&mut merged);
|
|
||||||
(merged, ())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
|
||||||
|
|
||||||
Ok(format_settings_response(&merged))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the backend process (config reload).
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
|
||||||
state
|
|
||||||
.opencode
|
|
||||||
.restart()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
|
|
||||||
|
|
||||||
Ok(RestartResult { restarted: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_projects(value: &Value) -> Option<Value> {
|
|
||||||
let arr = value.as_array()?;
|
|
||||||
let mut seen_ids = HashSet::new();
|
|
||||||
let mut seen_paths = HashSet::new();
|
|
||||||
let mut result = Vec::new();
|
|
||||||
|
|
||||||
for entry in arr {
|
|
||||||
let Some(obj) = entry.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
|
||||||
let raw_path = obj
|
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
if id.is_empty() || raw_path.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string();
|
|
||||||
let normalized = if expanded == "/" {
|
|
||||||
expanded
|
|
||||||
} else {
|
|
||||||
expanded.trim_end_matches('/').replace('\\', "/")
|
|
||||||
};
|
|
||||||
|
|
||||||
if normalized.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if seen_ids.contains(id) || seen_paths.contains(&normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen_ids.insert(id.to_string());
|
|
||||||
seen_paths.insert(normalized.clone());
|
|
||||||
|
|
||||||
let mut project = serde_json::Map::new();
|
|
||||||
project.insert("id".to_string(), json!(id));
|
|
||||||
project.insert("path".to_string(), json!(normalized));
|
|
||||||
|
|
||||||
if let Some(Value::String(label)) = obj.get("label") {
|
|
||||||
if !label.trim().is_empty() {
|
|
||||||
project.insert("label".to_string(), json!(label.trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(num)) = obj.get("addedAt") {
|
|
||||||
if let Some(value) = num.as_i64() {
|
|
||||||
if value >= 0 {
|
|
||||||
project.insert("addedAt".to_string(), json!(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(num)) = obj.get("lastOpenedAt") {
|
|
||||||
if let Some(value) = num.as_i64() {
|
|
||||||
if value >= 0 {
|
|
||||||
project.insert("lastOpenedAt".to_string(), json!(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve worktreeDefaults
|
|
||||||
if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") {
|
|
||||||
let mut defaults = serde_json::Map::new();
|
|
||||||
if let Some(Value::String(s)) = wt.get("branchPrefix") {
|
|
||||||
if !s.trim().is_empty() {
|
|
||||||
defaults.insert("branchPrefix".to_string(), json!(s.trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = wt.get("baseBranch") {
|
|
||||||
if !s.trim().is_empty() {
|
|
||||||
defaults.insert("baseBranch".to_string(), json!(s.trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") {
|
|
||||||
defaults.insert("autoCreateWorktree".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if !defaults.is_empty() {
|
|
||||||
project.insert("worktreeDefaults".to_string(), Value::Object(defaults));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(Value::Object(project));
|
|
||||||
}
|
|
||||||
|
|
||||||
if arr.is_empty() {
|
|
||||||
return Some(Value::Array(vec![]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(Value::Array(result))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
|
||||||
fn sanitize_settings_update(payload: &Value) -> Value {
|
|
||||||
let mut result = json!({});
|
|
||||||
|
|
||||||
if let Some(obj) = payload.as_object() {
|
|
||||||
let result_obj = result.as_object_mut().unwrap();
|
|
||||||
|
|
||||||
// String fields
|
|
||||||
if let Some(Value::String(s)) = obj.get("themeId") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("themeId".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("themeVariant") {
|
|
||||||
if s == "light" || s == "dark" {
|
|
||||||
result_obj.insert("themeVariant".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("lightThemeId") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("lightThemeId".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("darkThemeId") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("darkThemeId".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("lastDirectory") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
|
|
||||||
result_obj.insert("lastDirectory".to_string(), json!(expanded));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
|
|
||||||
result_obj.insert("homeDirectory".to_string(), json!(expanded));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(projects) = obj.get("projects").and_then(sanitize_projects) {
|
|
||||||
result_obj.insert("projects".to_string(), projects);
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("activeProjectId") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("activeProjectId".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("uiFont") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("uiFont".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("monoFont") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("monoFont".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitHub OAuth config (non-secret)
|
|
||||||
if let Some(Value::String(s)) = obj.get("githubClientId") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
result_obj.insert("githubClientId".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("githubScopes") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
result_obj.insert("githubScopes".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("defaultModel") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
result_obj.insert("defaultModel".to_string(), Value::Null);
|
|
||||||
} else {
|
|
||||||
result_obj.insert("defaultModel".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("defaultVariant") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
result_obj.insert("defaultVariant".to_string(), Value::Null);
|
|
||||||
} else {
|
|
||||||
result_obj.insert("defaultVariant".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("defaultAgent") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
result_obj.insert("defaultAgent".to_string(), Value::Null);
|
|
||||||
} else {
|
|
||||||
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("defaultGitIdentityId") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
result_obj.insert("defaultGitIdentityId".to_string(), Value::Null);
|
|
||||||
} else {
|
|
||||||
result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Boolean fields
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") {
|
|
||||||
result_obj.insert("gitmojiEnabled".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
|
||||||
result_obj.insert("useSystemTheme".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
|
||||||
result_obj.insert("showReasoningTraces".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("showTextJustificationActivity") {
|
|
||||||
result_obj.insert("showTextJustificationActivity".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") {
|
|
||||||
result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("notifyOnSubtasks") {
|
|
||||||
result_obj.insert("notifyOnSubtasks".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("usageAutoRefresh") {
|
|
||||||
result_obj.insert("usageAutoRefresh".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("notificationMode") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed == "always" || trimmed == "hidden-only" {
|
|
||||||
result_obj.insert("notificationMode".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
|
|
||||||
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") {
|
|
||||||
result_obj.insert("queueModeEnabled".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("autoCreateWorktree") {
|
|
||||||
result_obj.insert("autoCreateWorktree".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("toolCallExpansion") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed == "collapsed" || trimmed == "activity" || trimmed == "detailed" {
|
|
||||||
result_obj.insert("toolCallExpansion".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number fields
|
|
||||||
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| {
|
|
||||||
n.as_i64()
|
|
||||||
.and_then(|value| if value >= 0 { Some(value as u64) } else { None })
|
|
||||||
})
|
|
||||||
.or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(1).min(365);
|
|
||||||
result_obj.insert("autoDeleteAfterDays".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(Value::Number(n)) = obj.get("fontSize") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(50).min(200);
|
|
||||||
result_obj.insert("fontSize".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(n)) = obj.get("padding") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(50).min(200);
|
|
||||||
result_obj.insert("padding".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(n)) = obj.get("cornerRadius") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(0).min(32);
|
|
||||||
result_obj.insert("cornerRadius".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(n)) = obj.get("inputBarOffset") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(0).min(100);
|
|
||||||
result_obj.insert("inputBarOffset".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(n)) = obj.get("usageRefreshIntervalMs") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(30000).min(300000);
|
|
||||||
result_obj.insert("usageRefreshIntervalMs".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory limit fields
|
|
||||||
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| {
|
|
||||||
n.as_i64()
|
|
||||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
|
||||||
})
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(10).min(500);
|
|
||||||
result_obj.insert("memoryLimitHistorical".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| {
|
|
||||||
n.as_i64()
|
|
||||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
|
||||||
})
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(20).min(500);
|
|
||||||
result_obj.insert("memoryLimitViewport".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
|
|
||||||
let parsed = n
|
|
||||||
.as_u64()
|
|
||||||
.or_else(|| {
|
|
||||||
n.as_i64()
|
|
||||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
|
||||||
})
|
|
||||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
|
||||||
if let Some(value) = parsed {
|
|
||||||
let clamped = value.max(30).min(1000);
|
|
||||||
result_obj.insert("memoryLimitActiveSession".to_string(), json!(clamped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(Value::String(s)) = obj.get("diffLayoutPreference") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed == "dynamic" || trimmed == "inline" || trimmed == "side-by-side" {
|
|
||||||
result_obj.insert("diffLayoutPreference".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = obj.get("diffViewMode") {
|
|
||||||
let trimmed = s.trim();
|
|
||||||
if trimmed == "single" || trimmed == "stacked" {
|
|
||||||
result_obj.insert("diffViewMode".to_string(), json!(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("directoryShowHidden") {
|
|
||||||
result_obj.insert("directoryShowHidden".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("filesViewShowGitignored") {
|
|
||||||
result_obj.insert("filesViewShowGitignored".to_string(), json!(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array fields
|
|
||||||
if let Some(arr) = obj.get("approvedDirectories") {
|
|
||||||
result_obj.insert(
|
|
||||||
"approvedDirectories".to_string(),
|
|
||||||
normalize_string_array(arr),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(arr) = obj.get("securityScopedBookmarks") {
|
|
||||||
result_obj.insert(
|
|
||||||
"securityScopedBookmarks".to_string(),
|
|
||||||
normalize_string_array(arr),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(arr) = obj.get("pinnedDirectories") {
|
|
||||||
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typography sizes object (partial)
|
|
||||||
if let Some(typo) = obj.get("typographySizes") {
|
|
||||||
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
|
|
||||||
result_obj.insert("typographySizes".to_string(), sanitized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill catalogs (array of objects)
|
|
||||||
if let Some(Value::Array(arr)) = obj.get("skillCatalogs") {
|
|
||||||
let mut seen: HashSet<String> = HashSet::new();
|
|
||||||
let mut catalogs: Vec<Value> = vec![];
|
|
||||||
|
|
||||||
for entry in arr {
|
|
||||||
let Some(obj) = entry.as_object() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
|
||||||
let label = obj
|
|
||||||
.get("label")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let source = obj
|
|
||||||
.get("source")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let subpath = obj
|
|
||||||
.get("subpath")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let git_identity_id = obj
|
|
||||||
.get("gitIdentityId")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if id.is_empty() || label.is_empty() || source.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if seen.contains(id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.insert(id.to_string());
|
|
||||||
|
|
||||||
let mut catalog = serde_json::Map::new();
|
|
||||||
catalog.insert("id".to_string(), json!(id));
|
|
||||||
catalog.insert("label".to_string(), json!(label));
|
|
||||||
catalog.insert("source".to_string(), json!(source));
|
|
||||||
if !subpath.is_empty() {
|
|
||||||
catalog.insert("subpath".to_string(), json!(subpath));
|
|
||||||
}
|
|
||||||
if !git_identity_id.is_empty() {
|
|
||||||
catalog.insert("gitIdentityId".to_string(), json!(git_identity_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogs.push(Value::Object(catalog));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !catalogs.is_empty() {
|
|
||||||
result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn migrate_legacy_project_settings(settings: &mut Value) {
|
|
||||||
if !settings.is_object() {
|
|
||||||
*settings = json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now().timestamp_millis();
|
|
||||||
let obj = settings.as_object_mut().unwrap();
|
|
||||||
|
|
||||||
let has_projects = obj
|
|
||||||
.get("projects")
|
|
||||||
.and_then(|value| value.as_array())
|
|
||||||
.map(|arr| !arr.is_empty())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if has_projects {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let last_directory = obj
|
|
||||||
.get("lastDirectory")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(expand_tilde_path);
|
|
||||||
|
|
||||||
let Some(mut last_directory) = last_directory else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) {
|
|
||||||
last_directory = canonicalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(stats) = std::fs::metadata(&last_directory) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !stats.is_dir() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized_path = last_directory.to_string_lossy().to_string();
|
|
||||||
if normalized_path.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let project_id = Uuid::new_v4().to_string();
|
|
||||||
let active_project_id = project_id.clone();
|
|
||||||
let project_path = normalized_path.clone();
|
|
||||||
|
|
||||||
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
|
||||||
*projects_value = json!([
|
|
||||||
{
|
|
||||||
"id": project_id,
|
|
||||||
"path": project_path,
|
|
||||||
"addedAt": now,
|
|
||||||
"lastOpenedAt": now
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
obj.insert("activeProjectId".to_string(), json!(active_project_id));
|
|
||||||
|
|
||||||
// Ensure approvedDirectories includes the migrated project root.
|
|
||||||
let approved_value = obj
|
|
||||||
.entry("approvedDirectories")
|
|
||||||
.or_insert_with(|| json!([]));
|
|
||||||
if !approved_value.is_array() {
|
|
||||||
*approved_value = json!([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(array) = approved_value.as_array_mut() {
|
|
||||||
array.push(json!(normalized_path.clone()));
|
|
||||||
array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty()));
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
array.retain(|entry| {
|
|
||||||
let Some(value) = entry.as_str() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if seen.contains(value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seen.insert(value.to_string());
|
|
||||||
true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn migrate_legacy_theme_settings(settings: &mut Value) {
|
|
||||||
if !settings.is_object() {
|
|
||||||
*settings = json!({});
|
|
||||||
}
|
|
||||||
|
|
||||||
let obj = settings.as_object_mut().unwrap();
|
|
||||||
|
|
||||||
let theme_id = obj
|
|
||||||
.get("themeId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(|value| value.to_string());
|
|
||||||
|
|
||||||
let theme_variant = obj
|
|
||||||
.get("themeVariant")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| *value == "light" || *value == "dark")
|
|
||||||
.map(|value| value.to_string());
|
|
||||||
|
|
||||||
let has_light = obj
|
|
||||||
.get("lightThemeId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.is_some_and(|value| !value.trim().is_empty());
|
|
||||||
let has_dark = obj
|
|
||||||
.get("darkThemeId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.is_some_and(|value| !value.trim().is_empty());
|
|
||||||
|
|
||||||
if has_light && has_dark {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let default_light = "flexoki-light".to_string();
|
|
||||||
let default_dark = "flexoki-dark".to_string();
|
|
||||||
|
|
||||||
if !has_light {
|
|
||||||
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
|
|
||||||
if variant == "light" {
|
|
||||||
id.clone()
|
|
||||||
} else {
|
|
||||||
default_light.clone()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
default_light.clone()
|
|
||||||
};
|
|
||||||
obj.insert("lightThemeId".to_string(), json!(next));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !has_dark {
|
|
||||||
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
|
|
||||||
if variant == "dark" {
|
|
||||||
id.clone()
|
|
||||||
} else {
|
|
||||||
default_dark.clone()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
default_dark.clone()
|
|
||||||
};
|
|
||||||
obj.insert("darkThemeId".to_string(), json!(next));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_project_selection(settings: &mut Value) {
|
|
||||||
let Some(obj) = settings.as_object_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if projects.is_empty() {
|
|
||||||
obj.remove("activeProjectId");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_active = obj
|
|
||||||
.get("activeProjectId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let has_active = projects.iter().any(|entry| {
|
|
||||||
entry
|
|
||||||
.get("id")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(|id| id == current_active)
|
|
||||||
.unwrap_or(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_active {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let first_id = projects
|
|
||||||
.first()
|
|
||||||
.and_then(|entry| entry.get("id"))
|
|
||||||
.and_then(|value| value.as_str());
|
|
||||||
|
|
||||||
if let Some(id) = first_id {
|
|
||||||
obj.insert("activeProjectId".to_string(), json!(id));
|
|
||||||
} else {
|
|
||||||
obj.remove("activeProjectId");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge persisted settings (port of Express mergePersistedSettings)
|
|
||||||
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
|
||||||
let mut result = current.clone();
|
|
||||||
|
|
||||||
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
|
|
||||||
// First apply all changes
|
|
||||||
for (key, value) in changes_obj {
|
|
||||||
result_obj.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build approvedDirectories from base + additional
|
|
||||||
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
|
|
||||||
extract_string_vec(arr)
|
|
||||||
} else if let Some(arr) = current.get("approvedDirectories") {
|
|
||||||
extract_string_vec(arr)
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut additional_approved = vec![];
|
|
||||||
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
additional_approved.push(s.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
|
|
||||||
if !s.is_empty() {
|
|
||||||
additional_approved.push(s.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") {
|
|
||||||
Some(arr)
|
|
||||||
} else {
|
|
||||||
current.get("projects").and_then(|v| v.as_array())
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(entries) = project_source {
|
|
||||||
for entry in entries {
|
|
||||||
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
|
||||||
if !path.trim().is_empty() {
|
|
||||||
additional_approved.push(path.trim().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
|
||||||
for item in additional_approved {
|
|
||||||
approved_set.insert(item);
|
|
||||||
}
|
|
||||||
let approved_vec: Vec<String> = approved_set.into_iter().collect();
|
|
||||||
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
|
|
||||||
|
|
||||||
// Security scoped bookmarks
|
|
||||||
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
|
|
||||||
extract_string_vec(arr)
|
|
||||||
} else if let Some(arr) = current.get("securityScopedBookmarks") {
|
|
||||||
extract_string_vec(arr)
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
|
|
||||||
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
|
|
||||||
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
|
|
||||||
|
|
||||||
// Merge typography sizes if present
|
|
||||||
if changes_obj.contains_key("typographySizes") {
|
|
||||||
let current_typo = current
|
|
||||||
.get("typographySizes")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let changes_typo = changes_obj
|
|
||||||
.get("typographySizes")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut merged_typo = current_typo;
|
|
||||||
for (key, value) in changes_typo {
|
|
||||||
merged_typo.insert(key, value);
|
|
||||||
}
|
|
||||||
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format settings response (port of Express formatSettingsResponse)
|
|
||||||
fn format_settings_response(settings: &Value) -> Value {
|
|
||||||
let mut result = sanitize_settings_update(settings);
|
|
||||||
|
|
||||||
if let Some(obj) = result.as_object_mut() {
|
|
||||||
// Ensure array fields are normalized
|
|
||||||
obj.insert(
|
|
||||||
"approvedDirectories".to_string(),
|
|
||||||
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
|
|
||||||
);
|
|
||||||
obj.insert(
|
|
||||||
"securityScopedBookmarks".to_string(),
|
|
||||||
normalize_string_array(
|
|
||||||
settings
|
|
||||||
.get("securityScopedBookmarks")
|
|
||||||
.unwrap_or(&json!([])),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
obj.insert(
|
|
||||||
"pinnedDirectories".to_string(),
|
|
||||||
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Typography sizes
|
|
||||||
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
|
|
||||||
settings.get("typographySizes").unwrap_or(&json!(null)),
|
|
||||||
) {
|
|
||||||
obj.insert("typographySizes".to_string(), sanitized_typo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// showReasoningTraces with fallback
|
|
||||||
let show_reasoning = settings
|
|
||||||
.get("showReasoningTraces")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.or_else(|| {
|
|
||||||
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
|
|
||||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
|
||||||
Some(*b)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize string array helper
|
|
||||||
fn normalize_string_array(input: &Value) -> Value {
|
|
||||||
if let Some(arr) = input.as_array() {
|
|
||||||
let strings: Vec<String> = arr
|
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
let unique: HashSet<String> = strings.into_iter().collect();
|
|
||||||
json!(unique.into_iter().collect::<Vec<_>>())
|
|
||||||
} else {
|
|
||||||
json!([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize typography sizes partial helper
|
|
||||||
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
|
|
||||||
if let Some(obj) = input.as_object() {
|
|
||||||
let mut result = serde_json::Map::new();
|
|
||||||
let mut populated = false;
|
|
||||||
|
|
||||||
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
|
|
||||||
if let Some(Value::String(s)) = obj.get(*key) {
|
|
||||||
if !s.is_empty() {
|
|
||||||
result.insert(key.to_string(), json!(s));
|
|
||||||
populated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if populated {
|
|
||||||
Some(json!(result))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract string vector from JSON value
|
|
||||||
fn extract_string_vec(value: &Value) -> Vec<String> {
|
|
||||||
if let Some(arr) = value.as_array() {
|
|
||||||
arr.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
use log::error;
|
|
||||||
use parking_lot::Mutex;
|
|
||||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
env,
|
|
||||||
io::{Read, Write},
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::Arc,
|
|
||||||
thread,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
use tauri::{Emitter, State, Window};
|
|
||||||
|
|
||||||
const DEFAULT_SHELL: &str = "/bin/zsh";
|
|
||||||
const DEFAULT_TERM: &str = "xterm-256color";
|
|
||||||
const DEFAULT_COLORTERM: &str = "truecolor";
|
|
||||||
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
|
|
||||||
const TERM_PROGRAM_NAME: &str = "OpenChamber";
|
|
||||||
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
||||||
|
|
||||||
// Emit at most ~60fps and avoid tiny payload spam.
|
|
||||||
const EMIT_INTERVAL: Duration = Duration::from_millis(16);
|
|
||||||
const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024;
|
|
||||||
|
|
||||||
pub struct TerminalSession {
|
|
||||||
pub master: Box<dyn MasterPty + Send>,
|
|
||||||
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
|
||||||
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TerminalState {
|
|
||||||
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TerminalState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CreateTerminalPayload {
|
|
||||||
pub cols: u16,
|
|
||||||
pub rows: u16,
|
|
||||||
pub cwd: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct CreateTerminalResponse {
|
|
||||||
pub session_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn create_terminal_session(
|
|
||||||
payload: CreateTerminalPayload,
|
|
||||||
state: State<'_, TerminalState>,
|
|
||||||
window: Window,
|
|
||||||
) -> Result<CreateTerminalResponse, String> {
|
|
||||||
let pty_system = NativePtySystem::default();
|
|
||||||
let size = PtySize {
|
|
||||||
rows: payload.rows,
|
|
||||||
cols: payload.cols,
|
|
||||||
pixel_width: 0,
|
|
||||||
pixel_height: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
|
|
||||||
let shell_path = resolve_shell();
|
|
||||||
|
|
||||||
let mut cmd = CommandBuilder::new(&shell_path);
|
|
||||||
if shell_accepts_login_flag(&shell_path) {
|
|
||||||
cmd.arg("-l");
|
|
||||||
}
|
|
||||||
if let Some(cwd) = working_dir.to_str() {
|
|
||||||
cmd.cwd(cwd);
|
|
||||||
}
|
|
||||||
apply_terminal_environment(&mut cmd, &shell_path);
|
|
||||||
|
|
||||||
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
|
|
||||||
let child = pair
|
|
||||||
.slave
|
|
||||||
.spawn_command(cmd)
|
|
||||||
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
|
|
||||||
drop(pair.slave);
|
|
||||||
|
|
||||||
let reader = pair
|
|
||||||
.master
|
|
||||||
.try_clone_reader()
|
|
||||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
|
||||||
let writer = Arc::new(Mutex::new(
|
|
||||||
pair.master
|
|
||||||
.take_writer()
|
|
||||||
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
|
|
||||||
));
|
|
||||||
let master = pair.master;
|
|
||||||
let child = Arc::new(Mutex::new(child));
|
|
||||||
|
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
state.sessions.lock().insert(
|
|
||||||
session_id.clone(),
|
|
||||||
TerminalSession {
|
|
||||||
master,
|
|
||||||
writer: writer.clone(),
|
|
||||||
child: child.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
spawn_reader_thread(reader, window.clone(), session_id.clone());
|
|
||||||
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
|
|
||||||
|
|
||||||
Ok(CreateTerminalResponse { session_id })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn send_terminal_input(
|
|
||||||
session_id: String,
|
|
||||||
data: String,
|
|
||||||
state: State<'_, TerminalState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let writer = {
|
|
||||||
let sessions = state.sessions.lock();
|
|
||||||
let Some(session) = sessions.get(&session_id) else {
|
|
||||||
return Err("Terminal session not found".to_string());
|
|
||||||
};
|
|
||||||
session.writer.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut guard = writer.lock();
|
|
||||||
guard
|
|
||||||
.write_all(data.as_bytes())
|
|
||||||
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn resize_terminal(
|
|
||||||
session_id: String,
|
|
||||||
cols: u16,
|
|
||||||
rows: u16,
|
|
||||||
state: State<'_, TerminalState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut sessions = state.sessions.lock();
|
|
||||||
let Some(session) = sessions.get_mut(&session_id) else {
|
|
||||||
return Err("Terminal session not found".to_string());
|
|
||||||
};
|
|
||||||
|
|
||||||
session
|
|
||||||
.master
|
|
||||||
.resize(PtySize {
|
|
||||||
rows,
|
|
||||||
cols,
|
|
||||||
pixel_width: 0,
|
|
||||||
pixel_height: 0,
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn close_terminal(
|
|
||||||
session_id: String,
|
|
||||||
state: State<'_, TerminalState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let session = { state.sessions.lock().remove(&session_id) };
|
|
||||||
|
|
||||||
if let Some(session) = session {
|
|
||||||
let _ = session.child.lock().kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct RestartTerminalPayload {
|
|
||||||
pub session_id: String,
|
|
||||||
pub cols: u16,
|
|
||||||
pub rows: u16,
|
|
||||||
pub cwd: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn restart_terminal_session(
|
|
||||||
payload: RestartTerminalPayload,
|
|
||||||
state: State<'_, TerminalState>,
|
|
||||||
window: Window,
|
|
||||||
) -> Result<CreateTerminalResponse, String> {
|
|
||||||
{
|
|
||||||
let session = state.sessions.lock().remove(&payload.session_id);
|
|
||||||
if let Some(session) = session {
|
|
||||||
let _ = session.child.lock().kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pty_system = NativePtySystem::default();
|
|
||||||
let size = PtySize {
|
|
||||||
rows: payload.rows,
|
|
||||||
cols: payload.cols,
|
|
||||||
pixel_width: 0,
|
|
||||||
pixel_height: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let working_dir = resolve_working_directory(Some(&payload.cwd))?;
|
|
||||||
let shell_path = resolve_shell();
|
|
||||||
|
|
||||||
let mut cmd = CommandBuilder::new(&shell_path);
|
|
||||||
if shell_accepts_login_flag(&shell_path) {
|
|
||||||
cmd.arg("-l");
|
|
||||||
}
|
|
||||||
if let Some(cwd) = working_dir.to_str() {
|
|
||||||
cmd.cwd(cwd);
|
|
||||||
}
|
|
||||||
apply_terminal_environment(&mut cmd, &shell_path);
|
|
||||||
|
|
||||||
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
|
|
||||||
let child = pair
|
|
||||||
.slave
|
|
||||||
.spawn_command(cmd)
|
|
||||||
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
|
|
||||||
drop(pair.slave);
|
|
||||||
|
|
||||||
let reader = pair
|
|
||||||
.master
|
|
||||||
.try_clone_reader()
|
|
||||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
|
||||||
let writer = Arc::new(Mutex::new(
|
|
||||||
pair.master
|
|
||||||
.take_writer()
|
|
||||||
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
|
|
||||||
));
|
|
||||||
let master = pair.master;
|
|
||||||
let child = Arc::new(Mutex::new(child));
|
|
||||||
|
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
state.sessions.lock().insert(
|
|
||||||
session_id.clone(),
|
|
||||||
TerminalSession {
|
|
||||||
master,
|
|
||||||
writer: writer.clone(),
|
|
||||||
child: child.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
spawn_reader_thread(reader, window.clone(), session_id.clone());
|
|
||||||
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
|
|
||||||
|
|
||||||
Ok(CreateTerminalResponse { session_id })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ForceKillPayload {
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub cwd: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn force_kill_terminal(
|
|
||||||
payload: ForceKillPayload,
|
|
||||||
state: State<'_, TerminalState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut sessions = state.sessions.lock();
|
|
||||||
|
|
||||||
if let Some(session_id) = payload.session_id {
|
|
||||||
if let Some(session) = sessions.remove(&session_id) {
|
|
||||||
let _ = session.child.lock().kill();
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current API ignores cwd; keep behavior but avoid holding poisoned locks.
|
|
||||||
let _ = payload.cwd;
|
|
||||||
|
|
||||||
let ids: Vec<String> = sessions.keys().cloned().collect();
|
|
||||||
for id in ids {
|
|
||||||
if let Some(session) = sessions.remove(&id) {
|
|
||||||
let _ = session.child.lock().kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_reader_thread(reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
|
||||||
thread::spawn(move || {
|
|
||||||
use std::sync::mpsc;
|
|
||||||
|
|
||||||
let event_name = format!("terminal://{}", session_id);
|
|
||||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
|
||||||
|
|
||||||
// Dedicated blocking reader thread.
|
|
||||||
let reader_handle = thread::spawn(move || {
|
|
||||||
let mut reader = reader;
|
|
||||||
let mut buffer = [0u8; 16384];
|
|
||||||
loop {
|
|
||||||
match reader.read(&mut buffer) {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
if tx.send(buffer[..n].to_vec()).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut pending = String::new();
|
|
||||||
let mut pending_bytes: Vec<u8> = Vec::new();
|
|
||||||
|
|
||||||
let flush = |pending: &mut String| -> bool {
|
|
||||||
if pending.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload_data = std::mem::take(pending);
|
|
||||||
let payload = serde_json::json!({ "type": "data", "data": payload_data });
|
|
||||||
|
|
||||||
match window.emit(&event_name, payload) {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(error) => {
|
|
||||||
error!("Failed to emit terminal data: {error}");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let decode_pending = |pending_bytes: &mut Vec<u8>, pending: &mut String| {
|
|
||||||
loop {
|
|
||||||
match std::str::from_utf8(pending_bytes) {
|
|
||||||
Ok(text) => {
|
|
||||||
if !text.is_empty() {
|
|
||||||
pending.push_str(text);
|
|
||||||
}
|
|
||||||
pending_bytes.clear();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let valid = error.valid_up_to();
|
|
||||||
if valid > 0 {
|
|
||||||
let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or("");
|
|
||||||
if !text.is_empty() {
|
|
||||||
pending.push_str(text);
|
|
||||||
}
|
|
||||||
pending_bytes.drain(..valid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incomplete UTF-8 at end; wait for more bytes.
|
|
||||||
if error.error_len().is_none() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid leading byte; consume 1 byte and replace.
|
|
||||||
if !pending_bytes.is_empty() {
|
|
||||||
pending_bytes.drain(..1);
|
|
||||||
pending.push('\u{FFFD}');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match rx.recv_timeout(EMIT_INTERVAL) {
|
|
||||||
Ok(bytes) => {
|
|
||||||
pending_bytes.extend_from_slice(&bytes);
|
|
||||||
decode_pending(&mut pending_bytes, &mut pending);
|
|
||||||
|
|
||||||
if pending.len() >= EMIT_MAX_BUFFER_BYTES {
|
|
||||||
if !flush(&mut pending) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
||||||
// Flush any buffered output even if the PTY is idle.
|
|
||||||
if !pending_bytes.is_empty() {
|
|
||||||
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
|
|
||||||
pending_bytes.clear();
|
|
||||||
}
|
|
||||||
if !flush(&mut pending) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
|
||||||
if !pending_bytes.is_empty() {
|
|
||||||
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
|
|
||||||
pending_bytes.clear();
|
|
||||||
}
|
|
||||||
let _ = flush(&mut pending);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = reader_handle.join();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_exit_watcher(
|
|
||||||
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
|
||||||
window: Window,
|
|
||||||
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
|
||||||
session_id: String,
|
|
||||||
) {
|
|
||||||
thread::spawn(move || {
|
|
||||||
let status = { child.lock().wait() };
|
|
||||||
|
|
||||||
let (exit_code, signal) = match status {
|
|
||||||
Ok(status) => (
|
|
||||||
status.exit_code() as i32,
|
|
||||||
status.signal().map(|sig| sig.to_string()),
|
|
||||||
),
|
|
||||||
Err(err) => {
|
|
||||||
error!("Failed to wait for terminal exit: {err}");
|
|
||||||
(1, Some("Terminal crashed".to_string()))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let event_name = format!("terminal://{}", session_id);
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"type": "exit",
|
|
||||||
"exitCode": exit_code,
|
|
||||||
"signal": signal
|
|
||||||
});
|
|
||||||
let _ = window.emit(&event_name, payload);
|
|
||||||
|
|
||||||
sessions.lock().remove(&session_id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_shell() -> String {
|
|
||||||
env::var("SHELL")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shell_accepts_login_flag(shell_path: &str) -> bool {
|
|
||||||
let shell_name = Path::new(shell_path)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or(shell_path)
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
matches!(
|
|
||||||
shell_name.as_str(),
|
|
||||||
name if name.contains("zsh")
|
|
||||||
|| name.contains("bash")
|
|
||||||
|| name.contains("sh")
|
|
||||||
|| name.contains("fish")
|
|
||||||
|| name.contains("ksh")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
|
|
||||||
let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir());
|
|
||||||
|
|
||||||
let Some(path) = maybe_path else {
|
|
||||||
return Err("Unable to determine working directory".to_string());
|
|
||||||
};
|
|
||||||
|
|
||||||
if !path.exists() || !path.is_dir() {
|
|
||||||
return Err(format!(
|
|
||||||
"Working directory is not accessible: {}",
|
|
||||||
path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
|
|
||||||
cmd.env(
|
|
||||||
"TERM",
|
|
||||||
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
|
|
||||||
);
|
|
||||||
cmd.env(
|
|
||||||
"COLORTERM",
|
|
||||||
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
|
|
||||||
);
|
|
||||||
cmd.env(
|
|
||||||
"LC_ALL",
|
|
||||||
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
|
||||||
);
|
|
||||||
cmd.env(
|
|
||||||
"LANG",
|
|
||||||
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
|
||||||
);
|
|
||||||
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
|
|
||||||
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
|
|
||||||
cmd.env("OPENCHAMBER_DESKTOP", "1");
|
|
||||||
cmd.env("SHELL", shell_path);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
const PLATFORM_LOG_SEGMENTS: &[&str] = &["Library", "Logs", "OpenChamber"];
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
const PLATFORM_LOG_SEGMENTS: &[&str] = &[".config", "openchamber", "logs"];
|
|
||||||
|
|
||||||
pub fn log_directory() -> Option<PathBuf> {
|
|
||||||
let mut path = dirs::home_dir()?;
|
|
||||||
for segment in PLATFORM_LOG_SEGMENTS {
|
|
||||||
path.push(segment);
|
|
||||||
}
|
|
||||||
Some(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn log_file_path() -> Option<PathBuf> {
|
|
||||||
let mut dir = log_directory()?;
|
|
||||||
dir.push("openchamber.log");
|
|
||||||
Some(dir)
|
|
||||||
}
|
|
||||||
+1094
-2684
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use log::info;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
/// Get OpenCode data directory path (~/.local/share/opencode)
|
|
||||||
fn get_data_dir() -> PathBuf {
|
|
||||||
dirs::home_dir()
|
|
||||||
.expect("Cannot determine home directory")
|
|
||||||
.join(".local")
|
|
||||||
.join("share")
|
|
||||||
.join("opencode")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get auth file path
|
|
||||||
fn get_auth_file() -> PathBuf {
|
|
||||||
get_data_dir().join("auth.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure data directory exists
|
|
||||||
async fn ensure_data_dir() -> Result<()> {
|
|
||||||
let data_dir = get_data_dir();
|
|
||||||
fs::create_dir_all(&data_dir).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read auth.json file
|
|
||||||
pub async fn read_auth() -> Result<Value> {
|
|
||||||
let auth_file = get_auth_file();
|
|
||||||
|
|
||||||
if !auth_file.exists() {
|
|
||||||
return Ok(Value::Object(serde_json::Map::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(&auth_file).await?;
|
|
||||||
let trimmed = content.trim();
|
|
||||||
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Ok(Value::Object(serde_json::Map::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::from_str(trimmed).map_err(|e| anyhow!("Failed to parse auth file: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write auth.json file with backup
|
|
||||||
pub async fn write_auth(auth: &Value) -> Result<()> {
|
|
||||||
ensure_data_dir().await?;
|
|
||||||
|
|
||||||
let auth_file = get_auth_file();
|
|
||||||
|
|
||||||
// Create backup before writing
|
|
||||||
if auth_file.exists() {
|
|
||||||
let file_name = auth_file
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.ok_or_else(|| anyhow!("Invalid auth file name"))?;
|
|
||||||
|
|
||||||
let backup_path = auth_file.with_file_name(format!("{file_name}.openchamber.backup"));
|
|
||||||
fs::copy(&auth_file, &backup_path).await?;
|
|
||||||
info!("Created auth backup: {}", backup_path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
let json_string = serde_json::to_string_pretty(auth)?;
|
|
||||||
fs::write(&auth_file, json_string).await?;
|
|
||||||
info!("Successfully wrote auth file");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get provider auth entry from auth.json
|
|
||||||
pub async fn get_provider_auth(provider_id: &str) -> Result<Option<Value>> {
|
|
||||||
if provider_id.is_empty() {
|
|
||||||
return Err(anyhow!("Provider ID is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let auth = read_auth().await?;
|
|
||||||
let auth_obj = auth
|
|
||||||
.as_object()
|
|
||||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
|
|
||||||
Ok(auth_obj.get(provider_id).cloned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove provider auth entry from auth.json
|
|
||||||
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
|
|
||||||
if provider_id.is_empty() {
|
|
||||||
return Err(anyhow!("Provider ID is required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut auth = read_auth().await?;
|
|
||||||
|
|
||||||
let auth_obj = auth
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
|
|
||||||
|
|
||||||
if !auth_obj.contains_key(provider_id) {
|
|
||||||
info!(
|
|
||||||
"Provider {} not found in auth file, nothing to remove",
|
|
||||||
provider_id
|
|
||||||
);
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
auth_obj.remove(provider_id);
|
|
||||||
write_auth(&auth).await?;
|
|
||||||
info!("Removed provider auth: {}", provider_id);
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,751 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use parking_lot::RwLock;
|
|
||||||
use regex::Regex;
|
|
||||||
use reqwest::Client;
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
io::{AsyncBufReadExt, BufReader},
|
|
||||||
process::{Child, Command},
|
|
||||||
sync::Mutex,
|
|
||||||
time::timeout,
|
|
||||||
};
|
|
||||||
|
|
||||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
|
||||||
Regex::new(r#"https?://[^:\s]+:(?P<port>\d+)(?P<path>/[^\s"']*)?"#).expect("valid regex")
|
|
||||||
});
|
|
||||||
|
|
||||||
const FIRST_SIGNAL_TIMEOUT_MS: u64 = 750;
|
|
||||||
const READY_CHECK_TIMEOUT_MS: u64 = 20000;
|
|
||||||
const READY_CHECK_INTERVAL_MS: u64 = 400;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct OpenCodeManager {
|
|
||||||
binary: Option<String>,
|
|
||||||
args: Vec<String>,
|
|
||||||
env: HashMap<String, String>,
|
|
||||||
working_dir: Arc<RwLock<PathBuf>>,
|
|
||||||
desired_port: u16,
|
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
|
||||||
port: Arc<RwLock<Option<u16>>>,
|
|
||||||
api_prefix: Arc<RwLock<String>>,
|
|
||||||
is_ready: Arc<AtomicBool>,
|
|
||||||
shutting_down: Arc<AtomicBool>,
|
|
||||||
http_client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_api_prefix(prefix: &str) -> String {
|
|
||||||
let trimmed = prefix.trim();
|
|
||||||
if trimmed.is_empty() || trimmed == "/" {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut normalized = trimmed.trim_end_matches('/').to_string();
|
|
||||||
if !normalized.starts_with('/') {
|
|
||||||
normalized.insert(0, '/');
|
|
||||||
}
|
|
||||||
normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenCodeManager {
|
|
||||||
pub fn new_with_directory(_initial_dir: Option<PathBuf>) -> Self {
|
|
||||||
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|raw| raw.parse::<u16>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let binary = resolve_opencode_binary();
|
|
||||||
|
|
||||||
if let Some(ref bin) = binary {
|
|
||||||
if !Path::new(bin).is_absolute() {
|
|
||||||
info!("[desktop:opencode] using PATH-resolved binary: {}", bin);
|
|
||||||
} else {
|
|
||||||
info!("[desktop:opencode] using binary: {}", bin);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
warn!("[desktop:opencode] OpenCode CLI not found - app will run in limited mode");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut args = vec![
|
|
||||||
"serve".to_string(),
|
|
||||||
"--port".to_string(),
|
|
||||||
desired_port.to_string(),
|
|
||||||
];
|
|
||||||
if let Ok(config) = std::env::var("OPENCHAMBER_OPENCODE_CONFIG") {
|
|
||||||
if !config.is_empty() {
|
|
||||||
args.push("--config".to_string());
|
|
||||||
args.push(config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let env = build_augmented_env();
|
|
||||||
let working_dir = dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"[desktop:opencode] Initial working directory: {:?}",
|
|
||||||
working_dir
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
binary,
|
|
||||||
args,
|
|
||||||
env,
|
|
||||||
working_dir: Arc::new(RwLock::new(working_dir)),
|
|
||||||
desired_port,
|
|
||||||
child: Arc::new(Mutex::new(None)),
|
|
||||||
port: Arc::new(RwLock::new(None)),
|
|
||||||
api_prefix: Arc::new(RwLock::new(String::new())),
|
|
||||||
is_ready: Arc::new(AtomicBool::new(false)),
|
|
||||||
shutting_down: Arc::new(AtomicBool::new(false)),
|
|
||||||
http_client: Client::builder()
|
|
||||||
.timeout(Duration::from_secs(2))
|
|
||||||
.build()
|
|
||||||
.unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_cli_available(&self) -> bool {
|
|
||||||
self.binary.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_running(&self) -> Result<()> {
|
|
||||||
if self.binary.is_none() {
|
|
||||||
return Err(anyhow!("OpenCode CLI is not available"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut guard = self.child.lock().await;
|
|
||||||
if let Some(child) = guard.as_mut() {
|
|
||||||
if child.try_wait()?.is_none() && self.is_ready.load(Ordering::SeqCst) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.is_ready.store(false, Ordering::SeqCst);
|
|
||||||
let child = self.spawn_process().await?;
|
|
||||||
*guard = Some(child);
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
// Wait for port detection from logs
|
|
||||||
if self.desired_port == 0 {
|
|
||||||
self.wait_for_port_detection().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect API prefix early so proxy can forward correctly
|
|
||||||
let _ = self.detect_api_prefix().await;
|
|
||||||
|
|
||||||
// Wait for OpenCode to become ready by polling endpoints
|
|
||||||
self.wait_for_ready().await?;
|
|
||||||
|
|
||||||
self.is_ready.store(true, Ordering::SeqCst);
|
|
||||||
if let Some(port) = self.current_port() {
|
|
||||||
info!("[desktop:opencode] ready on port {port}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn restart(&self) -> Result<()> {
|
|
||||||
info!("[desktop:opencode] restarting...");
|
|
||||||
self.is_ready.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
self.graceful_stop().await?;
|
|
||||||
|
|
||||||
// Brief delay to let OS release resources
|
|
||||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
||||||
|
|
||||||
// Reset state
|
|
||||||
if self.desired_port == 0 {
|
|
||||||
*self.port.write() = None;
|
|
||||||
}
|
|
||||||
*self.api_prefix.write() = String::new();
|
|
||||||
|
|
||||||
self.ensure_running().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
|
||||||
self.shutting_down.store(true, Ordering::SeqCst);
|
|
||||||
self.is_ready.store(false, Ordering::SeqCst);
|
|
||||||
self.graceful_stop().await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
|
||||||
*self.working_dir.write() = new_dir;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_working_directory(&self) -> PathBuf {
|
|
||||||
self.working_dir.read().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn detect_api_prefix(&self) -> Result<()> {
|
|
||||||
let Some(port) = self.current_port() else {
|
|
||||||
return Err(anyhow!("Cannot detect API prefix without port"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try no prefix first, then /api (compatibility).
|
|
||||||
let candidates = ["", "/api"];
|
|
||||||
for candidate in candidates {
|
|
||||||
let base = if candidate.is_empty() {
|
|
||||||
format!("http://127.0.0.1:{port}")
|
|
||||||
} else {
|
|
||||||
format!("http://127.0.0.1:{port}{candidate}")
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{base}/config");
|
|
||||||
match self.http_client.get(&url).send().await {
|
|
||||||
Ok(resp) if resp.status().is_success() => {
|
|
||||||
// Validate it's actually JSON config, not HTML
|
|
||||||
if let Ok(text) = resp.text().await {
|
|
||||||
if text.trim().starts_with('{') || text.trim().starts_with('[') {
|
|
||||||
info!("[desktop:opencode] Detected API prefix: {:?}", candidate);
|
|
||||||
*self.api_prefix.write() = normalize_api_prefix(candidate);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("[desktop:opencode] No API prefix detected, using empty prefix");
|
|
||||||
*self.api_prefix.write() = String::new();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_port(&self) -> Option<u16> {
|
|
||||||
*self.port.read()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn api_prefix(&self) -> String {
|
|
||||||
self.api_prefix.read().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_ready(&self) -> bool {
|
|
||||||
self.is_ready.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_shutting_down(&self) -> bool {
|
|
||||||
self.shutting_down.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_child_running(&self) -> Result<bool> {
|
|
||||||
let mut guard = self.child.lock().await;
|
|
||||||
if let Some(child) = guard.as_mut() {
|
|
||||||
match child.try_wait()? {
|
|
||||||
None => return Ok(true),
|
|
||||||
Some(_status) => {
|
|
||||||
*guard = None;
|
|
||||||
self.is_ready.store(false, Ordering::SeqCst);
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rewrite_path(&self, incoming_path: &str) -> String {
|
|
||||||
// Strip /api prefix to get OpenCode path
|
|
||||||
let result = incoming_path
|
|
||||||
.strip_prefix("/api")
|
|
||||||
.map(|rest| if rest.is_empty() { "/" } else { rest })
|
|
||||||
.unwrap_or(incoming_path)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"[opencode_manager] rewrite_path: '{}' -> '{}'",
|
|
||||||
incoming_path, result
|
|
||||||
);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_process(&self) -> Result<Child> {
|
|
||||||
let binary = self
|
|
||||||
.binary
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?;
|
|
||||||
|
|
||||||
info!("[desktop:opencode] launching {} {:?}", binary, self.args);
|
|
||||||
|
|
||||||
let working_dir = self.working_dir.read().clone();
|
|
||||||
let mut cmd = Command::new(binary);
|
|
||||||
cmd.args(&self.args)
|
|
||||||
.current_dir(&working_dir)
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped())
|
|
||||||
.kill_on_drop(false);
|
|
||||||
|
|
||||||
for (key, value) in &self.env {
|
|
||||||
cmd.env(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut child = cmd.spawn().map_err(|e| {
|
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
|
||||||
anyhow!(
|
|
||||||
"OpenCode binary '{}' not found. Set OPENCODE_BINARY or ensure it's in PATH.",
|
|
||||||
binary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
anyhow!("Failed to spawn OpenCode: {}", e)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Set port immediately if pre-configured
|
|
||||||
if self.desired_port > 0 {
|
|
||||||
*self.port.write() = Some(self.desired_port);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for first signal (stdout/stderr) within 750ms to confirm startup
|
|
||||||
let first_signal_received = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
if let Some(stdout) = child.stdout.take() {
|
|
||||||
let signal_flag = first_signal_received.clone();
|
|
||||||
self.spawn_output_reader(stdout, "stdout", move || {
|
|
||||||
signal_flag.store(true, Ordering::SeqCst);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(stderr) = child.stderr.take() {
|
|
||||||
let signal_flag = first_signal_received.clone();
|
|
||||||
self.spawn_output_reader(stderr, "stderr", move || {
|
|
||||||
signal_flag.store(true, Ordering::SeqCst);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for first signal or timeout
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
while start.elapsed() < Duration::from_millis(FIRST_SIGNAL_TIMEOUT_MS) {
|
|
||||||
if first_signal_received.load(Ordering::SeqCst) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Ok(Some(_)) = child.try_wait() {
|
|
||||||
return Err(anyhow!("OpenCode process exited immediately after spawn"));
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(child)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_output_reader<F>(
|
|
||||||
&self,
|
|
||||||
stream: impl tokio::io::AsyncRead + Unpin + Send + 'static,
|
|
||||||
label: &'static str,
|
|
||||||
on_first_line: F,
|
|
||||||
) where
|
|
||||||
F: FnOnce() + Send + 'static,
|
|
||||||
{
|
|
||||||
let manager = self.clone();
|
|
||||||
let first_line_flag = Arc::new(Mutex::new(Some(on_first_line)));
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let reader = BufReader::new(stream);
|
|
||||||
let mut lines = reader.lines();
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
|
||||||
// Trigger first signal callback
|
|
||||||
if let Some(callback) = first_line_flag.lock().await.take() {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("[opencode:{label}] {line}");
|
|
||||||
manager.ingest_output_line(&line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ingest_output_line(&self, line: &str) {
|
|
||||||
if let Some(captures) = URL_REGEX.captures(line) {
|
|
||||||
if let Some(port_match) = captures
|
|
||||||
.name("port")
|
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
|
||||||
{
|
|
||||||
*self.port.write() = Some(port_match);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(path_match) = captures.name("path") {
|
|
||||||
let value = path_match.as_str();
|
|
||||||
if !value.is_empty() && value != "/" {
|
|
||||||
*self.api_prefix.write() = value.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_port_detection(&self) -> Result<()> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let timeout_duration = Duration::from_secs(15);
|
|
||||||
|
|
||||||
while start.elapsed() < timeout_duration {
|
|
||||||
if self.current_port().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!("OpenCode did not report port within 15 seconds"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_ready(&self) -> Result<()> {
|
|
||||||
let Some(port) = self.current_port() else {
|
|
||||||
return Err(anyhow!("Cannot check readiness without port"));
|
|
||||||
};
|
|
||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(READY_CHECK_TIMEOUT_MS);
|
|
||||||
let mut last_error: Option<String> = None;
|
|
||||||
|
|
||||||
while tokio::time::Instant::now() < deadline {
|
|
||||||
let api_prefix = self.api_prefix();
|
|
||||||
|
|
||||||
// Try /config, /agent endpoints
|
|
||||||
match self.check_endpoints(port, &api_prefix).await {
|
|
||||||
Ok(()) => {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
last_error = Some(e.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(READY_CHECK_INTERVAL_MS)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!(
|
|
||||||
"OpenCode not ready after {}ms: {}",
|
|
||||||
READY_CHECK_TIMEOUT_MS,
|
|
||||||
last_error.unwrap_or_else(|| "no error details".to_string())
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_endpoints(&self, port: u16, prefix: &str) -> Result<()> {
|
|
||||||
let base_url = format!("http://127.0.0.1:{port}{prefix}");
|
|
||||||
|
|
||||||
let config_url = format!("{base_url}/config");
|
|
||||||
let agent_url = format!("{base_url}/agent");
|
|
||||||
|
|
||||||
let (config_resp, agent_resp) = tokio::join!(
|
|
||||||
self.http_client.get(&config_url).send(),
|
|
||||||
self.http_client.get(&agent_url).send()
|
|
||||||
);
|
|
||||||
|
|
||||||
let config_resp = config_resp?;
|
|
||||||
if !config_resp.status().is_success() {
|
|
||||||
return Err(anyhow!("/config returned {}", config_resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let agent_resp = agent_resp?;
|
|
||||||
if !agent_resp.status().is_success() {
|
|
||||||
return Err(anyhow!("/agent returned {}", agent_resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn graceful_stop(&self) -> Result<()> {
|
|
||||||
let port_to_kill = self.current_port();
|
|
||||||
|
|
||||||
let mut guard = self.child.lock().await;
|
|
||||||
let Some(mut child) = guard.take() else {
|
|
||||||
// No child, but still kill by port in case of orphaned processes
|
|
||||||
drop(guard);
|
|
||||||
kill_process_on_port(port_to_kill);
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if child.try_wait()?.is_some() {
|
|
||||||
// Already exited, but still clean up by port
|
|
||||||
drop(guard);
|
|
||||||
kill_process_on_port(port_to_kill);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// SIGTERM
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use nix::{
|
|
||||||
sys::signal::{kill, Signal},
|
|
||||||
unistd::Pid,
|
|
||||||
};
|
|
||||||
if let Some(id) = child.id() {
|
|
||||||
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
|
|
||||||
info!("[desktop:opencode] sent SIGTERM");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let _ = child.kill().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 3 seconds for graceful exit
|
|
||||||
match timeout(Duration::from_secs(3), child.wait()).await {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("[desktop:opencode] exited gracefully");
|
|
||||||
drop(guard);
|
|
||||||
kill_process_on_port(port_to_kill);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SIGKILL
|
|
||||||
let _ = child.kill().await;
|
|
||||||
|
|
||||||
match timeout(Duration::from_secs(2), child.wait()).await {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("[desktop:opencode] exited after SIGKILL");
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
kill_process_on_port(port_to_kill);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_process_on_port(port: Option<u16>) {
|
|
||||||
let Some(port) = port else { return };
|
|
||||||
|
|
||||||
// Kill any process listening on our port to clean up orphaned children.
|
|
||||||
// The opencode CLI is a Node wrapper that spawns the actual binary as a child.
|
|
||||||
// Killing the wrapper doesn't kill the child, so we kill by port.
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::process::Command;
|
|
||||||
// First get PIDs, then kill them separately to avoid xargs issues
|
|
||||||
if let Ok(output) = Command::new("lsof")
|
|
||||||
.args(["-ti", &format!(":{}", port)])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
let pids = String::from_utf8_lossy(&output.stdout);
|
|
||||||
for pid in pids.split_whitespace() {
|
|
||||||
if let Ok(pid_num) = pid.trim().parse::<i32>() {
|
|
||||||
// Don't kill our own process
|
|
||||||
if pid_num != std::process::id() as i32 {
|
|
||||||
let _ = Command::new("kill")
|
|
||||||
.args(["-9", &pid_num.to_string()])
|
|
||||||
.output();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if CLI binary exists (can be called dynamically for polling)
|
|
||||||
pub fn check_cli_exists() -> bool {
|
|
||||||
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
resolve_opencode_binary().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_opencode_binary() -> Option<String> {
|
|
||||||
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
|
|
||||||
if !value.is_empty() && Path::new(&value).exists() {
|
|
||||||
info!(
|
|
||||||
"[desktop:opencode] using binary from OPENCODE_BINARY env: {}",
|
|
||||||
value
|
|
||||||
);
|
|
||||||
return Some(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let shell_env = detect_shell_env();
|
|
||||||
|
|
||||||
if let Some(ref binary) = shell_env.opencode_binary {
|
|
||||||
if Path::new(binary).exists() {
|
|
||||||
info!(
|
|
||||||
"[desktop:opencode] using binary from shell OPENCODE_BINARY: {}",
|
|
||||||
binary
|
|
||||||
);
|
|
||||||
return Some(binary.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref login_path) = shell_env.path {
|
|
||||||
for dir in login_path.split(':') {
|
|
||||||
let candidate = format!("{}/opencode", dir);
|
|
||||||
if Path::new(&candidate).exists() {
|
|
||||||
info!("[desktop:opencode] found binary in PATH: {}", candidate);
|
|
||||||
return Some(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(home) = dirs::home_dir() {
|
|
||||||
let fallback = home.join(".opencode/bin/opencode");
|
|
||||||
if fallback.exists() {
|
|
||||||
info!(
|
|
||||||
"[desktop:opencode] found binary in fallback location: {:?}",
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
return Some(fallback.to_string_lossy().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!("[desktop:opencode] opencode binary not found");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_augmented_env() -> HashMap<String, String> {
|
|
||||||
let mut env: HashMap<String, String> = std::env::vars().collect();
|
|
||||||
if let Ok(login_path) = detect_login_shell_path() {
|
|
||||||
let current = env.get("PATH").cloned().unwrap_or_default();
|
|
||||||
env.insert("PATH".to_string(), merge_paths(&login_path, ¤t));
|
|
||||||
}
|
|
||||||
env
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_paths(login_path: &str, current: &str) -> String {
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
for part in login_path.split(':').chain(current.split(':')) {
|
|
||||||
if part.is_empty() || seen.contains(part) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.insert(part.to_string());
|
|
||||||
segments.push(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.join(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct ShellEnv {
|
|
||||||
path: Option<String>,
|
|
||||||
opencode_binary: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn get_user_shell() -> Option<String> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let username =
|
|
||||||
dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
|
|
||||||
|
|
||||||
let output = Command::new("dscl")
|
|
||||||
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if output.status.success() {
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
stdout.split(':').nth(1).map(|s| s.trim().to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
|
||||||
fn get_user_shell() -> Option<String> {
|
|
||||||
std::env::var("SHELL").ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn get_user_shell() -> Option<String> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_shell_env_command(shell: &str) -> Vec<String> {
|
|
||||||
let shell_name = std::path::Path::new(shell)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("sh");
|
|
||||||
|
|
||||||
match shell_name {
|
|
||||||
"nu" | "nushell" => vec![
|
|
||||||
"-l".to_string(),
|
|
||||||
"-i".to_string(),
|
|
||||||
"-c".to_string(),
|
|
||||||
"echo $\"__PATH__=($env.PATH | str join (char esep))\"; echo $\"__OPENCODE_BINARY__=($env.OPENCODE_BINARY? | default '')\"".to_string(),
|
|
||||||
],
|
|
||||||
"bash" => vec![
|
|
||||||
"-lic".to_string(),
|
|
||||||
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(),
|
|
||||||
],
|
|
||||||
_ => vec![
|
|
||||||
"-lic".to_string(),
|
|
||||||
"echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_shell_env() -> ShellEnv {
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
ShellEnv::default()
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into());
|
|
||||||
info!("[desktop:opencode] detected user shell: {}", shell);
|
|
||||||
let args = build_shell_env_command(&shell);
|
|
||||||
info!("[desktop:opencode] shell args: {:?}", args);
|
|
||||||
|
|
||||||
let output = match Command::new(&shell).args(&args).output() {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("[desktop:opencode] failed to run shell {}: {}", shell, e);
|
|
||||||
return ShellEnv::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
warn!(
|
|
||||||
"[desktop:opencode] shell env detection failed for {}, stderr: {}",
|
|
||||||
shell, stderr
|
|
||||||
);
|
|
||||||
return ShellEnv::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
info!("[desktop:opencode] shell stdout length: {}", stdout.len());
|
|
||||||
let mut env = ShellEnv::default();
|
|
||||||
|
|
||||||
for line in stdout.lines() {
|
|
||||||
if let Some(path) = line.strip_prefix("__PATH__=") {
|
|
||||||
if !path.is_empty() {
|
|
||||||
env.path = Some(path.to_string());
|
|
||||||
}
|
|
||||||
} else if let Some(binary) = line.strip_prefix("__OPENCODE_BINARY__=") {
|
|
||||||
if !binary.is_empty() {
|
|
||||||
env.opencode_binary = Some(binary.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"[desktop:opencode] parsed path exists: {}",
|
|
||||||
env.path.is_some()
|
|
||||||
);
|
|
||||||
env
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_login_shell_path() -> Result<String> {
|
|
||||||
detect_shell_env()
|
|
||||||
.path
|
|
||||||
.ok_or_else(|| anyhow!("shell PATH detection failed"))
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub fn expand_tilde_path(value: &str) -> PathBuf {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return PathBuf::from(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
|
||||||
|
|
||||||
if trimmed == "~" {
|
|
||||||
return home;
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
|
|
||||||
return home.join(&trimmed[2..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
PathBuf::from(trimmed)
|
|
||||||
}
|
|
||||||
@@ -1,930 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use chrono::{DateTime, Local, TimeZone};
|
|
||||||
use log::warn;
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::{
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
path::PathBuf,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::opencode_auth;
|
|
||||||
|
|
||||||
const OPENCODE_CONFIG_DIR: &str = ".config/opencode";
|
|
||||||
const OPENCODE_DATA_DIR: &str = ".local/share/opencode";
|
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID: &str =
|
|
||||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
|
||||||
const GOOGLE_CLIENT_SECRET: &str = "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";
|
|
||||||
const DEFAULT_PROJECT_ID: &str = "rising-fact-p41fc";
|
|
||||||
const GOOGLE_WINDOW_SECONDS: i64 = 5 * 60 * 60;
|
|
||||||
|
|
||||||
const GOOGLE_ENDPOINTS: [&str; 3] = [
|
|
||||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
||||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
|
||||||
"https://cloudcode-pa.googleapis.com",
|
|
||||||
];
|
|
||||||
|
|
||||||
const GOOGLE_USER_AGENT: &str = "antigravity/1.11.5 windows/amd64";
|
|
||||||
const GOOGLE_API_CLIENT: &str = "google-cloud-sdk vscode_cloudshelleditor/0.1";
|
|
||||||
const GOOGLE_CLIENT_METADATA: &str =
|
|
||||||
"{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}";
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct AuthEntry {
|
|
||||||
token: Option<String>,
|
|
||||||
access: Option<String>,
|
|
||||||
refresh: Option<String>,
|
|
||||||
expires: Option<i64>,
|
|
||||||
key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct GoogleAuth {
|
|
||||||
access_token: Option<String>,
|
|
||||||
refresh_token: Option<String>,
|
|
||||||
expires: Option<i64>,
|
|
||||||
project_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ProviderResult {
|
|
||||||
provider_id: String,
|
|
||||||
provider_name: String,
|
|
||||||
ok: bool,
|
|
||||||
configured: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
usage: Option<ProviderUsage>,
|
|
||||||
fetched_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct ProviderUsage {
|
|
||||||
windows: HashMap<String, UsageWindow>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
models: Option<HashMap<String, ProviderUsage>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct UsageWindow {
|
|
||||||
used_percent: Option<f64>,
|
|
||||||
remaining_percent: Option<f64>,
|
|
||||||
window_seconds: Option<i64>,
|
|
||||||
reset_after_seconds: Option<i64>,
|
|
||||||
reset_at: Option<i64>,
|
|
||||||
reset_at_formatted: Option<String>,
|
|
||||||
reset_after_formatted: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_home_dir() -> PathBuf {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn opencode_config_dir() -> PathBuf {
|
|
||||||
get_home_dir().join(OPENCODE_CONFIG_DIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn opencode_data_dir() -> PathBuf {
|
|
||||||
get_home_dir().join(OPENCODE_DATA_DIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn antigravity_accounts_paths() -> [PathBuf; 2] {
|
|
||||||
[
|
|
||||||
opencode_config_dir().join("antigravity-accounts.json"),
|
|
||||||
opencode_data_dir().join("antigravity-accounts.json"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_json_file(path: &PathBuf) -> Option<Value> {
|
|
||||||
if !path.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let raw = tokio::fs::read_to_string(path).await.ok()?;
|
|
||||||
let trimmed = raw.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
serde_json::from_str(trimmed).map_err(|err| {
|
|
||||||
warn!("Failed to read JSON file {}: {}", path.display(), err);
|
|
||||||
err
|
|
||||||
}).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_auth_entry<'a>(auth: &'a serde_json::Map<String, Value>, aliases: &[&str]) -> Option<&'a Value> {
|
|
||||||
for alias in aliases {
|
|
||||||
if let Some(value) = auth.get(*alias) {
|
|
||||||
return Some(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_auth_entry(value: Option<&Value>) -> Option<AuthEntry> {
|
|
||||||
let value = value?;
|
|
||||||
match value {
|
|
||||||
Value::String(token) => Some(AuthEntry {
|
|
||||||
token: Some(token.clone()),
|
|
||||||
..AuthEntry::default()
|
|
||||||
}),
|
|
||||||
Value::Object(map) => {
|
|
||||||
let token = map.get("token").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
||||||
let access = map.get("access").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
||||||
let refresh = map.get("refresh").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
||||||
let key = map.get("key").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
||||||
let expires = map
|
|
||||||
.get("expires")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.or_else(|| map.get("expires").and_then(|v| v.as_f64()).map(|v| v.round() as i64));
|
|
||||||
|
|
||||||
Some(AuthEntry {
|
|
||||||
token,
|
|
||||||
access,
|
|
||||||
refresh,
|
|
||||||
expires,
|
|
||||||
key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_reset_time(timestamp_ms: i64) -> Option<String> {
|
|
||||||
let reset_dt = Local.timestamp_millis_opt(timestamp_ms).single()?;
|
|
||||||
let now = Local::now();
|
|
||||||
let is_today = reset_dt.date_naive() == now.date_naive();
|
|
||||||
|
|
||||||
if is_today {
|
|
||||||
// Same day: show time only (e.g., "9:56 PM")
|
|
||||||
Some(reset_dt.format("%-I:%M %p").to_string())
|
|
||||||
} else {
|
|
||||||
// Different day: show date + weekday + time (e.g., "Feb 2, Sun 9:56 PM")
|
|
||||||
Some(reset_dt.format("%b %-d, %a %-I:%M %p").to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_reset_after_seconds(reset_at: Option<i64>) -> Option<i64> {
|
|
||||||
let reset_at = reset_at?;
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
||||||
let delta = (reset_at - now_ms) / 1000;
|
|
||||||
Some(delta.max(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_usage_window(used_percent: Option<f64>, window_seconds: Option<i64>, reset_at: Option<i64>) -> UsageWindow {
|
|
||||||
let remaining_percent = used_percent.map(|value| (100.0 - value).max(0.0));
|
|
||||||
let reset_after_seconds = calculate_reset_after_seconds(reset_at);
|
|
||||||
let reset_formatted = reset_at.and_then(format_reset_time);
|
|
||||||
|
|
||||||
UsageWindow {
|
|
||||||
used_percent,
|
|
||||||
remaining_percent,
|
|
||||||
window_seconds,
|
|
||||||
reset_after_seconds,
|
|
||||||
reset_at,
|
|
||||||
reset_at_formatted: reset_formatted.clone(),
|
|
||||||
reset_after_formatted: reset_formatted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_result(
|
|
||||||
provider_id: &str,
|
|
||||||
provider_name: &str,
|
|
||||||
ok: bool,
|
|
||||||
configured: bool,
|
|
||||||
usage: Option<ProviderUsage>,
|
|
||||||
error: Option<String>,
|
|
||||||
) -> ProviderResult {
|
|
||||||
ProviderResult {
|
|
||||||
provider_id: provider_id.to_string(),
|
|
||||||
provider_name: provider_name.to_string(),
|
|
||||||
ok,
|
|
||||||
configured,
|
|
||||||
error,
|
|
||||||
usage,
|
|
||||||
fetched_at: chrono::Utc::now().timestamp_millis(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_auth_map() -> Result<serde_json::Map<String, Value>> {
|
|
||||||
let auth = opencode_auth::read_auth().await?;
|
|
||||||
auth.as_object()
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn has_antigravity_accounts() -> bool {
|
|
||||||
for path in antigravity_accounts_paths() {
|
|
||||||
if let Some(data) = read_json_file(&path).await {
|
|
||||||
if data
|
|
||||||
.get("accounts")
|
|
||||||
.and_then(|value| value.as_array())
|
|
||||||
.is_some_and(|accounts| !accounts.is_empty())
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_configured_quota_providers() -> Result<Vec<String>> {
|
|
||||||
let auth = load_auth_map().await?;
|
|
||||||
let mut configured: HashSet<String> = HashSet::new();
|
|
||||||
|
|
||||||
let openai_auth = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"]));
|
|
||||||
if let Some(entry) = openai_auth {
|
|
||||||
if entry.access.is_some() || entry.token.is_some() {
|
|
||||||
configured.insert("openai".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let google_auth = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"]));
|
|
||||||
if let Some(entry) = google_auth {
|
|
||||||
if entry.access.is_some() || entry.token.is_some() || entry.refresh.is_some() {
|
|
||||||
configured.insert("google".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let zai_auth =
|
|
||||||
normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"]));
|
|
||||||
if let Some(entry) = zai_auth {
|
|
||||||
if entry.key.is_some() || entry.token.is_some() {
|
|
||||||
configured.insert("zai-coding-plan".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let github_copilot_auth =
|
|
||||||
normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"]));
|
|
||||||
if let Some(entry) = github_copilot_auth {
|
|
||||||
if entry.access.is_some() || entry.token.is_some() {
|
|
||||||
configured.insert("github-copilot".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if has_antigravity_accounts().await {
|
|
||||||
configured.insert("google".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(configured.into_iter().collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_number(value: Option<&Value>) -> Option<f64> {
|
|
||||||
let value = value?;
|
|
||||||
value.as_f64().or_else(|| value.as_i64().map(|v| v as f64))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_openai_quota(client: &Client) -> Result<ProviderResult> {
|
|
||||||
let auth = load_auth_map().await?;
|
|
||||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"]));
|
|
||||||
let access_token = entry
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|entry| entry.access.clone().or(entry.token.clone()));
|
|
||||||
|
|
||||||
let Some(access_token) = access_token else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"openai",
|
|
||||||
"OpenAI",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
Some("Not configured".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get("https://chatgpt.com/backend-api/wham/usage")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = match response {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
return Ok(build_result(
|
|
||||||
"openai",
|
|
||||||
"OpenAI",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(err.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Ok(build_result(
|
|
||||||
"openai",
|
|
||||||
"OpenAI",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(format!("API error: {}", response.status().as_u16())),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value = match response.json().await {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(err) => {
|
|
||||||
return Ok(build_result(
|
|
||||||
"openai",
|
|
||||||
"OpenAI",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(err.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let primary = payload
|
|
||||||
.get("rate_limit")
|
|
||||||
.and_then(|value| value.get("primary_window"));
|
|
||||||
let secondary = payload
|
|
||||||
.get("rate_limit")
|
|
||||||
.and_then(|value| value.get("secondary_window"));
|
|
||||||
|
|
||||||
let mut windows: HashMap<String, UsageWindow> = HashMap::new();
|
|
||||||
|
|
||||||
if let Some(primary) = primary {
|
|
||||||
let used_percent = parse_number(primary.get("used_percent"));
|
|
||||||
let window_seconds = primary
|
|
||||||
.get("limit_window_seconds")
|
|
||||||
.and_then(|value| value.as_i64());
|
|
||||||
let reset_at = primary
|
|
||||||
.get("reset_at")
|
|
||||||
.and_then(|value| value.as_i64())
|
|
||||||
.map(|value| value * 1000);
|
|
||||||
windows.insert(
|
|
||||||
"5h".to_string(),
|
|
||||||
to_usage_window(used_percent, window_seconds, reset_at),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(secondary) = secondary {
|
|
||||||
let used_percent = parse_number(secondary.get("used_percent"));
|
|
||||||
let window_seconds = secondary
|
|
||||||
.get("limit_window_seconds")
|
|
||||||
.and_then(|value| value.as_i64());
|
|
||||||
let reset_at = secondary
|
|
||||||
.get("reset_at")
|
|
||||||
.and_then(|value| value.as_i64())
|
|
||||||
.map(|value| value * 1000);
|
|
||||||
windows.insert(
|
|
||||||
"weekly".to_string(),
|
|
||||||
to_usage_window(used_percent, window_seconds, reset_at),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(build_result(
|
|
||||||
"openai",
|
|
||||||
"OpenAI",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
Some(ProviderUsage {
|
|
||||||
windows,
|
|
||||||
models: None,
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_google_auth() -> Result<Option<GoogleAuth>> {
|
|
||||||
let auth = load_auth_map().await?;
|
|
||||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"]));
|
|
||||||
|
|
||||||
if let Some(entry) = entry {
|
|
||||||
let mut refresh = entry.refresh.clone();
|
|
||||||
let mut project_id = None;
|
|
||||||
if let Some(value) = entry.refresh.clone() {
|
|
||||||
if let Some((first, second)) = value.split_once('|') {
|
|
||||||
refresh = Some(first.to_string());
|
|
||||||
project_id = Some(second.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(Some(GoogleAuth {
|
|
||||||
access_token: entry.access.or(entry.token),
|
|
||||||
refresh_token: refresh,
|
|
||||||
expires: entry.expires,
|
|
||||||
project_id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
for path in antigravity_accounts_paths() {
|
|
||||||
let data = match read_json_file(&path).await {
|
|
||||||
Some(data) => data,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let accounts = data.get("accounts").and_then(|value| value.as_array());
|
|
||||||
if let Some(accounts) = accounts {
|
|
||||||
if accounts.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let index = data
|
|
||||||
.get("activeIndex")
|
|
||||||
.and_then(|value| value.as_i64())
|
|
||||||
.unwrap_or(0)
|
|
||||||
.max(0) as usize;
|
|
||||||
let account = accounts.get(index).or_else(|| accounts.first());
|
|
||||||
if let Some(account) = account {
|
|
||||||
let refresh_token = account
|
|
||||||
.get("refreshToken")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(|value| value.to_string());
|
|
||||||
if refresh_token.is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let project_id = account
|
|
||||||
.get("projectId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.or_else(|| {
|
|
||||||
account
|
|
||||||
.get("managedProjectId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
})
|
|
||||||
.map(|value| value.to_string());
|
|
||||||
|
|
||||||
return Ok(Some(GoogleAuth {
|
|
||||||
access_token: None,
|
|
||||||
refresh_token,
|
|
||||||
expires: None,
|
|
||||||
project_id,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_google_access_token(client: &Client, refresh_token: &str) -> Result<Option<String>> {
|
|
||||||
let body = format!(
|
|
||||||
"client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token",
|
|
||||||
urlencoding::encode(GOOGLE_CLIENT_ID),
|
|
||||||
urlencoding::encode(GOOGLE_CLIENT_SECRET),
|
|
||||||
urlencoding::encode(refresh_token)
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post("https://oauth2.googleapis.com/token")
|
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
.body(body)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = match response {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("Failed to refresh Google token: {}", err);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value = response.json().await.unwrap_or(Value::Null);
|
|
||||||
Ok(payload
|
|
||||||
.get("access_token")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(|value| value.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_google_models(client: &Client, access_token: &str, project_id: Option<&str>) -> Option<Value> {
|
|
||||||
let body = if let Some(project_id) = project_id {
|
|
||||||
serde_json::json!({ "project": project_id })
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
|
|
||||||
for endpoint in GOOGLE_ENDPOINTS {
|
|
||||||
let response = client
|
|
||||||
.post(format!("{}/v1internal:fetchAvailableModels", endpoint))
|
|
||||||
.header("Authorization", format!("Bearer {}", access_token))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("User-Agent", GOOGLE_USER_AGENT)
|
|
||||||
.header("X-Goog-Api-Client", GOOGLE_API_CLIENT)
|
|
||||||
.header("Client-Metadata", GOOGLE_CLIENT_METADATA)
|
|
||||||
.json(&body)
|
|
||||||
.timeout(Duration::from_secs(15))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = match response {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
if let Ok(payload) = response.json::<Value>().await {
|
|
||||||
return Some(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_reset_time(value: Option<&Value>) -> Option<i64> {
|
|
||||||
let value = value?;
|
|
||||||
if let Some(num) = value.as_i64() {
|
|
||||||
if num > 0 {
|
|
||||||
return Some(num);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(text) = value.as_str() {
|
|
||||||
if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
|
|
||||||
return Some(parsed.timestamp_millis());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_google_quota(client: &Client) -> Result<ProviderResult> {
|
|
||||||
let auth = resolve_google_auth().await?;
|
|
||||||
let Some(auth) = auth else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"google",
|
|
||||||
"Google",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
Some("Not configured".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
|
||||||
let mut access_token = auth.access_token;
|
|
||||||
if access_token.is_none()
|
|
||||||
|| auth
|
|
||||||
.expires
|
|
||||||
.is_some_and(|expires| expires <= now)
|
|
||||||
{
|
|
||||||
let Some(refresh_token) = auth.refresh_token.as_ref() else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"google",
|
|
||||||
"Google",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some("Missing refresh token".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
access_token = refresh_google_access_token(client, refresh_token).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(access_token) = access_token else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"google",
|
|
||||||
"Google",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some("Failed to refresh OAuth token".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let project_id = auth.project_id.unwrap_or_else(|| DEFAULT_PROJECT_ID.to_string());
|
|
||||||
let payload = fetch_google_models(client, &access_token, Some(project_id.as_str())).await;
|
|
||||||
let Some(payload) = payload else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"google",
|
|
||||||
"Google",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some("Failed to fetch models".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut models: HashMap<String, ProviderUsage> = HashMap::new();
|
|
||||||
if let Some(model_map) = payload.get("models").and_then(|value| value.as_object()) {
|
|
||||||
for (model_name, model_data) in model_map {
|
|
||||||
let remaining_fraction = parse_number(model_data.get("quotaInfo").and_then(|v| v.get("remainingFraction")));
|
|
||||||
let remaining_percent = remaining_fraction.map(|value| (value * 100.0).round());
|
|
||||||
let used_percent = remaining_percent.map(|value| (100.0 - value).max(0.0));
|
|
||||||
let reset_at = parse_reset_time(model_data.get("quotaInfo").and_then(|v| v.get("resetTime")));
|
|
||||||
|
|
||||||
let mut windows = HashMap::new();
|
|
||||||
windows.insert(
|
|
||||||
"5h".to_string(),
|
|
||||||
to_usage_window(used_percent, Some(GOOGLE_WINDOW_SECONDS), reset_at),
|
|
||||||
);
|
|
||||||
models.insert(
|
|
||||||
model_name.to_string(),
|
|
||||||
ProviderUsage {
|
|
||||||
windows,
|
|
||||||
models: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(build_result(
|
|
||||||
"google",
|
|
||||||
"Google",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
Some(ProviderUsage {
|
|
||||||
windows: HashMap::new(),
|
|
||||||
models: if models.is_empty() { None } else { Some(models) },
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_timestamp(value: Option<&Value>) -> Option<i64> {
|
|
||||||
let value = value?;
|
|
||||||
if let Some(num) = value.as_i64() {
|
|
||||||
if num < 1_000_000_000_000 {
|
|
||||||
return Some(num * 1000);
|
|
||||||
}
|
|
||||||
return Some(num);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_window_seconds(limit: &Value) -> Option<i64> {
|
|
||||||
let number = limit.get("number").and_then(|value| value.as_i64())?;
|
|
||||||
let unit = limit.get("unit").and_then(|value| value.as_i64())?;
|
|
||||||
let unit_seconds = match unit {
|
|
||||||
3 => Some(3600),
|
|
||||||
_ => None,
|
|
||||||
}?;
|
|
||||||
Some(unit_seconds * number)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_window_label(window_seconds: Option<i64>) -> String {
|
|
||||||
let Some(window_seconds) = window_seconds else {
|
|
||||||
return "tokens".to_string();
|
|
||||||
};
|
|
||||||
if window_seconds % 86400 == 0 {
|
|
||||||
let days = window_seconds / 86400;
|
|
||||||
if days == 7 {
|
|
||||||
return "weekly".to_string();
|
|
||||||
}
|
|
||||||
return format!("{}d", days);
|
|
||||||
}
|
|
||||||
if window_seconds % 3600 == 0 {
|
|
||||||
return format!("{}h", window_seconds / 3600);
|
|
||||||
}
|
|
||||||
format!("{}s", window_seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_zai_quota(client: &Client) -> Result<ProviderResult> {
|
|
||||||
let auth = load_auth_map().await?;
|
|
||||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"]));
|
|
||||||
let api_key = entry
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|entry| entry.key.clone().or(entry.token.clone()));
|
|
||||||
|
|
||||||
let Some(api_key) = api_key else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"zai-coding-plan",
|
|
||||||
"z.ai",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
Some("Not configured".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get("https://api.z.ai/api/monitor/usage/quota/limit")
|
|
||||||
.bearer_auth(api_key)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = match response {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
return Ok(build_result(
|
|
||||||
"zai-coding-plan",
|
|
||||||
"z.ai",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(err.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Ok(build_result(
|
|
||||||
"zai-coding-plan",
|
|
||||||
"z.ai",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(format!("API error: {}", response.status().as_u16())),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value = match response.json().await {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(err) => {
|
|
||||||
return Ok(build_result(
|
|
||||||
"zai-coding-plan",
|
|
||||||
"z.ai",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(err.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let limits = payload
|
|
||||||
.get("data")
|
|
||||||
.and_then(|value| value.get("limits"))
|
|
||||||
.and_then(|value| value.as_array())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let tokens_limit = limits
|
|
||||||
.iter()
|
|
||||||
.find(|limit| limit.get("type").and_then(|value| value.as_str()) == Some("TOKENS_LIMIT"));
|
|
||||||
|
|
||||||
let mut windows = HashMap::new();
|
|
||||||
if let Some(limit) = tokens_limit {
|
|
||||||
let window_seconds = resolve_window_seconds(limit);
|
|
||||||
let window_label = resolve_window_label(window_seconds);
|
|
||||||
let reset_at = normalize_timestamp(limit.get("nextResetTime"));
|
|
||||||
let used_percent = parse_number(limit.get("percentage"));
|
|
||||||
|
|
||||||
windows.insert(
|
|
||||||
window_label,
|
|
||||||
to_usage_window(used_percent, window_seconds, reset_at),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(build_result(
|
|
||||||
"zai-coding-plan",
|
|
||||||
"z.ai",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
Some(ProviderUsage {
|
|
||||||
windows,
|
|
||||||
models: None,
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_github_copilot_quota(client: &Client) -> Result<ProviderResult> {
|
|
||||||
let auth = load_auth_map().await?;
|
|
||||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"]));
|
|
||||||
let access_token = entry
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|entry| entry.access.clone().or(entry.token.clone()));
|
|
||||||
|
|
||||||
let Some(access_token) = access_token else {
|
|
||||||
return Ok(build_result(
|
|
||||||
"github-copilot",
|
|
||||||
"GitHub Copilot",
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
Some("Not configured".to_string()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get("https://api.github.com/copilot_internal/user")
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.header("Accept", "application/vnd.github+json")
|
|
||||||
.header("User-Agent", "OpenChamber")
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = match response {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
return Ok(build_result(
|
|
||||||
"github-copilot",
|
|
||||||
"GitHub Copilot",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(err.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Ok(build_result(
|
|
||||||
"github-copilot",
|
|
||||||
"GitHub Copilot",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(format!("API error: {}", response.status().as_u16())),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: Value = match response.json().await {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(err) => {
|
|
||||||
return Ok(build_result(
|
|
||||||
"github-copilot",
|
|
||||||
"GitHub Copilot",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
None,
|
|
||||||
Some(err.to_string()),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse reset date
|
|
||||||
let mut reset_at: Option<i64> = None;
|
|
||||||
let reset_date_utc = payload
|
|
||||||
.get("quota_reset_date_utc")
|
|
||||||
.and_then(|v| v.as_str());
|
|
||||||
let reset_date = payload
|
|
||||||
.get("quota_reset_date")
|
|
||||||
.and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
if let Some(date_str) = reset_date_utc {
|
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
|
|
||||||
reset_at = Some(dt.timestamp_millis());
|
|
||||||
}
|
|
||||||
} else if let Some(date_str) = reset_date {
|
|
||||||
// Use the date as UTC midnight
|
|
||||||
let full_date = format!("{}T00:00:00Z", date_str);
|
|
||||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&full_date) {
|
|
||||||
reset_at = Some(dt.timestamp_millis());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut windows: HashMap<String, UsageWindow> = HashMap::new();
|
|
||||||
|
|
||||||
// Get premium_interactions snapshot
|
|
||||||
if let Some(snapshots) = payload.get("quota_snapshots") {
|
|
||||||
if let Some(premium) = snapshots.get("premium_interactions") {
|
|
||||||
let mut used_percent: Option<f64> = None;
|
|
||||||
|
|
||||||
let unlimited = premium
|
|
||||||
.get("unlimited")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !unlimited {
|
|
||||||
if let Some(percent_remaining) = premium.get("percent_remaining").and_then(|v| v.as_f64()) {
|
|
||||||
used_percent = Some(100.0 - percent_remaining);
|
|
||||||
} else if let Some(entitlement) = premium.get("entitlement").and_then(|v| v.as_f64()) {
|
|
||||||
if entitlement > 0.0 {
|
|
||||||
let remaining = premium
|
|
||||||
.get("remaining")
|
|
||||||
.and_then(|v| v.as_f64())
|
|
||||||
.or_else(|| premium.get("quota_remaining").and_then(|v| v.as_f64()));
|
|
||||||
|
|
||||||
if let Some(rem) = remaining {
|
|
||||||
used_percent = Some(((entitlement - rem) / entitlement) * 100.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
windows.insert(
|
|
||||||
"premium_interactions".to_string(),
|
|
||||||
to_usage_window(used_percent, None, reset_at),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(build_result(
|
|
||||||
"github-copilot",
|
|
||||||
"GitHub Copilot",
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
Some(ProviderUsage {
|
|
||||||
windows,
|
|
||||||
models: None,
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn fetch_quota_for_provider(client: &Client, provider_id: &str) -> Result<ProviderResult> {
|
|
||||||
match provider_id {
|
|
||||||
"openai" => fetch_openai_quota(client).await,
|
|
||||||
"google" => fetch_google_quota(client).await,
|
|
||||||
"zai-coding-plan" => fetch_zai_quota(client).await,
|
|
||||||
"github-copilot" => fetch_github_copilot_quota(client).await,
|
|
||||||
_ => Ok(build_result(
|
|
||||||
provider_id,
|
|
||||||
provider_id,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
Some("Unsupported provider".to_string()),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures_util::TryStreamExt;
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::Value;
|
|
||||||
use tauri::{AppHandle, Emitter};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio_util::io::StreamReader;
|
|
||||||
|
|
||||||
use crate::path_utils::expand_tilde_path;
|
|
||||||
use crate::DesktopRuntime;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct EventEnvelope {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
event_type: String,
|
|
||||||
#[serde(default)]
|
|
||||||
properties: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct MultiplexedEventEnvelope {
|
|
||||||
#[serde(default)]
|
|
||||||
directory: Option<String>,
|
|
||||||
payload: EventEnvelope,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub enum ActivityPhase {
|
|
||||||
Idle,
|
|
||||||
Busy,
|
|
||||||
Cooldown,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
enum SseScope {
|
|
||||||
Global,
|
|
||||||
Directory(std::path::PathBuf),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_session_activity_tracker(
|
|
||||||
app: AppHandle,
|
|
||||||
runtime: DesktopRuntime,
|
|
||||||
) -> tauri::async_runtime::JoinHandle<()> {
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(Duration::from_secs(24 * 60 * 60))
|
|
||||||
.tcp_keepalive(Some(Duration::from_secs(30)))
|
|
||||||
.build()
|
|
||||||
.expect("failed to build reqwest client");
|
|
||||||
|
|
||||||
let mut shutdown_rx = runtime.subscribe_shutdown();
|
|
||||||
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
|
|
||||||
let cooldowns = Arc::new(Mutex::new(HashMap::<
|
|
||||||
String,
|
|
||||||
tauri::async_runtime::JoinHandle<()>,
|
|
||||||
>::new()));
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown_rx.recv() => {
|
|
||||||
info!("[desktop:activity] Shutdown received, stopping SSE listener");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ = async {
|
|
||||||
// Reset stale phases to idle before connecting so UI doesn't stay stuck on "working" after wake.
|
|
||||||
reset_and_emit_all_phases(&app, phases.clone(), cooldowns.clone()).await;
|
|
||||||
|
|
||||||
if let Err(err) = run_once(&app, &runtime, &client, phases.clone(), cooldowns.clone()).await {
|
|
||||||
warn!("[desktop:activity] SSE loop error: {err:?}");
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
} => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_once(
|
|
||||||
app: &AppHandle,
|
|
||||||
runtime: &DesktopRuntime,
|
|
||||||
client: &Client,
|
|
||||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
|
||||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let opencode = runtime.opencode_manager();
|
|
||||||
|
|
||||||
let port = match opencode.current_port() {
|
|
||||||
Some(port) => port,
|
|
||||||
None => {
|
|
||||||
warn!("[desktop:activity] OpenCode port unavailable; will retry");
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let prefix = opencode.api_prefix();
|
|
||||||
let base = format!("http://127.0.0.1:{port}{prefix}");
|
|
||||||
let (response, scope) = connect_activity_sse(runtime, client, &base).await?;
|
|
||||||
|
|
||||||
use tokio::io::AsyncBufReadExt;
|
|
||||||
|
|
||||||
let stream = response
|
|
||||||
.bytes_stream()
|
|
||||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
|
|
||||||
let mut reader = StreamReader::new(stream);
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut data_lines: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
buf.clear();
|
|
||||||
let bytes_read = match tokio::time::timeout(
|
|
||||||
Duration::from_secs(2),
|
|
||||||
reader.read_until(b'\n', &mut buf),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(n)) => n,
|
|
||||||
Ok(Err(err)) => {
|
|
||||||
warn!("[desktop:activity] Read error in SSE stream: {err:?}");
|
|
||||||
return Err(err.into());
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// No data received recently; if we are connected to a directory-scoped stream and the working directory
|
|
||||||
// has changed, reconnect so activity tracking follows the new directory.
|
|
||||||
if let SseScope::Directory(connected_dir) = &scope {
|
|
||||||
if let Some(current_dir) =
|
|
||||||
resolve_project_directory_from_settings(runtime).await
|
|
||||||
{
|
|
||||||
if current_dir != *connected_dir {
|
|
||||||
debug!(
|
|
||||||
"[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})",
|
|
||||||
connected_dir, current_dir
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if bytes_read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let line = match std::str::from_utf8(&buf) {
|
|
||||||
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
|
|
||||||
Err(err) => {
|
|
||||||
warn!("[desktop:activity] Non-UTF8 SSE chunk: {err}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if line.is_empty() {
|
|
||||||
if data_lines.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let raw = data_lines.join("\n");
|
|
||||||
data_lines.clear();
|
|
||||||
|
|
||||||
match parse_event_envelope(&raw) {
|
|
||||||
Ok((event, _directory)) => {
|
|
||||||
handle_event(app, event, phases.clone(), cooldowns.clone()).await
|
|
||||||
}
|
|
||||||
Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(rest) = line.strip_prefix("data:") {
|
|
||||||
data_lines.push(rest.trim_start().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option<String>)> {
|
|
||||||
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
|
|
||||||
return Ok((event, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
|
|
||||||
Ok((multiplexed.payload, multiplexed.directory))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
|
||||||
let settings = runtime.settings().load().await.ok()?;
|
|
||||||
|
|
||||||
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
|
||||||
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
|
||||||
if let Some(path) = projects.iter().find_map(|entry| {
|
|
||||||
let id = entry.get("id").and_then(Value::as_str)?;
|
|
||||||
if id != active_id {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
entry.get("path").and_then(Value::as_str)
|
|
||||||
}) {
|
|
||||||
return Some(expand_tilde_path(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings
|
|
||||||
.get("lastDirectory")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(expand_tilde_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_activity_sse(
|
|
||||||
runtime: &DesktopRuntime,
|
|
||||||
client: &Client,
|
|
||||||
base: &str,
|
|
||||||
) -> Result<(reqwest::Response, SseScope)> {
|
|
||||||
let global_url = format!("{base}/global/event");
|
|
||||||
match try_connect_sse(client, &global_url, "[desktop:activity]").await {
|
|
||||||
Ok(response) => {
|
|
||||||
debug!("[desktop:activity] Using SSE endpoint: {global_url}");
|
|
||||||
return Ok((response, SseScope::Global));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"[desktop:activity] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let event_url = format!("{base}/event");
|
|
||||||
match try_connect_sse(client, &event_url, "[desktop:activity]").await {
|
|
||||||
Ok(response) => {
|
|
||||||
debug!("[desktop:activity] Using SSE endpoint: {event_url}");
|
|
||||||
return Ok((response, SseScope::Global));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
debug!(
|
|
||||||
"[desktop:activity] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
|
||||||
anyhow::bail!("No project directory available for SSE fallback");
|
|
||||||
};
|
|
||||||
let directory = working_dir.to_string_lossy().to_string();
|
|
||||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
|
||||||
parsed
|
|
||||||
.query_pairs_mut()
|
|
||||||
.append_pair("directory", &directory);
|
|
||||||
let directory_url = parsed.to_string();
|
|
||||||
|
|
||||||
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?;
|
|
||||||
debug!("[desktop:activity] Using directory-scoped SSE endpoint: {directory_url}");
|
|
||||||
Ok((response, SseScope::Directory(working_dir)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_connect_sse(
|
|
||||||
client: &Client,
|
|
||||||
url: &str,
|
|
||||||
log_prefix: &str,
|
|
||||||
) -> Result<reqwest::Response> {
|
|
||||||
debug!("{log_prefix} Connecting SSE: {url}");
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(url)
|
|
||||||
.header("accept", "text/event-stream")
|
|
||||||
.header("accept-encoding", "identity")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"{log_prefix} SSE response status={} headers={:?}",
|
|
||||||
response.status(),
|
|
||||||
response.headers()
|
|
||||||
);
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!("SSE connect failed with status {}", response.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_event(
|
|
||||||
app: &AppHandle,
|
|
||||||
event: EventEnvelope,
|
|
||||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
|
||||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
|
||||||
) {
|
|
||||||
match event.event_type.as_str() {
|
|
||||||
"session.status" => {
|
|
||||||
let session_id = event
|
|
||||||
.properties
|
|
||||||
.get("sessionID")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
let status = event
|
|
||||||
.properties
|
|
||||||
.get("status")
|
|
||||||
.and_then(|s| s.get("type"))
|
|
||||||
.and_then(Value::as_str);
|
|
||||||
|
|
||||||
if let (Some(id), Some(status_type)) = (session_id, status) {
|
|
||||||
let phase = if status_type == "busy" || status_type == "retry" {
|
|
||||||
ActivityPhase::Busy
|
|
||||||
} else {
|
|
||||||
ActivityPhase::Idle
|
|
||||||
};
|
|
||||||
set_phase(app, &id, phase, phases.clone(), cooldowns.clone()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"session.idle" => {
|
|
||||||
let session_id = event
|
|
||||||
.properties
|
|
||||||
.get("sessionID")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
if let Some(id) = session_id {
|
|
||||||
set_phase(
|
|
||||||
app,
|
|
||||||
&id,
|
|
||||||
ActivityPhase::Idle,
|
|
||||||
phases.clone(),
|
|
||||||
cooldowns.clone(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"message.updated" => {
|
|
||||||
if let Some(info) = event.properties.get("info") {
|
|
||||||
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
if role != "assistant" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let finish = info.get("finish").and_then(Value::as_str);
|
|
||||||
if finish != Some("stop") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let session_id = info
|
|
||||||
.get("sessionID")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
if let Some(id) = session_id {
|
|
||||||
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"message.part.updated" => {
|
|
||||||
let Some(info) = event.properties.get("info") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
if role != "assistant" {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let session_id = info
|
|
||||||
.get("sessionID")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let Some(id) = session_id else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
|
|
||||||
if is_streaming_assistant_part(&event.properties) {
|
|
||||||
set_phase(
|
|
||||||
app,
|
|
||||||
&id,
|
|
||||||
ActivityPhase::Busy,
|
|
||||||
phases.clone(),
|
|
||||||
cooldowns.clone(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive cooldown from info.finish === 'stop' when present.
|
|
||||||
if has_finish_stop(info) {
|
|
||||||
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_streaming_assistant_part(properties: &Value) -> bool {
|
|
||||||
let Some(part) = properties.get("part") else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
matches!(
|
|
||||||
part_type,
|
|
||||||
"step-start" | "text" | "tool" | "reasoning" | "file" | "patch"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_finish_stop(info: &Value) -> bool {
|
|
||||||
info.get("finish").and_then(Value::as_str) == Some("stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn enter_cooldown_if_busy(
|
|
||||||
app: &AppHandle,
|
|
||||||
session_id: &str,
|
|
||||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
|
||||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
|
||||||
) {
|
|
||||||
let current = { phases.lock().await.get(session_id).cloned() };
|
|
||||||
if !matches!(current, Some(ActivityPhase::Busy)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_phase(
|
|
||||||
app,
|
|
||||||
session_id,
|
|
||||||
ActivityPhase::Cooldown,
|
|
||||||
phases.clone(),
|
|
||||||
cooldowns.clone(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let app_clone = app.clone();
|
|
||||||
let phases_clone = phases.clone();
|
|
||||||
let cooldowns_clone = cooldowns.clone();
|
|
||||||
let id_clone = session_id.to_string();
|
|
||||||
let handle = tauri::async_runtime::spawn(async move {
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
let current = { phases_clone.lock().await.get(&id_clone).cloned() };
|
|
||||||
if matches!(current, Some(ActivityPhase::Cooldown)) {
|
|
||||||
set_phase(
|
|
||||||
&app_clone,
|
|
||||||
&id_clone,
|
|
||||||
ActivityPhase::Idle,
|
|
||||||
phases_clone,
|
|
||||||
cooldowns_clone,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut cd = cooldowns.lock().await;
|
|
||||||
if let Some(prev) = cd.remove(session_id) {
|
|
||||||
prev.abort();
|
|
||||||
}
|
|
||||||
cd.insert(session_id.to_string(), handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_phase(
|
|
||||||
app: &AppHandle,
|
|
||||||
session_id: &str,
|
|
||||||
phase: ActivityPhase,
|
|
||||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
|
||||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
|
||||||
) {
|
|
||||||
{
|
|
||||||
let mut map = phases.lock().await;
|
|
||||||
let current = map.get(session_id);
|
|
||||||
if current == Some(&phase) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
map.insert(session_id.to_string(), phase.clone());
|
|
||||||
|
|
||||||
// Cancel cooldown timer when leaving cooldown
|
|
||||||
if !matches!(phase, ActivityPhase::Cooldown) {
|
|
||||||
if let Some(handle) = cooldowns.lock().await.remove(session_id) {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit to webview so UI stays in sync
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"sessionId": session_id,
|
|
||||||
"phase": match phase {
|
|
||||||
ActivityPhase::Idle => "idle",
|
|
||||||
ActivityPhase::Busy => "busy",
|
|
||||||
ActivityPhase::Cooldown => "cooldown",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = app.emit("openchamber:session-activity", payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reset_and_emit_all_phases(
|
|
||||||
app: &AppHandle,
|
|
||||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
|
||||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
|
||||||
) {
|
|
||||||
// Cancel any cooldown timers and set all phases to idle to avoid stale "busy" after wake.
|
|
||||||
{
|
|
||||||
let mut cd = cooldowns.lock().await;
|
|
||||||
for handle in cd.values() {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
cd.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = {
|
|
||||||
let mut guard = phases.lock().await;
|
|
||||||
for value in guard.values_mut() {
|
|
||||||
*value = ActivityPhase::Idle;
|
|
||||||
}
|
|
||||||
guard.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
if snapshot.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (session_id, phase) in snapshot {
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"sessionId": session_id,
|
|
||||||
"phase": match phase {
|
|
||||||
ActivityPhase::Idle => "idle",
|
|
||||||
ActivityPhase::Busy => "busy",
|
|
||||||
ActivityPhase::Cooldown => "cooldown",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let _ = app.emit("openchamber:session-activity", payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{
|
|
||||||
path::PathBuf,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
use tauri::{LogicalPosition, LogicalSize, WebviewWindow, Window};
|
|
||||||
use tokio::fs as async_fs;
|
|
||||||
|
|
||||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct WindowState {
|
|
||||||
pub width: f64,
|
|
||||||
pub height: f64,
|
|
||||||
pub x: f64,
|
|
||||||
pub y: f64,
|
|
||||||
pub is_maximized: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WindowState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
width: 1280.0,
|
|
||||||
height: 800.0,
|
|
||||||
x: 0.0,
|
|
||||||
y: 0.0,
|
|
||||||
is_maximized: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct WindowStateFile {
|
|
||||||
#[serde(rename = "windowState")]
|
|
||||||
pub window_state: WindowState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WindowStateManager {
|
|
||||||
inner: Arc<Mutex<WindowState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowStateManager {
|
|
||||||
pub fn new(initial: WindowState) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(initial)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot(&self) -> WindowState {
|
|
||||||
self.inner.lock().expect("window state poisoned").clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_position(&self, x: f64, y: f64, is_maximized: bool) {
|
|
||||||
if is_maximized {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(mut state) = self.inner.lock() {
|
|
||||||
if !state.is_maximized {
|
|
||||||
state.x = x;
|
|
||||||
state.y = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_size(&self, width: f64, height: f64, is_maximized: bool) {
|
|
||||||
if let Ok(mut state) = self.inner.lock() {
|
|
||||||
if !is_maximized {
|
|
||||||
state.width = width;
|
|
||||||
state.height = height;
|
|
||||||
}
|
|
||||||
state.is_maximized = is_maximized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_file_path() -> Result<PathBuf> {
|
|
||||||
let mut path = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?;
|
|
||||||
path.push(".config");
|
|
||||||
path.push("openchamber");
|
|
||||||
path.push(WINDOW_STATE_FILE);
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_window_state() -> Result<Option<WindowState>> {
|
|
||||||
let path = state_file_path()?;
|
|
||||||
match async_fs::read(&path).await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let file: WindowStateFile = serde_json::from_slice(&bytes)?;
|
|
||||||
Ok(Some(file.window_state))
|
|
||||||
}
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(err) => Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_window_state(state: &WindowState) -> Result<()> {
|
|
||||||
let path = state_file_path()?;
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
async_fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
let payload = WindowStateFile {
|
|
||||||
window_state: state.clone(),
|
|
||||||
};
|
|
||||||
let data = serde_json::to_vec_pretty(&payload)?;
|
|
||||||
async_fs::write(&path, data).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_window_state(window: &WebviewWindow, state: &WindowState) -> Result<()> {
|
|
||||||
let mut normalized = state.clone();
|
|
||||||
clamp_to_visible_region(window, &mut normalized);
|
|
||||||
|
|
||||||
if normalized.width > 0.0 && normalized.height > 0.0 {
|
|
||||||
let _ = window.set_size(LogicalSize::new(normalized.width, normalized.height));
|
|
||||||
}
|
|
||||||
let _ = window.set_position(LogicalPosition::new(normalized.x, normalized.y));
|
|
||||||
if state.is_maximized {
|
|
||||||
let _ = window.maximize();
|
|
||||||
} else {
|
|
||||||
let _ = window.unmaximize();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn persist_window_state(window: &Window, manager: &WindowStateManager) -> Result<()> {
|
|
||||||
let mut snapshot = manager.snapshot();
|
|
||||||
let is_maximized = window.is_maximized().unwrap_or(snapshot.is_maximized);
|
|
||||||
snapshot.is_maximized = is_maximized;
|
|
||||||
|
|
||||||
if !is_maximized {
|
|
||||||
let scale_factor = window.scale_factor().unwrap_or(1.0);
|
|
||||||
if let Ok(size) = window.outer_size() {
|
|
||||||
let logical: LogicalSize<f64> = size.to_logical(scale_factor);
|
|
||||||
snapshot.width = logical.width.max(200.0);
|
|
||||||
snapshot.height = logical.height.max(200.0);
|
|
||||||
}
|
|
||||||
if let Ok(position) = window.outer_position() {
|
|
||||||
let logical: LogicalPosition<f64> = position.to_logical(scale_factor);
|
|
||||||
snapshot.x = logical.x;
|
|
||||||
snapshot.y = logical.y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
save_window_state(&snapshot).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clamp_to_visible_region(window: &WebviewWindow, state: &mut WindowState) {
|
|
||||||
let monitor = match window.current_monitor() {
|
|
||||||
Ok(Some(monitor)) => monitor,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let scale_factor = monitor.scale_factor();
|
|
||||||
let monitor_size: LogicalSize<f64> = monitor.size().to_logical(scale_factor);
|
|
||||||
let monitor_position: LogicalPosition<f64> = monitor.position().to_logical(scale_factor);
|
|
||||||
|
|
||||||
state.width = state.width.clamp(400.0, monitor_size.width);
|
|
||||||
state.height = state.height.clamp(300.0, monitor_size.height);
|
|
||||||
|
|
||||||
let max_x = monitor_position.x + (monitor_size.width - state.width).max(0.0);
|
|
||||||
let max_y = monitor_position.y + (monitor_size.height - state.height).max(0.0);
|
|
||||||
|
|
||||||
state.x = state.x.clamp(monitor_position.x, max_x);
|
|
||||||
state.y = state.y.clamp(monitor_position.y, max_y);
|
|
||||||
}
|
|
||||||
@@ -4,15 +4,16 @@
|
|||||||
"version": "1.6.3",
|
"version": "1.6.3",
|
||||||
"identifier": "ai.opencode.openchamber",
|
"identifier": "ai.opencode.openchamber",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
|
||||||
"beforeBuildCommand": "bun run build",
|
"beforeBuildCommand": "bun run build:sidecar",
|
||||||
"devUrl": "http://127.0.0.1:1421",
|
"devUrl": "http://127.0.0.1:3001",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../noop-dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
|
"create": false,
|
||||||
"title": "OpenChamber",
|
"title": "OpenChamber",
|
||||||
"transparent": false,
|
"transparent": false,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
@@ -33,10 +34,13 @@
|
|||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
},
|
},
|
||||||
|
"withGlobalTauri": true,
|
||||||
"macOSPrivateApi": true
|
"macOSPrivateApi": true
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"externalBin": ["sidecars/openchamber-server"],
|
||||||
|
"resources": ["resources/web-dist/**/*"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
|
|
||||||
import type { DiagnosticsAPI } from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
type LogResponse = {
|
|
||||||
fileName?: string;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePayload = (payload: LogResponse): { fileName: string; content: string } => ({
|
|
||||||
fileName: typeof payload.fileName === 'string' && payload.fileName.trim().length > 0 ? payload.fileName : 'openchamber.log',
|
|
||||||
content: typeof payload.content === 'string' ? payload.content : '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createDesktopDiagnosticsAPI = (): DiagnosticsAPI => ({
|
|
||||||
async downloadLogs() {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<LogResponse>('fetch_desktop_logs', {}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[DiagnosticsAPI] Fetch desktop logs operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return normalizePayload(result ?? {});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to download desktop logs');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
|
|
||||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
|
||||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI, ListDirectoryOptions } from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
type ReadFileBinaryResponse = {
|
|
||||||
dataUrl: string;
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ListDirectoryResponse = DirectoryListResult & {
|
|
||||||
path?: string;
|
|
||||||
entries: Array<
|
|
||||||
DirectoryListResult['entries'][number] & {
|
|
||||||
isFile?: boolean;
|
|
||||||
isSymbolicLink?: boolean;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchFilesResponse = {
|
|
||||||
root: string;
|
|
||||||
count: number;
|
|
||||||
files: Array<{
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
relativePath: string;
|
|
||||||
extension?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
const normalizeDirectoryPayload = (result: ListDirectoryResponse): DirectoryListResult => ({
|
|
||||||
directory: normalizePath(result.directory || result.path || ''),
|
|
||||||
entries: Array.isArray(result.entries)
|
|
||||||
? result.entries.map((entry) => ({
|
|
||||||
name: entry.name || '',
|
|
||||||
path: normalizePath(entry.path || ''),
|
|
||||||
isDirectory: entry.isDirectory ?? false,
|
|
||||||
size: entry.size ?? 0,
|
|
||||||
modified: (entry as { modified?: string }).modified ?? new Date().toISOString(),
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createDesktopFilesAPI = (): FilesAPI => ({
|
|
||||||
async listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult> {
|
|
||||||
try {
|
|
||||||
const result = await safeInvoke<ListDirectoryResponse>('list_directory', {
|
|
||||||
path: normalizePath(path),
|
|
||||||
// NOTE: pass both casings; Tauri arg casing differs across commands
|
|
||||||
respectGitignore: options?.respectGitignore ?? false,
|
|
||||||
respect_gitignore: options?.respectGitignore ?? false,
|
|
||||||
}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] List directory operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return normalizeDirectoryPayload(result);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to list directory');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
|
||||||
try {
|
|
||||||
const normalizedDirectory =
|
|
||||||
typeof payload.directory === 'string' && payload.directory.length > 0
|
|
||||||
? normalizePath(payload.directory)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const result = await safeInvoke<SearchFilesResponse>('search_files', {
|
|
||||||
directory: normalizedDirectory,
|
|
||||||
query: payload.query,
|
|
||||||
// NOTE: pass both casings; Tauri arg casing differs across commands
|
|
||||||
maxResults: payload.maxResults || 100,
|
|
||||||
includeHidden: payload.includeHidden ?? false,
|
|
||||||
respectGitignore: payload.respectGitignore ?? true,
|
|
||||||
max_results: payload.maxResults || 100,
|
|
||||||
include_hidden: payload.includeHidden ?? false,
|
|
||||||
respect_gitignore: payload.respectGitignore ?? true,
|
|
||||||
}, {
|
|
||||||
timeout: 15000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Search files operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result || !Array.isArray(result.files)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.files.map<FileSearchResult>((file) => ({
|
|
||||||
path: normalizePath(file.path),
|
|
||||||
preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to search files');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
|
||||||
try {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const result = await safeInvoke<{ success: boolean; path: string }>('create_directory', {
|
|
||||||
path: normalizedPath
|
|
||||||
}, {
|
|
||||||
timeout: 5000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Create directory operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: Boolean(result?.success),
|
|
||||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to create directory');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async readFile(path: string): Promise<{ content: string; path: string }> {
|
|
||||||
try {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const result = await safeInvoke<{ content: string; path: string }>('read_file', {
|
|
||||||
path: normalizedPath
|
|
||||||
}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Read file operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: result?.content ?? '',
|
|
||||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to read file');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async readFileBinary(path: string): Promise<ReadFileBinaryResponse> {
|
|
||||||
try {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const result = await safeInvoke<ReadFileBinaryResponse>('read_file_binary', {
|
|
||||||
path: normalizedPath
|
|
||||||
}, {
|
|
||||||
timeout: 15000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Read binary file operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataUrl: result?.dataUrl ?? '',
|
|
||||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to read file');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> {
|
|
||||||
try {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const result = await safeInvoke<{ success: boolean; path: string }>('write_file', {
|
|
||||||
path: normalizedPath,
|
|
||||||
content
|
|
||||||
}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Write file operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: Boolean(result?.success),
|
|
||||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to write file');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(path: string): Promise<{ success: boolean }> {
|
|
||||||
try {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const result = await safeInvoke<{ success: boolean }>('delete_path', {
|
|
||||||
path: normalizedPath,
|
|
||||||
}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Delete operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: Boolean(result?.success),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to delete path');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> {
|
|
||||||
try {
|
|
||||||
const result = await safeInvoke<{ success: boolean; path: string }>('rename_path', {
|
|
||||||
oldPath: normalizePath(oldPath),
|
|
||||||
newPath: normalizePath(newPath),
|
|
||||||
}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Rename operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: Boolean(result?.success),
|
|
||||||
path: result?.path ? normalizePath(result.path) : normalizePath(newPath),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to rename path');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async execCommands(commands: string[], cwd: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
results: Array<{
|
|
||||||
command: string;
|
|
||||||
success: boolean;
|
|
||||||
exitCode?: number;
|
|
||||||
stdout?: string;
|
|
||||||
stderr?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const normalizedCwd = normalizePath(cwd);
|
|
||||||
const result = await safeInvoke<{
|
|
||||||
success: boolean;
|
|
||||||
results: Array<{
|
|
||||||
command: string;
|
|
||||||
success: boolean;
|
|
||||||
exitCode?: number;
|
|
||||||
stdout?: string;
|
|
||||||
stderr?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}>('exec_commands', {
|
|
||||||
commands,
|
|
||||||
cwd: normalizedCwd
|
|
||||||
}, {
|
|
||||||
timeout: 120000, // 2 minutes for command execution
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[FilesAPI] Exec commands operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: Boolean(result?.success),
|
|
||||||
results: result?.results ?? [],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(message || 'Failed to execute commands');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
|
|
||||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
|
||||||
import type {
|
|
||||||
GitAPI,
|
|
||||||
GitStatus,
|
|
||||||
GitDiffResponse,
|
|
||||||
GetGitDiffOptions,
|
|
||||||
GitFileDiffResponse,
|
|
||||||
GitBranch,
|
|
||||||
GitDeleteBranchPayload,
|
|
||||||
GitDeleteRemoteBranchPayload,
|
|
||||||
GeneratedCommitMessage,
|
|
||||||
GeneratedPullRequestDescription,
|
|
||||||
GitWorktreeInfo,
|
|
||||||
GitAddWorktreePayload,
|
|
||||||
GitRemoveWorktreePayload,
|
|
||||||
CreateGitCommitOptions,
|
|
||||||
GitCommitResult,
|
|
||||||
GitPushResult,
|
|
||||||
GitPullResult,
|
|
||||||
GitLogOptions,
|
|
||||||
GitLogResponse,
|
|
||||||
GitCommitFilesResponse,
|
|
||||||
GitIdentitySummary,
|
|
||||||
GitIdentityProfile,
|
|
||||||
DiscoveredGitCredential
|
|
||||||
} from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
async function safeGitInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await safeInvoke<T>(command, args, {
|
|
||||||
timeout: 120000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn(`[GitAPI] Git operation ${command} did not complete within 120s; it may still be running.`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createDesktopGitAPI = (): GitAPI => ({
|
|
||||||
async checkIsGitRepository(directory: string): Promise<boolean> {
|
|
||||||
return safeGitInvoke<boolean>('check_is_git_repository', { directory });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGitStatus(directory: string): Promise<GitStatus> {
|
|
||||||
return safeGitInvoke<GitStatus>('get_git_status', { directory });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
|
||||||
const diff = await safeGitInvoke<string>('get_git_diff', {
|
|
||||||
directory,
|
|
||||||
pathStr: options.path,
|
|
||||||
staged: options.staged,
|
|
||||||
contextLines: options.contextLines
|
|
||||||
});
|
|
||||||
return { diff };
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGitFileDiff(directory: string, options: { path: string }): Promise<GitFileDiffResponse> {
|
|
||||||
const [original, modified] = await safeGitInvoke<[string, string]>('get_git_file_diff', {
|
|
||||||
directory,
|
|
||||||
pathStr: options.path,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
original: original ?? '',
|
|
||||||
modified: modified ?? '',
|
|
||||||
path: options.path,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async revertGitFile(directory: string, filePath: string): Promise<void> {
|
|
||||||
return safeGitInvoke<void>('revert_git_file', { directory, filePath });
|
|
||||||
},
|
|
||||||
|
|
||||||
async isLinkedWorktree(directory: string): Promise<boolean> {
|
|
||||||
return safeGitInvoke<boolean>('is_linked_worktree', { directory });
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGitBranches(directory: string): Promise<GitBranch> {
|
|
||||||
return safeGitInvoke<GitBranch>('get_git_branches', { directory });
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
|
|
||||||
await safeGitInvoke<void>('delete_git_branch', {
|
|
||||||
directory,
|
|
||||||
branch: payload.branch,
|
|
||||||
force: payload.force
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
|
|
||||||
await safeGitInvoke<void>('delete_remote_branch', {
|
|
||||||
directory,
|
|
||||||
branch: payload.branch,
|
|
||||||
remote: payload.remote
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> {
|
|
||||||
const response = await safeGitInvoke<{ message: GeneratedCommitMessage }>('generate_commit_message', {
|
|
||||||
directory,
|
|
||||||
files
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
async generatePullRequestDescription(
|
|
||||||
directory: string,
|
|
||||||
payload: { base: string; head: string; context?: string }
|
|
||||||
): Promise<GeneratedPullRequestDescription> {
|
|
||||||
const params: { directory: string; base: string; head: string; context?: string } = {
|
|
||||||
directory,
|
|
||||||
base: payload.base,
|
|
||||||
head: payload.head,
|
|
||||||
};
|
|
||||||
if (payload.context?.trim()) {
|
|
||||||
params.context = payload.context.trim();
|
|
||||||
}
|
|
||||||
return safeGitInvoke<GeneratedPullRequestDescription>('generate_pr_description', params);
|
|
||||||
},
|
|
||||||
|
|
||||||
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
|
||||||
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
|
|
||||||
},
|
|
||||||
|
|
||||||
async addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
|
|
||||||
await safeGitInvoke<void>('add_git_worktree', {
|
|
||||||
directory,
|
|
||||||
pathStr: payload.path,
|
|
||||||
branch: payload.branch,
|
|
||||||
createBranch: payload.createBranch,
|
|
||||||
startPoint: payload.startPoint,
|
|
||||||
});
|
|
||||||
return { success: true, path: payload.path, branch: payload.branch };
|
|
||||||
},
|
|
||||||
|
|
||||||
async removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
|
|
||||||
await safeGitInvoke<void>('remove_git_worktree', {
|
|
||||||
directory,
|
|
||||||
pathStr: payload.path,
|
|
||||||
force: payload.force
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async ensureOpenChamberIgnored(directory: string): Promise<void> {
|
|
||||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
|
||||||
return safeGitInvoke<void>('ensure_openchamber_ignored', { directory });
|
|
||||||
},
|
|
||||||
|
|
||||||
async createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> {
|
|
||||||
return safeGitInvoke<GitCommitResult>('create_git_commit', {
|
|
||||||
directory,
|
|
||||||
message,
|
|
||||||
addAll: options?.addAll,
|
|
||||||
files: options?.files
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult> {
|
|
||||||
return safeGitInvoke<GitPushResult>('git_push', {
|
|
||||||
directory,
|
|
||||||
remote: options?.remote,
|
|
||||||
branch: options?.branch,
|
|
||||||
options: options?.options
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult> {
|
|
||||||
return safeGitInvoke<GitPullResult>('git_pull', {
|
|
||||||
directory,
|
|
||||||
remote: options?.remote,
|
|
||||||
branch: options?.branch
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }> {
|
|
||||||
await safeGitInvoke<void>('git_fetch', {
|
|
||||||
directory,
|
|
||||||
remote: options?.remote
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
async checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
|
|
||||||
await safeGitInvoke<void>('checkout_branch', { directory, branch });
|
|
||||||
return { success: true, branch };
|
|
||||||
},
|
|
||||||
|
|
||||||
async createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> {
|
|
||||||
await safeGitInvoke<void>('create_branch', {
|
|
||||||
directory,
|
|
||||||
name,
|
|
||||||
startPoint
|
|
||||||
});
|
|
||||||
return { success: true, branch: name };
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> {
|
|
||||||
await safeGitInvoke<void>('rename_branch', {
|
|
||||||
directory,
|
|
||||||
oldName,
|
|
||||||
newName
|
|
||||||
});
|
|
||||||
return { success: true, branch: newName };
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse> {
|
|
||||||
return safeGitInvoke<GitLogResponse>('get_git_log', {
|
|
||||||
directory,
|
|
||||||
maxCount: options?.maxCount,
|
|
||||||
from: options?.from,
|
|
||||||
to: options?.to,
|
|
||||||
file: options?.file
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse> {
|
|
||||||
return safeGitInvoke<GitCommitFilesResponse>('get_commit_files', {
|
|
||||||
directory,
|
|
||||||
hash
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
|
|
||||||
try {
|
|
||||||
return await safeGitInvoke<GitIdentitySummary>('get_current_git_identity', { directory });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async hasLocalIdentity(directory: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await safeGitInvoke<boolean>('has_local_identity', { directory });
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
|
|
||||||
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
|
|
||||||
return { success: true, profile };
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGitIdentities(): Promise<GitIdentityProfile[]> {
|
|
||||||
return safeGitInvoke<GitIdentityProfile[]>('get_git_identities');
|
|
||||||
},
|
|
||||||
|
|
||||||
async createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
|
|
||||||
return safeGitInvoke<GitIdentityProfile>('create_git_identity', { profile });
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
|
|
||||||
return safeGitInvoke<GitIdentityProfile>('update_git_identity', { id, updates });
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteGitIdentity(id: string): Promise<void> {
|
|
||||||
return safeGitInvoke<void>('delete_git_identity', { id });
|
|
||||||
},
|
|
||||||
|
|
||||||
async discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
|
|
||||||
return safeGitInvoke<DiscoveredGitCredential[]>('discover_git_credentials');
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
|
|
||||||
try {
|
|
||||||
return await safeGitInvoke<GitIdentitySummary>('get_global_git_identity');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
return await safeGitInvoke<string | null>('get_remote_url', { directory, remote });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import type {
|
|
||||||
GitHubAPI,
|
|
||||||
GitHubAuthStatus,
|
|
||||||
GitHubIssueCommentsResult,
|
|
||||||
GitHubIssueGetResult,
|
|
||||||
GitHubIssuesListResult,
|
|
||||||
GitHubPullRequestContextResult,
|
|
||||||
GitHubPullRequestsListResult,
|
|
||||||
GitHubPullRequest,
|
|
||||||
GitHubPullRequestCreateInput,
|
|
||||||
GitHubPullRequestMergeInput,
|
|
||||||
GitHubPullRequestMergeResult,
|
|
||||||
GitHubPullRequestReadyInput,
|
|
||||||
GitHubPullRequestReadyResult,
|
|
||||||
GitHubPullRequestStatus,
|
|
||||||
GitHubDeviceFlowComplete,
|
|
||||||
GitHubDeviceFlowStart,
|
|
||||||
GitHubUserSummary,
|
|
||||||
} from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
export const createDesktopGitHubAPI = (): GitHubAPI => ({
|
|
||||||
async authStatus(): Promise<GitHubAuthStatus> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubAuthStatus>('github_auth_status', {}, { timeout: 8000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async authStart(): Promise<GitHubDeviceFlowStart> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubDeviceFlowStart>('github_auth_start', {}, { timeout: 8000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubDeviceFlowComplete>('github_auth_complete', { deviceCode }, { timeout: 12000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async authDisconnect(): Promise<{ removed: boolean }> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<{ removed: boolean }>('github_auth_disconnect', {}, { timeout: 8000 });
|
|
||||||
return { removed: Boolean(result?.removed) };
|
|
||||||
},
|
|
||||||
|
|
||||||
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubAuthStatus>('github_auth_activate', { accountId }, { timeout: 8000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async me(): Promise<GitHubUserSummary> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubUserSummary>('github_me', {}, { timeout: 8000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubPullRequestStatus>('github_pr_status', { directory, branch }, { timeout: 12000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubPullRequest>('github_pr_create', payload, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubPullRequestMergeResult>('github_pr_merge', payload, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubIssuesListResult>('github_issues_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubIssueGetResult>('github_issue_get', { directory, number }, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubIssueCommentsResult>('github_issue_comments', { directory, number }, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubPullRequestsListResult>('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async prContext(
|
|
||||||
directory: string,
|
|
||||||
number: number,
|
|
||||||
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
|
|
||||||
): Promise<GitHubPullRequestContextResult> {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
return safeInvoke<GitHubPullRequestContextResult>(
|
|
||||||
'github_pr_context',
|
|
||||||
{ directory, number, includeDiff: Boolean(options?.includeDiff), includeCheckDetails: Boolean(options?.includeCheckDetails) },
|
|
||||||
{ timeout: 30000 }
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import type { RuntimeAPIs, TerminalHandlers } from '@openchamber/ui/lib/api/types';
|
|
||||||
import { createDesktopTerminalAPI } from './terminal';
|
|
||||||
import { createDesktopGitAPI } from './git';
|
|
||||||
import { createDesktopFilesAPI } from './files';
|
|
||||||
import { createDesktopSettingsAPI } from './settings';
|
|
||||||
import { createDesktopPermissionsAPI } from './permissions';
|
|
||||||
import { createDesktopDiagnosticsAPI } from './diagnostics';
|
|
||||||
import { createDesktopNotificationsAPI } from './notifications';
|
|
||||||
import { createDesktopToolsAPI } from './tools';
|
|
||||||
import { createDesktopGitHubAPI } from './github';
|
|
||||||
|
|
||||||
const activeTerminalConnections = new Set<string>();
|
|
||||||
|
|
||||||
export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
|
|
||||||
const terminalAPI = createDesktopTerminalAPI();
|
|
||||||
const originalConnect = terminalAPI.connect.bind(terminalAPI);
|
|
||||||
|
|
||||||
const wrappedTerminalAPI = {
|
|
||||||
...terminalAPI,
|
|
||||||
connect: (sessionId: string, handlers: TerminalHandlers) => {
|
|
||||||
activeTerminalConnections.add(sessionId);
|
|
||||||
const connection = originalConnect(sessionId, handlers);
|
|
||||||
|
|
||||||
const originalClose = connection.close;
|
|
||||||
return {
|
|
||||||
...connection,
|
|
||||||
close: () => {
|
|
||||||
activeTerminalConnections.delete(sessionId);
|
|
||||||
originalClose();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
runtime: { platform: 'desktop', isDesktop: true, isVSCode: false, label: 'tauri-bootstrap' },
|
|
||||||
terminal: wrappedTerminalAPI,
|
|
||||||
git: createDesktopGitAPI(),
|
|
||||||
files: createDesktopFilesAPI(),
|
|
||||||
settings: createDesktopSettingsAPI(),
|
|
||||||
permissions: createDesktopPermissionsAPI(),
|
|
||||||
notifications: createDesktopNotificationsAPI(),
|
|
||||||
github: createDesktopGitHubAPI(),
|
|
||||||
diagnostics: createDesktopDiagnosticsAPI(),
|
|
||||||
tools: createDesktopToolsAPI(),
|
|
||||||
cleanup: () => {
|
|
||||||
console.info('[DesktopAPIs] Performing cleanup...');
|
|
||||||
|
|
||||||
const activeConnections = Array.from(activeTerminalConnections);
|
|
||||||
activeConnections.forEach(sessionId => {
|
|
||||||
console.info(`[DesktopAPIs] Closing terminal session: ${sessionId}`);
|
|
||||||
|
|
||||||
activeTerminalConnections.delete(sessionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info(`[DesktopAPIs] Cleanup completed, closed ${activeConnections.length} terminal connections`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
|
|
||||||
import type { NotificationsAPI, NotificationPayload } from '@openchamber/ui/lib/api/types';
|
|
||||||
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
|
|
||||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
|
||||||
|
|
||||||
export const requestInitialNotificationPermission = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined' && 'Notification' in window) {
|
|
||||||
const permission = await Notification.requestPermission();
|
|
||||||
if (permission !== 'granted') {
|
|
||||||
console.warn('[notifications] Notification permission not granted');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[notifications] Failed to request permission:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDesktopNotificationsAPI = (): NotificationsAPI => ({
|
|
||||||
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
let granted = await isPermissionGranted();
|
|
||||||
if (!granted) {
|
|
||||||
const permission = await requestPermission();
|
|
||||||
granted = permission === 'granted';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!granted) {
|
|
||||||
console.warn('[notifications] Cannot send notification: Permission denied');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await safeInvoke(
|
|
||||||
'desktop_notify',
|
|
||||||
{ payload },
|
|
||||||
{
|
|
||||||
timeout: 5000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[NotificationsAPI] Notify operation timed out');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[notifications] Failed to send notification:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async canNotify(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await isPermissionGranted();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[notifications] Failed to check notification permission:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { DirectoryPermissionRequest, DirectoryPermissionResult, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
export const createDesktopPermissionsAPI = (): PermissionsAPI => ({
|
|
||||||
async requestDirectoryAccess(request: DirectoryPermissionRequest): Promise<DirectoryPermissionResult> {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<DirectoryPermissionResult>('request_directory_access', { request }, {
|
|
||||||
timeout: 30000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[PermissionsAPI] Request directory access operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error requesting directory access:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<StartAccessingResult>('start_accessing_directory', { path }, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[PermissionsAPI] Start accessing directory operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error starting directory access:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<StartAccessingResult>('stop_accessing_directory', { path }, {
|
|
||||||
timeout: 5000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[PermissionsAPI] Stop accessing directory operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error stopping directory access:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
|
||||||
if (!data || typeof data !== 'object') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return data as SettingsPayload;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDesktopSettingsAPI = (): SettingsAPI => ({
|
|
||||||
async load(): Promise<SettingsLoadResult> {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<{ settings: unknown; source: 'desktop' | 'web' }>('load_settings', {}, {
|
|
||||||
timeout: 5000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[SettingsAPI] Load settings operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
settings: sanitizePayload(result.settings),
|
|
||||||
source: result.source,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to load settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<unknown>('save_settings', { changes }, {
|
|
||||||
timeout: 5000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[SettingsAPI] Save settings operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return sanitizePayload(result);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to save settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
|
||||||
try {
|
|
||||||
const { safeInvoke } = await import('../lib/tauriCallbackManager');
|
|
||||||
const result = await safeInvoke<{ restarted: boolean }>('restart_opencode', {}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[SettingsAPI] Restart OpenCode operation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { restarted: result.restarted };
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to restart OpenCode: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { safeInvoke, safeListen } from '../lib/tauriCallbackManager';
|
|
||||||
import type {
|
|
||||||
TerminalAPI,
|
|
||||||
TerminalHandlers,
|
|
||||||
CreateTerminalOptions,
|
|
||||||
ResizeTerminalPayload,
|
|
||||||
TerminalSession,
|
|
||||||
TerminalStreamEvent
|
|
||||||
} from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
async function safeTerminalInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await safeInvoke<T>(command, args, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn(`[TerminalAPI] Command ${command} timed out`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createDesktopTerminalAPI = (): TerminalAPI => ({
|
|
||||||
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
|
||||||
const cols = options.cols ?? 80;
|
|
||||||
const rows = options.rows ?? 24;
|
|
||||||
|
|
||||||
const res = await safeTerminalInvoke<{ session_id: string }>('create_terminal_session', {
|
|
||||||
payload: {
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
cwd: options.cwd
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: res.session_id,
|
|
||||||
cols,
|
|
||||||
rows
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
connect(sessionId: string, handlers: TerminalHandlers) {
|
|
||||||
let unlistenFn: (() => void) | undefined;
|
|
||||||
let cancelled = false;
|
|
||||||
let isConnected = false;
|
|
||||||
|
|
||||||
const stopListening = () => {
|
|
||||||
if (unlistenFn) {
|
|
||||||
unlistenFn();
|
|
||||||
unlistenFn = undefined;
|
|
||||||
isConnected = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startListening = async () => {
|
|
||||||
try {
|
|
||||||
const unlisten = await safeListen<TerminalStreamEvent>(
|
|
||||||
`terminal://${sessionId}`,
|
|
||||||
(event) => {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handlers.onEvent(event.payload);
|
|
||||||
|
|
||||||
if (event.payload?.type === 'exit') {
|
|
||||||
stopListening();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Terminal streams are long-lived; never auto-expire this listener.
|
|
||||||
timeout: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cancelled) {
|
|
||||||
unlisten();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unlistenFn = unlisten;
|
|
||||||
isConnected = true;
|
|
||||||
handlers.onEvent({ type: 'connected' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to listen to terminal events:', err);
|
|
||||||
if (!cancelled) {
|
|
||||||
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
startListening();
|
|
||||||
|
|
||||||
return {
|
|
||||||
close: () => {
|
|
||||||
cancelled = true;
|
|
||||||
stopListening();
|
|
||||||
},
|
|
||||||
isConnected: () => isConnected,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async sendInput(sessionId: string, input: string): Promise<void> {
|
|
||||||
await safeTerminalInvoke('send_terminal_input', {
|
|
||||||
|
|
||||||
sessionId,
|
|
||||||
session_id: sessionId,
|
|
||||||
data: input,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async resize(payload: ResizeTerminalPayload): Promise<void> {
|
|
||||||
await safeTerminalInvoke('resize_terminal', {
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
session_id: payload.sessionId,
|
|
||||||
cols: payload.cols,
|
|
||||||
rows: payload.rows,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async close(sessionId: string): Promise<void> {
|
|
||||||
await safeTerminalInvoke('close_terminal', {
|
|
||||||
sessionId,
|
|
||||||
session_id: sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async restartSession(
|
|
||||||
currentSessionId: string,
|
|
||||||
options: CreateTerminalOptions
|
|
||||||
): Promise<TerminalSession> {
|
|
||||||
const cols = options.cols ?? 80;
|
|
||||||
const rows = options.rows ?? 24;
|
|
||||||
|
|
||||||
const res = await safeTerminalInvoke<{ session_id: string }>(
|
|
||||||
'restart_terminal_session',
|
|
||||||
{
|
|
||||||
payload: {
|
|
||||||
session_id: currentSessionId,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
cwd: options.cwd ?? '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId: res.session_id,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async forceKill(options: {
|
|
||||||
sessionId?: string;
|
|
||||||
cwd?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await safeTerminalInvoke('force_kill_terminal', {
|
|
||||||
payload: {
|
|
||||||
session_id: options.sessionId ?? null,
|
|
||||||
cwd: options.cwd ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
|
||||||
|
|
||||||
export const createDesktopToolsAPI = (): ToolsAPI => ({
|
|
||||||
async getAvailableTools(): Promise<string[]> {
|
|
||||||
|
|
||||||
const response = await fetch('/api/experimental/tool/ids');
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
throw new Error('Tools API returned invalid data format');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
|
||||||
.sort();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
export interface UpdateInfo {
|
|
||||||
available: boolean;
|
|
||||||
version?: string;
|
|
||||||
currentVersion: string;
|
|
||||||
body?: string;
|
|
||||||
date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProgress {
|
|
||||||
downloaded: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Update {
|
|
||||||
version: string;
|
|
||||||
body?: string;
|
|
||||||
date?: string;
|
|
||||||
downloadAndInstall: (
|
|
||||||
onEvent?: (event: DownloadEvent) => void
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DownloadEvent =
|
|
||||||
| { event: 'Started'; data: { contentLength?: number } }
|
|
||||||
| { event: 'Progress'; data: { chunkLength: number } }
|
|
||||||
| { event: 'Finished' };
|
|
||||||
|
|
||||||
let cachedUpdate: Update | null = null;
|
|
||||||
|
|
||||||
export async function checkForUpdates(): Promise<UpdateInfo> {
|
|
||||||
try {
|
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
|
||||||
const [update, currentVersion] = await Promise.all([
|
|
||||||
check(),
|
|
||||||
getCurrentVersion(),
|
|
||||||
]);
|
|
||||||
cachedUpdate = update;
|
|
||||||
|
|
||||||
if (!update) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
currentVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const changelogNotes = await fetchChangelogNotes(currentVersion, update.version);
|
|
||||||
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
version: update.version,
|
|
||||||
currentVersion,
|
|
||||||
body: changelogNotes ?? update.body ?? undefined,
|
|
||||||
date: update.date ?? undefined,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[updater] Failed to check for updates:', error);
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
currentVersion: await getCurrentVersion(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadUpdate(
|
|
||||||
onProgress?: (progress: UpdateProgress) => void
|
|
||||||
): Promise<void> {
|
|
||||||
let update = cachedUpdate;
|
|
||||||
if (!update) {
|
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
|
||||||
const checked = await check();
|
|
||||||
if (!checked) {
|
|
||||||
throw new Error('No update available');
|
|
||||||
}
|
|
||||||
update = checked;
|
|
||||||
cachedUpdate = checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloaded = 0;
|
|
||||||
let total: number | undefined;
|
|
||||||
|
|
||||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
|
||||||
switch (event.event) {
|
|
||||||
case 'Started':
|
|
||||||
total = event.data.contentLength;
|
|
||||||
onProgress?.({ downloaded: 0, total });
|
|
||||||
break;
|
|
||||||
case 'Progress':
|
|
||||||
downloaded += event.data.chunkLength;
|
|
||||||
onProgress?.({ downloaded, total });
|
|
||||||
break;
|
|
||||||
case 'Finished':
|
|
||||||
onProgress?.({ downloaded: total ?? downloaded, total });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restartToUpdate(): Promise<void> {
|
|
||||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
|
||||||
await relaunch();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCurrentVersion(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const { getVersion } = await import('@tauri-apps/api/app');
|
|
||||||
return await getVersion();
|
|
||||||
} catch {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChangelogNotes(fromVersion: string, toVersion: string): Promise<string | undefined> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'
|
|
||||||
);
|
|
||||||
if (!response.ok) return undefined;
|
|
||||||
|
|
||||||
const changelog = await response.text();
|
|
||||||
const sections = changelog.split(/^## /m).slice(1);
|
|
||||||
|
|
||||||
const fromNum = parseVersion(fromVersion);
|
|
||||||
const toNum = parseVersion(toVersion);
|
|
||||||
|
|
||||||
const relevantSections = sections.filter((section) => {
|
|
||||||
const match = section.match(/^\[(\d+\.\d+\.\d+)\]/);
|
|
||||||
if (!match) return false;
|
|
||||||
const ver = parseVersion(match[1]);
|
|
||||||
return ver > fromNum && ver <= toNum;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (relevantSections.length === 0) return undefined;
|
|
||||||
|
|
||||||
return relevantSections
|
|
||||||
.map((s) => '## ' + s.trim())
|
|
||||||
.join('\n\n');
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVersion(version: string): number {
|
|
||||||
const parts = version.split('.').map(Number);
|
|
||||||
return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
|
|
||||||
import { safeInvoke, cleanupAllTauriCallbacks } from './tauriCallbackManager';
|
|
||||||
|
|
||||||
type ServerInfo = {
|
|
||||||
server_port: number;
|
|
||||||
opencode_port?: number | null;
|
|
||||||
api_prefix?: string | null;
|
|
||||||
cli_available?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__OPENCHAMBER_DESKTOP_SERVER__?: {
|
|
||||||
origin: string;
|
|
||||||
opencodePort: number | null;
|
|
||||||
apiPrefix: string;
|
|
||||||
cliAvailable: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let bridgePromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
export function initializeDesktopBridge(): Promise<void> {
|
|
||||||
if (!bridgePromise) {
|
|
||||||
bridgePromise = setupBridge();
|
|
||||||
}
|
|
||||||
return bridgePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupBridge(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const info = await safeInvoke<ServerInfo>('desktop_server_info', {}, {
|
|
||||||
timeout: 10000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[Bridge] Server info request timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const origin = `http://127.0.0.1:${info.server_port}`;
|
|
||||||
|
|
||||||
window.__OPENCHAMBER_DESKTOP_SERVER__ = {
|
|
||||||
origin,
|
|
||||||
opencodePort: info.opencode_port ?? null,
|
|
||||||
apiPrefix: info.api_prefix ?? '',
|
|
||||||
cliAvailable: info.cli_available ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
patchFetch(origin);
|
|
||||||
patchEventSource(origin);
|
|
||||||
|
|
||||||
const cleanupDevtools = registerDevtoolsShortcut();
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup = () => {
|
|
||||||
cleanupDevtools();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[bridge] Failed to initialize bridge:', error);
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && (window as { __openchamberCleanup?: () => void }).__openchamberCleanup) {
|
|
||||||
try {
|
|
||||||
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup?.();
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('[bridge] Cleanup during failed initialization failed:', cleanupError);
|
|
||||||
}
|
|
||||||
delete (window as { __openchamberCleanup?: () => void }).__openchamberCleanup;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupAllTauriCallbacks();
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchFetch(origin: string) {
|
|
||||||
const originalFetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
const rewrite = (value: string): string => {
|
|
||||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (value.startsWith('//')) {
|
|
||||||
return `http:${value}`;
|
|
||||||
}
|
|
||||||
if (value.startsWith('/')) {
|
|
||||||
return `${origin}${value}`;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
return originalFetch(rewrite(input), init);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input instanceof Request) {
|
|
||||||
const rewritten = rewrite(input.url);
|
|
||||||
if (rewritten === input.url) {
|
|
||||||
return originalFetch(input, init);
|
|
||||||
}
|
|
||||||
const cloned = new Request(rewritten, input);
|
|
||||||
return originalFetch(cloned, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input instanceof URL) {
|
|
||||||
return originalFetch(rewrite(input.toString()), init);
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalFetch(input, init);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchEventSource(origin: string) {
|
|
||||||
if (typeof window.EventSource === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OriginalEventSource = window.EventSource;
|
|
||||||
|
|
||||||
class DesktopEventSource extends OriginalEventSource {
|
|
||||||
constructor(url: string | URL, eventSourceInit?: EventSourceInit) {
|
|
||||||
const normalized = typeof url === 'string' ? url : url.toString();
|
|
||||||
super(normalized.startsWith('/') ? `${origin}${normalized}` : normalized, eventSourceInit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(DesktopEventSource, 'name', { value: 'DesktopEventSource' });
|
|
||||||
Object.setPrototypeOf(DesktopEventSource.prototype, OriginalEventSource.prototype);
|
|
||||||
Object.setPrototypeOf(DesktopEventSource, OriginalEventSource);
|
|
||||||
|
|
||||||
window.EventSource = DesktopEventSource as unknown as typeof EventSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerDevtoolsShortcut() {
|
|
||||||
const handler = (event: KeyboardEvent) => {
|
|
||||||
const key = event.key?.toLowerCase();
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.altKey && key === 'i') {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const devtoolsPromise = safeInvoke('desktop_open_devtools', {}, {
|
|
||||||
timeout: 2000,
|
|
||||||
onCancel: () => {
|
|
||||||
console.warn('[Bridge] Devtools invocation timed out');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
devtoolsPromise.catch(() => {
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|
||||||
|
|
||||||
interface PendingCallback {
|
|
||||||
id: string;
|
|
||||||
timestamp: number;
|
|
||||||
type: 'invoke' | 'listen';
|
|
||||||
cleanup?: () => void;
|
|
||||||
timeout?: NodeJS.Timeout;
|
|
||||||
timeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CallbackManagerConfig {
|
|
||||||
maxCallbackAge?: number;
|
|
||||||
cleanupInterval?: number;
|
|
||||||
invokeTimeout?: number;
|
|
||||||
listenTimeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TauriCallbackManager {
|
|
||||||
private callbacks = new Map<string, PendingCallback>();
|
|
||||||
private isShuttingDown = false;
|
|
||||||
private cleanupTimer?: NodeJS.Timeout;
|
|
||||||
private config: Required<CallbackManagerConfig>;
|
|
||||||
private windowUnloadHandler?: () => void;
|
|
||||||
|
|
||||||
constructor(config: CallbackManagerConfig = {}) {
|
|
||||||
this.config = {
|
|
||||||
maxCallbackAge: 30000,
|
|
||||||
cleanupInterval: 5000,
|
|
||||||
invokeTimeout: 10000,
|
|
||||||
listenTimeout: 30000,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setupWindowUnloadHandler();
|
|
||||||
this.startCleanupTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
register(callback: Omit<PendingCallback, 'timestamp'>): string {
|
|
||||||
if (this.isShuttingDown) {
|
|
||||||
console.warn('[TauriCallbackManager] Attempted to register callback during shutdown');
|
|
||||||
return callback.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullCallback: PendingCallback = {
|
|
||||||
...callback,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.callbacks.set(callback.id, fullCallback);
|
|
||||||
|
|
||||||
const timeoutMs =
|
|
||||||
typeof fullCallback.timeoutMs === 'number'
|
|
||||||
? fullCallback.timeoutMs
|
|
||||||
: callback.type === 'listen'
|
|
||||||
? this.config.listenTimeout
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (timeoutMs > 0) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.cleanupCallback(callback.id, 'timeout');
|
|
||||||
}, timeoutMs);
|
|
||||||
fullCallback.timeout = timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
return callback.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
unregister(callbackId: string): void {
|
|
||||||
const callback = this.callbacks.get(callbackId);
|
|
||||||
if (!callback) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callback.timeout) {
|
|
||||||
clearTimeout(callback.timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callback.cleanup) {
|
|
||||||
try {
|
|
||||||
callback.cleanup();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[TauriCallbackManager] Cleanup function failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.callbacks.delete(callbackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanupCallback(callbackId: string, reason: 'timeout' | 'shutdown' | 'expired'): void {
|
|
||||||
const callback = this.callbacks.get(callbackId);
|
|
||||||
if (!callback) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reason === 'expired') {
|
|
||||||
console.warn(`[TauriCallbackManager] Callback ${callbackId} expired and was cleaned up`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.unregister(callbackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupAll(): void {
|
|
||||||
this.isShuttingDown = true;
|
|
||||||
|
|
||||||
if (this.cleanupTimer) {
|
|
||||||
clearInterval(this.cleanupTimer);
|
|
||||||
this.cleanupTimer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackIds = Array.from(this.callbacks.keys());
|
|
||||||
callbackIds.forEach(id => this.cleanupCallback(id, 'shutdown'));
|
|
||||||
|
|
||||||
this.callbacks.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startCleanupTimer(): void {
|
|
||||||
this.cleanupTimer = setInterval(() => {
|
|
||||||
if (this.isShuttingDown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const expiredCallbacks: string[] = [];
|
|
||||||
|
|
||||||
this.callbacks.forEach((callback, id) => {
|
|
||||||
if (callback.type !== 'invoke') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const age = now - callback.timestamp;
|
|
||||||
if (age > this.config.maxCallbackAge) {
|
|
||||||
expiredCallbacks.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expiredCallbacks.forEach(id => this.cleanupCallback(id, 'expired'));
|
|
||||||
}, this.config.cleanupInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupWindowUnloadHandler(): void {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.windowUnloadHandler = () => {
|
|
||||||
console.info('[TauriCallbackManager] Window unloading, cleaning up callbacks...');
|
|
||||||
this.cleanupAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', this.windowUnloadHandler);
|
|
||||||
window.addEventListener('pagehide', this.windowUnloadHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeWindowHandlers(): void {
|
|
||||||
if (this.windowUnloadHandler && typeof window !== 'undefined') {
|
|
||||||
window.removeEventListener('beforeunload', this.windowUnloadHandler);
|
|
||||||
window.removeEventListener('pagehide', this.windowUnloadHandler);
|
|
||||||
this.windowUnloadHandler = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getStats(): { total: number; invoke: number; listen: number } {
|
|
||||||
const stats = { total: 0, invoke: 0, listen: 0 };
|
|
||||||
|
|
||||||
this.callbacks.forEach(callback => {
|
|
||||||
stats.total++;
|
|
||||||
stats[callback.type]++;
|
|
||||||
});
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let globalCallbackManager: TauriCallbackManager | null = null;
|
|
||||||
|
|
||||||
export function getTauriCallbackManager(config?: CallbackManagerConfig): TauriCallbackManager {
|
|
||||||
if (!globalCallbackManager) {
|
|
||||||
globalCallbackManager = new TauriCallbackManager(config);
|
|
||||||
}
|
|
||||||
return globalCallbackManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function safeInvoke<T>(
|
|
||||||
command: string,
|
|
||||||
args?: Record<string, unknown>,
|
|
||||||
options?: {
|
|
||||||
timeout?: number;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
): Promise<T> {
|
|
||||||
const manager = getTauriCallbackManager();
|
|
||||||
const callbackId = `invoke:${command}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
manager.register({
|
|
||||||
id: callbackId,
|
|
||||||
type: 'invoke',
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearAndUnregister = () => {
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
timeoutHandle = undefined;
|
|
||||||
}
|
|
||||||
manager.unregister(callbackId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options?.timeout || options.timeout <= 0) {
|
|
||||||
try {
|
|
||||||
const result = await invoke<T>(command, args);
|
|
||||||
clearAndUnregister();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
clearAndUnregister();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
|
|
||||||
console.warn(`[safeInvoke] Command ${command} timed out after ${options.timeout}ms`);
|
|
||||||
try {
|
|
||||||
options.onCancel?.();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[safeInvoke] onCancel handler threw:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAndUnregister();
|
|
||||||
reject(new Error(`Command ${command} timed out after ${options.timeout}ms`));
|
|
||||||
}, options.timeout);
|
|
||||||
|
|
||||||
invoke<T>(command, args)
|
|
||||||
.then((result) => {
|
|
||||||
if (settled) {
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
clearAndUnregister();
|
|
||||||
resolve(result);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
clearAndUnregister();
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function safeListen<T>(
|
|
||||||
event: string,
|
|
||||||
handler: (event: { payload: T }) => void,
|
|
||||||
options?: {
|
|
||||||
timeout?: number;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
): Promise<UnlistenFn> {
|
|
||||||
const manager = getTauriCallbackManager();
|
|
||||||
const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
manager.register({
|
|
||||||
id: callbackId,
|
|
||||||
type: 'listen',
|
|
||||||
cleanup: options?.onCancel,
|
|
||||||
timeoutMs: options?.timeout,
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlisten = await listen<T>(event, (event) => {
|
|
||||||
|
|
||||||
const currentManager = getTauriCallbackManager();
|
|
||||||
if (currentManager.getStats().total === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
handler(event);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[safeListen] Handler error for event ${event}:`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const enhancedUnlisten = () => {
|
|
||||||
try {
|
|
||||||
unlisten();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[safeListen] Failed to unlisten from ${event}:`, error);
|
|
||||||
}
|
|
||||||
manager.unregister(callbackId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return enhancedUnlisten;
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
manager.unregister(callbackId);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanupAllTauriCallbacks(): void {
|
|
||||||
if (globalCallbackManager) {
|
|
||||||
globalCallbackManager.cleanupAll();
|
|
||||||
globalCallbackManager.removeWindowHandlers();
|
|
||||||
globalCallbackManager = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import { createDesktopAPIs } from './api';
|
|
||||||
import { requestInitialNotificationPermission } from './api/notifications';
|
|
||||||
import { checkForUpdates, downloadUpdate, restartToUpdate, type UpdateInfo, type UpdateProgress } from './api/updater';
|
|
||||||
import { initializeDesktopBridge } from './lib/bridge';
|
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { listen } from '@tauri-apps/api/event';
|
|
||||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
|
||||||
import type { DesktopApi, DesktopSettings } from '@openchamber/ui/lib/desktop';
|
|
||||||
import '@openchamber/ui/index.css';
|
|
||||||
import '@openchamber/ui/styles/fonts';
|
|
||||||
|
|
||||||
if (!(window as typeof globalThis & { process?: unknown }).process) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(window as typeof globalThis & { process?: any }).process = {
|
|
||||||
env: {},
|
|
||||||
platform: 'darwin',
|
|
||||||
version: 'v20.0.0',
|
|
||||||
versions: {},
|
|
||||||
cwd: () => '/',
|
|
||||||
nextTick: (fn: () => void) => Promise.resolve().then(() => fn()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.PROD) {
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'r') {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
|
||||||
__OPENCHAMBER_HOME__?: string;
|
|
||||||
opencodeDesktop?: DesktopApi;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
|
||||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
|
||||||
|
|
||||||
const cleanupFunctions: Array<() => void | Promise<void>> = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await initializeDesktopBridge();
|
|
||||||
|
|
||||||
const activityUnlisten = await listen('openchamber:session-activity', (event) => {
|
|
||||||
window.dispatchEvent(new CustomEvent('openchamber:session-activity', { detail: event.payload }));
|
|
||||||
});
|
|
||||||
cleanupFunctions.push(() => activityUnlisten());
|
|
||||||
|
|
||||||
const updateCheckUnlisten = await listen(CHECK_FOR_UPDATES_EVENT, () => {
|
|
||||||
window.dispatchEvent(new CustomEvent(CHECK_FOR_UPDATES_EVENT));
|
|
||||||
});
|
|
||||||
cleanupFunctions.push(() => updateCheckUnlisten());
|
|
||||||
|
|
||||||
const menuActionUnlisten = await listen<string>(MENU_ACTION_EVENT, (event) => {
|
|
||||||
window.dispatchEvent(new CustomEvent(MENU_ACTION_EVENT, { detail: event.payload }));
|
|
||||||
});
|
|
||||||
cleanupFunctions.push(() => menuActionUnlisten());
|
|
||||||
|
|
||||||
requestInitialNotificationPermission().catch(err => {
|
|
||||||
console.error('[main] Failed to request notification permission:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createDesktopAPIs();
|
|
||||||
|
|
||||||
cleanupFunctions.push(() => {
|
|
||||||
console.info('[main] Cleaning up runtime APIs');
|
|
||||||
|
|
||||||
if (window.__OPENCHAMBER_RUNTIME_APIS__) {
|
|
||||||
/* cleanup placeholder */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[main] FATAL: Failed to initialize desktop runtime:', error);
|
|
||||||
|
|
||||||
for (const cleanup of cleanupFunctions) {
|
|
||||||
try {
|
|
||||||
const result = cleanup();
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
await result;
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('[main] Cleanup function failed during error handling:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
|
||||||
<h1>Desktop Runtime Initialization Failed</h1>
|
|
||||||
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
|
||||||
${error instanceof Error ? error.stack : String(error)}
|
|
||||||
</pre>
|
|
||||||
<p style="margin-top: 20px; color: #999;">Press Cmd+Option+I to open DevTools for more details</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
let homeDirectory: string | undefined;
|
|
||||||
try {
|
|
||||||
const { homeDir } = await import('@tauri-apps/api/path');
|
|
||||||
homeDirectory = await homeDir();
|
|
||||||
} catch {
|
|
||||||
homeDirectory = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (homeDirectory) {
|
|
||||||
window.__OPENCHAMBER_HOME__ = homeDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.opencodeDesktop = {
|
|
||||||
homeDirectory,
|
|
||||||
macosMajorVersion: null as number | null,
|
|
||||||
async getServerInfo() {
|
|
||||||
try {
|
|
||||||
const info = await invoke<ServerInfo>('desktop_server_info');
|
|
||||||
return {
|
|
||||||
webPort: info.server_port,
|
|
||||||
openCodePort: info.opencode_port ?? null,
|
|
||||||
host: '127.0.0.1',
|
|
||||||
ready: info.opencode_port !== null,
|
|
||||||
cliAvailable: info.cli_available ?? false,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
const server = window.__OPENCHAMBER_DESKTOP_SERVER__;
|
|
||||||
return {
|
|
||||||
webPort: server?.origin ? parseInt(server.origin.split(':')[2] || '0', 10) : null,
|
|
||||||
openCodePort: server?.opencodePort ?? null,
|
|
||||||
host: '127.0.0.1',
|
|
||||||
ready: false,
|
|
||||||
cliAvailable: server?.cliAvailable ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getSettings(): Promise<DesktopSettings> {
|
|
||||||
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
|
||||||
return result.settings;
|
|
||||||
},
|
|
||||||
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
|
||||||
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
async restartOpenCode() {
|
|
||||||
try {
|
|
||||||
await invoke('restart_opencode');
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error restarting OpenCode:', error);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async shutdown() {
|
|
||||||
return { success: false };
|
|
||||||
},
|
|
||||||
async getHomeDirectory() {
|
|
||||||
return { success: true, path: homeDirectory || null };
|
|
||||||
},
|
|
||||||
async openExternal(url: string) {
|
|
||||||
try {
|
|
||||||
await open(url);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error opening external link:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
markRendererReady() {
|
|
||||||
|
|
||||||
},
|
|
||||||
async requestDirectoryAccess(directoryPath?: string) {
|
|
||||||
try {
|
|
||||||
const normalized = typeof directoryPath === 'string' ? directoryPath.trim() : '';
|
|
||||||
|
|
||||||
// When the UI already picked a path (typed / directory tree), skip native dialog.
|
|
||||||
if (normalized.length > 0) {
|
|
||||||
const result = await invoke<{
|
|
||||||
success: boolean;
|
|
||||||
path?: string;
|
|
||||||
projectId?: string;
|
|
||||||
error?: string;
|
|
||||||
}>('process_directory_selection', {
|
|
||||||
path: normalized,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
|
||||||
const selected = await open({
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
title: 'Select Working Directory',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selected || typeof selected !== 'string') {
|
|
||||||
return { success: false, error: 'Directory selection cancelled' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await invoke<{
|
|
||||||
success: boolean;
|
|
||||||
path?: string;
|
|
||||||
projectId?: string;
|
|
||||||
error?: string;
|
|
||||||
}>('process_directory_selection', {
|
|
||||||
path: selected,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error requesting directory access:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async startAccessingDirectory(directoryPath: string) {
|
|
||||||
try {
|
|
||||||
const result = await invoke<{ success: boolean; error?: string }>('start_accessing_directory', { path: directoryPath });
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error starting directory access:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async stopAccessingDirectory(directoryPath: string) {
|
|
||||||
try {
|
|
||||||
const result = await invoke<{ success: boolean; error?: string }>('stop_accessing_directory', { path: directoryPath });
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error stopping directory access:', error);
|
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async notifyAssistantCompletion(payload) {
|
|
||||||
try {
|
|
||||||
const { createDesktopNotificationsAPI } = await import('./api/notifications');
|
|
||||||
const result = await createDesktopNotificationsAPI().notifyAgentCompletion(payload);
|
|
||||||
return { success: result };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[desktop] Error sending notification:', error);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async checkForUpdates(): Promise<UpdateInfo> {
|
|
||||||
return checkForUpdates();
|
|
||||||
},
|
|
||||||
async downloadUpdate(onProgress?: (progress: UpdateProgress) => void): Promise<void> {
|
|
||||||
return downloadUpdate(onProgress);
|
|
||||||
},
|
|
||||||
async restartToUpdate(): Promise<void> {
|
|
||||||
return restartToUpdate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch macOS version from Rust
|
|
||||||
try {
|
|
||||||
const macosVersion = await invoke<number>('desktop_get_macos_version');
|
|
||||||
window.opencodeDesktop.macosMajorVersion = macosVersion > 0 ? macosVersion : null;
|
|
||||||
console.info('[main] macOS version:', macosVersion);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[main] Failed to get macOS version:', err);
|
|
||||||
window.opencodeDesktop.macosMajorVersion = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info('[main] window.opencodeDesktop assigned');
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const handleBeforeUnload = () => {
|
|
||||||
console.info('[main] App is unloading, performing cleanup...');
|
|
||||||
|
|
||||||
cleanupFunctions.forEach((cleanup) => {
|
|
||||||
try {
|
|
||||||
const result = cleanup();
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
|
|
||||||
result.catch(cleanupError => {
|
|
||||||
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('[main] Cleanup function failed during unload:', cleanupError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info('[main] Cleanup initiated');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
||||||
|
|
||||||
window.addEventListener('pagehide', handleBeforeUnload);
|
|
||||||
|
|
||||||
cleanupFunctions.push(() => {
|
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
||||||
window.removeEventListener('pagehide', handleBeforeUnload);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerInfo {
|
|
||||||
server_port: number;
|
|
||||||
opencode_port: number | null;
|
|
||||||
api_prefix: string;
|
|
||||||
cli_available: boolean;
|
|
||||||
has_last_directory: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we need to prompt for directory selection first
|
|
||||||
const promptForDirectoryIfNeeded = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const info = await invoke<ServerInfo>('desktop_server_info');
|
|
||||||
// If CLI available but no saved directory, prompt user
|
|
||||||
if (info.cli_available && !info.has_last_directory) {
|
|
||||||
console.info('[main] No saved directory - prompting user');
|
|
||||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
|
||||||
const selected = await open({
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
title: 'Select a project folder to get started'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selected && typeof selected === 'string') {
|
|
||||||
await invoke('process_directory_selection', { path: selected });
|
|
||||||
await invoke('restart_opencode');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[main] Directory selection failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if directory selection is needed, then wait for opencode
|
|
||||||
await promptForDirectoryIfNeeded();
|
|
||||||
|
|
||||||
// Wait for opencode to be ready (or timeout if no CLI)
|
|
||||||
const waitForOpencode = async (): Promise<void> => {
|
|
||||||
const maxAttempts = 50;
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
|
||||||
const info = await invoke<ServerInfo>('desktop_server_info');
|
|
||||||
// Ready if opencode running, or no CLI (will show onboarding)
|
|
||||||
if (!info.cli_available || info.opencode_port !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await waitForOpencode();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await import('@openchamber/ui/main');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[main] FATAL: Failed to load UI module:', error);
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
|
|
||||||
<h1>UI Module Load Failed</h1>
|
|
||||||
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
|
|
||||||
${error instanceof Error ? error.stack : String(error)}
|
|
||||||
</pre>
|
|
||||||
<p style="margin-top: 20px; color: #999;">Check DevTools console for details</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["../ui/src/*"],
|
|
||||||
"@desktop/*": ["./src/*"],
|
|
||||||
"@openchamber/ui/*": ["../ui/src/*"],
|
|
||||||
"@openchamber/desktop/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src", "../ui/src", "../ui/src/types/**/*"]
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { themeStoragePlugin } from '../../vite-theme-plugin';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
root: path.resolve(__dirname, '.'),
|
|
||||||
plugins: [react(), themeStoragePlugin()],
|
|
||||||
resolve: {
|
|
||||||
alias: [
|
|
||||||
{ find: '@opencode-ai/sdk/v2', replacement: path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/v2/client.js') },
|
|
||||||
{ find: '@openchamber/ui', replacement: path.resolve(__dirname, '../ui/src') },
|
|
||||||
{ find: '@desktop', replacement: path.resolve(__dirname, './src') },
|
|
||||||
{ find: '@', replacement: path.resolve(__dirname, '../ui/src') },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
worker: {
|
|
||||||
format: 'es',
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
'process.env': {},
|
|
||||||
'process.platform': JSON.stringify('darwin'),
|
|
||||||
'process.version': JSON.stringify('v20.0.0'),
|
|
||||||
'process.versions': JSON.stringify({}),
|
|
||||||
global: 'globalThis',
|
|
||||||
__APP_VERSION__: JSON.stringify(packageJson.version),
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
|
||||||
include: ['@opencode-ai/sdk/v2'],
|
|
||||||
exclude: [
|
|
||||||
'@tauri-apps/plugin-dialog',
|
|
||||||
'@tauri-apps/api/core',
|
|
||||||
'@tauri-apps/api/path',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 1421,
|
|
||||||
strictPort: true,
|
|
||||||
hmr: {
|
|
||||||
protocol: 'ws',
|
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 1421,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: path.resolve(__dirname, 'dist'),
|
|
||||||
emptyOutDir: true,
|
|
||||||
chunkSizeWarningLimit: 1200,
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks(id) {
|
|
||||||
if (!id.includes('node_modules')) return undefined;
|
|
||||||
|
|
||||||
const match = id.split('node_modules/')[1];
|
|
||||||
if (!match) return undefined;
|
|
||||||
|
|
||||||
const segments = match.split('/');
|
|
||||||
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0];
|
|
||||||
|
|
||||||
if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react';
|
|
||||||
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
|
|
||||||
|
|
||||||
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk';
|
|
||||||
if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown';
|
|
||||||
if (packageName.startsWith('@radix-ui')) return 'vendor-radix';
|
|
||||||
if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax';
|
|
||||||
if (packageName.startsWith('@tauri-apps')) return 'vendor-tauri';
|
|
||||||
|
|
||||||
const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-');
|
|
||||||
return `vendor-${sanitized}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+47
-12
@@ -16,6 +16,8 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
|||||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { hasModifier } from '@/lib/utils';
|
import { hasModifier } from '@/lib/utils';
|
||||||
|
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||||
|
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
@@ -25,8 +27,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
|||||||
import { AboutDialog } from '@/components/ui/AboutDialog';
|
import { AboutDialog } from '@/components/ui/AboutDialog';
|
||||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
|
||||||
import { isCliAvailable } from '@/lib/desktop';
|
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||||
@@ -53,17 +53,12 @@ function App({ apis }: AppProps) {
|
|||||||
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||||
const { uiFont, monoFont } = useFontPreferences();
|
const { uiFont, monoFont } = useFontPreferences();
|
||||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => apis.runtime.isDesktop);
|
|
||||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||||
const [cliAvailable, setCliAvailable] = React.useState<boolean>(() => {
|
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
|
||||||
if (!apis.runtime.isDesktop) return true;
|
|
||||||
return isCliAvailable();
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsDesktopRuntime(apis.runtime.isDesktop);
|
|
||||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||||
}, [apis.runtime.isDesktop, apis.runtime.isVSCode]);
|
}, [apis.runtime.isVSCode]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
registerRuntimeAPIs(apis);
|
registerRuntimeAPIs(apis);
|
||||||
@@ -175,6 +170,19 @@ function App({ apis }: AppProps) {
|
|||||||
|
|
||||||
useMenuActions(handleToggleMemoryDebug);
|
useMenuActions(handleToggleMemoryDebug);
|
||||||
|
|
||||||
|
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isTauriShell()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||||
|
if (typeof tauri?.core?.invoke !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tauri.core.invoke('desktop_set_auto_worktree_menu', { enabled: settingsAutoCreateWorktree });
|
||||||
|
}, [settingsAutoCreateWorktree]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useSessionStatusBootstrap();
|
useSessionStatusBootstrap();
|
||||||
@@ -199,15 +207,42 @@ function App({ apis }: AppProps) {
|
|||||||
}
|
}
|
||||||
}, [error, clearError]);
|
}, [error, clearError]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/health', { method: 'GET' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown };
|
||||||
|
if (!data || cancelled) return;
|
||||||
|
const openCodeRunning = data.openCodeRunning === true;
|
||||||
|
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
|
||||||
|
const cliMissing = !openCodeRunning && /ENOENT|spawn\s+opencode|opencode(\.exe)?\s+not\s+found|not\s+found/i.test(err);
|
||||||
|
setShowCliOnboarding(cliMissing);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCliAvailable = React.useCallback(() => {
|
const handleCliAvailable = React.useCallback(() => {
|
||||||
setCliAvailable(true);
|
setShowCliOnboarding(false);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isDesktopRuntime && !cliAvailable) {
|
if (showCliOnboarding) {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className={`h-full text-foreground bg-transparent`}>
|
<div className="h-full text-foreground bg-transparent">
|
||||||
<OnboardingScreen onCliAvailable={handleCliAvailable} />
|
<OnboardingScreen onCliAvailable={handleCliAvailable} />
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React from 'react';
|
|||||||
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
|
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||||
|
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
|
||||||
|
|
||||||
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
const STATUS_CHECK_ENDPOINT = '/auth/session';
|
||||||
|
|
||||||
@@ -119,9 +120,9 @@ const clearTokenFromUrl = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||||
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
|
||||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const skipAuth = desktopRuntime || vscodeRuntime;
|
const skipAuth = vscodeRuntime;
|
||||||
|
const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]);
|
||||||
const [state, setState] = React.useState<GateState>(() => (skipAuth ? 'authenticated' : 'pending'));
|
const [state, setState] = React.useState<GateState>(() => (skipAuth ? 'authenticated' : 'pending'));
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
@@ -349,6 +350,15 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{showHostSwitcher && (
|
||||||
|
<div className="w-full">
|
||||||
|
<DesktopHostSwitcherInline />
|
||||||
|
<p className="mt-1 text-center typography-micro text-muted-foreground">
|
||||||
|
Use Local if remote is unreachable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
isSyncing,
|
isSyncing,
|
||||||
messageStreamStates,
|
messageStreamStates,
|
||||||
trimToViewportWindow,
|
trimToViewportWindow,
|
||||||
sessionActivityPhase,
|
sessionStatus,
|
||||||
newSessionDraft,
|
newSessionDraft,
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
|
|
||||||
@@ -161,8 +161,8 @@ export const ChatContainer: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await loadMessages(currentSessionId);
|
await loadMessages(currentSessionId);
|
||||||
} finally {
|
} finally {
|
||||||
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
|
const statusType = sessionStatus?.get(currentSessionId)?.type ?? 'idle';
|
||||||
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
|
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
||||||
// When pinned and active, scroll is already maintained automatically
|
// When pinned and active, scroll is already maintained automatically
|
||||||
const shouldSkipScroll = isActivePhase && isPinned;
|
const shouldSkipScroll = isActivePhase && isPinned;
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
|
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionStatus]);
|
||||||
|
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
return (
|
return (
|
||||||
@@ -266,14 +266,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
style={{
|
observeMutations={false}
|
||||||
contain: 'strict',
|
|
||||||
['--scroll-shadow-size' as string]: '48px',
|
|
||||||
// GPU acceleration hints for smoother scrolling
|
|
||||||
transform: 'translateZ(0)',
|
|
||||||
willChange: 'scroll-position',
|
|
||||||
backfaceVisibility: 'hidden',
|
|
||||||
}}
|
|
||||||
data-scroll-shadow="true"
|
data-scroll-shadow="true"
|
||||||
data-scrollbar="chat"
|
data-scrollbar="chat"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -232,6 +232,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const canAbort = working.isWorking;
|
const canAbort = working.isWorking;
|
||||||
|
|
||||||
|
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
||||||
|
const handleSubmitRef = React.useRef<(e?: React.FormEvent) => Promise<void>>(async () => {});
|
||||||
|
|
||||||
// Add message to queue instead of sending
|
// Add message to queue instead of sending
|
||||||
const handleQueueMessage = React.useCallback(() => {
|
const handleQueueMessage = React.useCallback(() => {
|
||||||
if (!hasContent || !currentSessionId) return;
|
if (!hasContent || !currentSessionId) return;
|
||||||
@@ -448,23 +451,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleSubmitRef.current = handleSubmit;
|
||||||
|
|
||||||
// Primary action for send button - respects queue mode setting
|
// Primary action for send button - respects queue mode setting
|
||||||
const handlePrimaryAction = React.useCallback(() => {
|
const handlePrimaryAction = React.useCallback(() => {
|
||||||
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
|
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
|
||||||
if (queueModeEnabled && canQueue) {
|
if (queueModeEnabled && canQueue) {
|
||||||
handleQueueMessage();
|
handleQueueMessage();
|
||||||
} else {
|
} else {
|
||||||
void handleSubmit();
|
void handleSubmitRef.current();
|
||||||
}
|
}
|
||||||
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage, handleSubmit]);
|
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
|
||||||
|
|
||||||
// Keep a ref to handleSubmit for auto-send effect
|
|
||||||
const handleSubmitRef = React.useRef(handleSubmit);
|
|
||||||
handleSubmitRef.current = handleSubmit;
|
|
||||||
|
|
||||||
// Auto-send queued messages when session becomes idle (but not after abort)
|
// Auto-send queued messages when session becomes idle (but not after abort)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown';
|
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'retry';
|
||||||
const isNowIdle = sessionPhase === 'idle';
|
const isNowIdle = sessionPhase === 'idle';
|
||||||
|
|
||||||
// Check if session was recently aborted (within last 2 seconds)
|
// Check if session was recently aborted (within last 2 seconds)
|
||||||
@@ -1483,8 +1484,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
isWaitingForPermission={working.isWaitingForPermission}
|
isWaitingForPermission={working.isWaitingForPermission}
|
||||||
wasAborted={working.wasAborted}
|
wasAborted={working.wasAborted}
|
||||||
abortActive={working.abortActive}
|
abortActive={working.abortActive}
|
||||||
completionId={working.lastCompletionId}
|
|
||||||
isComplete={working.isComplete}
|
|
||||||
showAbortStatus={showAbortStatus}
|
showAbortStatus={showAbortStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
|||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||||
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
import { getAgentColor } from '@/lib/agentColors';
|
import { getAgentColor } from '@/lib/agentColors';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||||
@@ -346,7 +347,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const { favoriteModelsList, recentModelsList } = useModelLists();
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const isDesktopRuntime = useIsDesktopRuntime();
|
const isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||||
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
||||||
const isCompact = isMobile;
|
const isCompact = isMobile;
|
||||||
@@ -2405,7 +2406,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
'model-controls__variant-label',
|
'model-controls__variant-label',
|
||||||
controlTextSize,
|
controlTextSize,
|
||||||
'font-medium min-w-0 truncate',
|
'font-medium min-w-0 truncate',
|
||||||
isDesktopRuntime ? 'max-w-[180px]' : undefined,
|
isDesktop ? 'max-w-[180px]' : undefined,
|
||||||
colorClass,
|
colorClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -2473,7 +2474,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
'model-controls__agent-label',
|
'model-controls__agent-label',
|
||||||
controlTextSize,
|
controlTextSize,
|
||||||
'font-medium min-w-0 truncate',
|
'font-medium min-w-0 truncate',
|
||||||
isDesktopRuntime ? 'max-w-[220px]' : undefined
|
isDesktop ? 'max-w-[220px]' : undefined
|
||||||
)}
|
)}
|
||||||
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
|
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ interface StatusRowProps {
|
|||||||
isWaitingForPermission?: boolean;
|
isWaitingForPermission?: boolean;
|
||||||
wasAborted?: boolean;
|
wasAborted?: boolean;
|
||||||
abortActive?: boolean;
|
abortActive?: boolean;
|
||||||
completionId?: string | null;
|
|
||||||
isComplete?: boolean;
|
|
||||||
// Abort state (for mobile/vscode)
|
// Abort state (for mobile/vscode)
|
||||||
showAbort?: boolean;
|
showAbort?: boolean;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
@@ -69,8 +67,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
isWaitingForPermission,
|
isWaitingForPermission,
|
||||||
wasAborted,
|
wasAborted,
|
||||||
abortActive,
|
abortActive,
|
||||||
completionId,
|
|
||||||
isComplete,
|
|
||||||
showAbort,
|
showAbort,
|
||||||
onAbort,
|
onAbort,
|
||||||
showAbortStatus,
|
showAbortStatus,
|
||||||
@@ -125,16 +121,15 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
// Original logic from ChatInput
|
// Original logic from ChatInput
|
||||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||||
|
|
||||||
// Track if placeholder is showing result (done/aborted) to keep StatusRow mounted
|
|
||||||
const [placeholderShowingResult, setPlaceholderShowingResult] = React.useState(false);
|
|
||||||
|
|
||||||
// Keep StatusRow rendered while:
|
// Keep StatusRow rendered while:
|
||||||
// - isWorking (active session)
|
// - isWorking (active session)
|
||||||
// - isComplete (showing "Done" result)
|
// - wasAborted / showAbortStatus
|
||||||
// - wasAborted (showing "Aborted" result)
|
// - hasActiveTodos
|
||||||
// - placeholderShowingResult (placeholder still displaying result)
|
const hasContent =
|
||||||
// - hasActiveTodos or showAbortStatus
|
isWorking ||
|
||||||
const hasContent = isWorking || isComplete || wasAborted || placeholderShowingResult || hasActiveTodos || showAbortStatus;
|
Boolean(wasAborted) ||
|
||||||
|
Boolean(showAbortStatus) ||
|
||||||
|
hasActiveTodos;
|
||||||
|
|
||||||
// Close popover when clicking outside
|
// Close popover when clicking outside
|
||||||
const popoverRef = React.useRef<HTMLDivElement>(null);
|
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -212,13 +207,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
) : shouldRenderPlaceholder ? (
|
) : shouldRenderPlaceholder ? (
|
||||||
<WorkingPlaceholder
|
<WorkingPlaceholder
|
||||||
key={currentSessionId ?? "no-session"}
|
key={currentSessionId ?? "no-session"}
|
||||||
|
isWorking={isWorking}
|
||||||
statusText={statusText}
|
statusText={statusText}
|
||||||
isGenericStatus={isGenericStatus}
|
isGenericStatus={isGenericStatus}
|
||||||
isWaitingForPermission={isWaitingForPermission}
|
isWaitingForPermission={isWaitingForPermission}
|
||||||
wasAborted={wasAborted}
|
|
||||||
completionId={completionId ?? null}
|
|
||||||
isComplete={isComplete}
|
|
||||||
onResultVisibilityChange={setPlaceholderShowingResult}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,253 +1,131 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import React from 'react';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
|
|
||||||
interface WorkingPlaceholderProps {
|
interface WorkingPlaceholderProps {
|
||||||
|
isWorking: boolean;
|
||||||
statusText: string | null;
|
statusText: string | null;
|
||||||
isGenericStatus?: boolean;
|
isGenericStatus?: boolean;
|
||||||
isWaitingForPermission?: boolean;
|
isWaitingForPermission?: boolean;
|
||||||
wasAborted?: boolean;
|
|
||||||
completionId?: string | null;
|
|
||||||
isComplete?: boolean;
|
|
||||||
onResultVisibilityChange?: (isShowingResult: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_DISPLAY_TIME = 1500; // Minimum time to show each status
|
const STATUS_DISPLAY_TIME_MS = 1200;
|
||||||
const DONE_DISPLAY_TIME = 2000; // Time to show Done/Aborted status
|
|
||||||
|
|
||||||
type PlaceholderState = 'idle' | 'showing' | 'done' | 'aborted';
|
|
||||||
|
|
||||||
export function WorkingPlaceholder({
|
export function WorkingPlaceholder({
|
||||||
|
isWorking,
|
||||||
statusText,
|
statusText,
|
||||||
isGenericStatus,
|
isGenericStatus,
|
||||||
isWaitingForPermission,
|
isWaitingForPermission,
|
||||||
wasAborted,
|
|
||||||
completionId,
|
|
||||||
isComplete,
|
|
||||||
onResultVisibilityChange,
|
|
||||||
}: WorkingPlaceholderProps) {
|
}: WorkingPlaceholderProps) {
|
||||||
// Internal state machine
|
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
|
||||||
const [state, setState] = useState<PlaceholderState>('idle');
|
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
||||||
const [displayedText, setDisplayedText] = useState<string | null>(null);
|
|
||||||
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Refs for timing
|
const statusShownAtRef = React.useRef<number>(0);
|
||||||
const statusShownAtRef = useRef<number>(0);
|
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
|
||||||
const queuedStatusRef = useRef<{ text: string; permission: boolean } | null>(null);
|
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const processQueueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const doneTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const lastCompletionIdRef = useRef<string | null>(null);
|
|
||||||
// Track if we've ever shown activity in this turn
|
|
||||||
const hasShownActivityRef = useRef<boolean>(false);
|
|
||||||
// Track the previous isComplete value to detect edges
|
|
||||||
const prevIsCompleteRef = useRef<boolean>(false);
|
|
||||||
const prevWasAbortedRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
// Clear all timers
|
const clearTimers = React.useCallback(() => {
|
||||||
const clearTimers = useCallback(() => {
|
|
||||||
if (processQueueTimerRef.current) {
|
if (processQueueTimerRef.current) {
|
||||||
clearTimeout(processQueueTimerRef.current);
|
clearTimeout(processQueueTimerRef.current);
|
||||||
processQueueTimerRef.current = null;
|
processQueueTimerRef.current = null;
|
||||||
}
|
}
|
||||||
if (doneTimerRef.current) {
|
|
||||||
clearTimeout(doneTimerRef.current);
|
|
||||||
doneTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show a status immediately
|
const showStatus = React.useCallback((text: string, permission: boolean) => {
|
||||||
const showStatus = useCallback((text: string, permission: boolean) => {
|
|
||||||
clearTimers();
|
clearTimers();
|
||||||
queuedStatusRef.current = null;
|
queuedStatusRef.current = null;
|
||||||
setDisplayedText(text);
|
setDisplayedText(text);
|
||||||
setDisplayedPermission(permission);
|
setDisplayedPermission(permission);
|
||||||
setState('showing');
|
|
||||||
statusShownAtRef.current = Date.now();
|
statusShownAtRef.current = Date.now();
|
||||||
hasShownActivityRef.current = true;
|
|
||||||
}, [clearTimers]);
|
}, [clearTimers]);
|
||||||
|
|
||||||
// Schedule processing of queued status
|
const scheduleQueueProcess = React.useCallback(() => {
|
||||||
const scheduleQueueProcess = useCallback(() => {
|
if (processQueueTimerRef.current) return;
|
||||||
if (processQueueTimerRef.current) return; // Already scheduled
|
|
||||||
|
|
||||||
const elapsed = Date.now() - statusShownAtRef.current;
|
const elapsed = Date.now() - statusShownAtRef.current;
|
||||||
const remaining = Math.max(0, STATUS_DISPLAY_TIME - elapsed);
|
const remaining = Math.max(0, STATUS_DISPLAY_TIME_MS - elapsed);
|
||||||
|
|
||||||
processQueueTimerRef.current = setTimeout(() => {
|
processQueueTimerRef.current = setTimeout(() => {
|
||||||
processQueueTimerRef.current = null;
|
processQueueTimerRef.current = null;
|
||||||
|
|
||||||
const queued = queuedStatusRef.current;
|
const queued = queuedStatusRef.current;
|
||||||
if (queued) {
|
if (queued) {
|
||||||
showStatus(queued.text, queued.permission);
|
showStatus(queued.text, queued.permission);
|
||||||
}
|
}
|
||||||
// If nothing queued, keep showing current status
|
|
||||||
}, remaining);
|
}, remaining);
|
||||||
}, [showStatus]);
|
}, [showStatus]);
|
||||||
|
|
||||||
// Show done/aborted result
|
React.useEffect(() => {
|
||||||
const showResult = useCallback((result: 'done' | 'aborted') => {
|
if (!isWorking) {
|
||||||
clearTimers();
|
clearTimers();
|
||||||
queuedStatusRef.current = null;
|
queuedStatusRef.current = null;
|
||||||
|
|
||||||
// Only show result if we had activity
|
|
||||||
if (!hasShownActivityRef.current) {
|
|
||||||
setState('idle');
|
|
||||||
setDisplayedText(null);
|
setDisplayedText(null);
|
||||||
onResultVisibilityChange?.(false);
|
setDisplayedPermission(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip duplicate completion for same completionId
|
const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText;
|
||||||
if (result === 'done' && completionId && lastCompletionIdRef.current === completionId) {
|
const incomingPermission = Boolean(isWaitingForPermission);
|
||||||
setState('idle');
|
const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission;
|
||||||
setDisplayedText(null);
|
|
||||||
hasShownActivityRef.current = false;
|
if (!incomingText) {
|
||||||
onResultVisibilityChange?.(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === 'done' && completionId) {
|
if (!displayedText) {
|
||||||
lastCompletionIdRef.current = completionId;
|
showStatus(incomingText, incomingPermission);
|
||||||
}
|
|
||||||
|
|
||||||
setState(result);
|
|
||||||
setDisplayedText(null);
|
|
||||||
onResultVisibilityChange?.(true);
|
|
||||||
|
|
||||||
// Auto-hide after DONE_DISPLAY_TIME
|
|
||||||
doneTimerRef.current = setTimeout(() => {
|
|
||||||
doneTimerRef.current = null;
|
|
||||||
setState('idle');
|
|
||||||
hasShownActivityRef.current = false;
|
|
||||||
onResultVisibilityChange?.(false);
|
|
||||||
}, DONE_DISPLAY_TIME);
|
|
||||||
}, [clearTimers, completionId, onResultVisibilityChange]);
|
|
||||||
|
|
||||||
// Main effect: handle prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
// Detect abort edge (false -> true)
|
|
||||||
if (wasAborted && !prevWasAbortedRef.current) {
|
|
||||||
prevWasAbortedRef.current = true;
|
|
||||||
showResult('aborted');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prevWasAbortedRef.current = !!wasAborted;
|
|
||||||
|
|
||||||
// Detect completion edge (false -> true)
|
|
||||||
if (isComplete && !prevIsCompleteRef.current) {
|
|
||||||
prevIsCompleteRef.current = true;
|
|
||||||
showResult('done');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Reset edge detection when isComplete goes back to false
|
|
||||||
if (!isComplete && prevIsCompleteRef.current) {
|
|
||||||
prevIsCompleteRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're showing done/aborted, don't process new status
|
|
||||||
if (state === 'done' || state === 'aborted') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new status text
|
if (incomingText === displayedText && incomingPermission === displayedPermission) {
|
||||||
if (statusText) {
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - statusShownAtRef.current;
|
|
||||||
|
|
||||||
if (state === 'idle' || !displayedText) {
|
|
||||||
// Not showing anything - show immediately (generic OK at turn start)
|
|
||||||
showStatus(statusText, !!isWaitingForPermission);
|
|
||||||
} else if (statusText !== displayedText || !!isWaitingForPermission !== displayedPermission) {
|
|
||||||
// Already showing something - ignore generic statuses
|
|
||||||
if (isGenericStatus) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different specific status
|
// Ignore generic churn.
|
||||||
if (elapsed >= STATUS_DISPLAY_TIME) {
|
if (incomingGeneric) {
|
||||||
// Minimum time passed - show immediately
|
return;
|
||||||
showStatus(statusText, !!isWaitingForPermission);
|
}
|
||||||
} else {
|
|
||||||
// Queue the latest (overwrites previous queued)
|
const elapsed = Date.now() - statusShownAtRef.current;
|
||||||
queuedStatusRef.current = { text: statusText, permission: !!isWaitingForPermission };
|
if (elapsed >= STATUS_DISPLAY_TIME_MS) {
|
||||||
|
showStatus(incomingText, incomingPermission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedStatusRef.current = { text: incomingText, permission: incomingPermission };
|
||||||
scheduleQueueProcess();
|
scheduleQueueProcess();
|
||||||
}
|
}, [
|
||||||
}
|
isWorking,
|
||||||
// Same status - keep showing
|
statusText,
|
||||||
}
|
isGenericStatus,
|
||||||
// IMPORTANT: When statusText becomes null, we do NOT clear the display
|
isWaitingForPermission,
|
||||||
// Only done/abort signals clear the display
|
displayedText,
|
||||||
|
displayedPermission,
|
||||||
|
clearTimers,
|
||||||
|
showStatus,
|
||||||
|
scheduleQueueProcess,
|
||||||
|
]);
|
||||||
|
|
||||||
}, [statusText, isWaitingForPermission, wasAborted, isComplete, state, displayedText, displayedPermission, showStatus, showResult, scheduleQueueProcess, isGenericStatus]);
|
React.useEffect(() => () => clearTimers(), [clearTimers]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
if (!isWorking || !displayedText) {
|
||||||
useEffect(() => {
|
|
||||||
return () => clearTimers();
|
|
||||||
}, [clearTimers]);
|
|
||||||
|
|
||||||
// Handle tab visibility changes
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
if (document.visibilityState !== 'visible') return;
|
|
||||||
|
|
||||||
// If showing done/aborted when user returns, hide it
|
|
||||||
if (state === 'done' || state === 'aborted') {
|
|
||||||
clearTimers();
|
|
||||||
setState('idle');
|
|
||||||
setDisplayedText(null);
|
|
||||||
hasShownActivityRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
}, [state, clearTimers]);
|
|
||||||
|
|
||||||
// Render nothing if idle with no text
|
|
||||||
if (state === 'idle' && !displayedText) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine what to show
|
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
|
||||||
let label: string;
|
const displayText = `${label}...`;
|
||||||
let showEllipsis = true;
|
|
||||||
|
|
||||||
if (state === 'done') {
|
|
||||||
label = 'Done';
|
|
||||||
showEllipsis = false;
|
|
||||||
} else if (state === 'aborted') {
|
|
||||||
label = 'Aborted';
|
|
||||||
showEllipsis = false;
|
|
||||||
} else if (displayedText) {
|
|
||||||
label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
|
|
||||||
} else {
|
|
||||||
label = 'Working';
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayText = showEllipsis ? `${label}...` : label;
|
|
||||||
const isVisible = state !== 'idle';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
|
className={
|
||||||
|
'flex h-full items-center text-muted-foreground pl-[2ch]'
|
||||||
|
}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live={displayedPermission ? 'assertive' : 'polite'}
|
aria-live={displayedPermission ? 'assertive' : 'polite'}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-waiting={displayedPermission ? 'true' : undefined}
|
data-waiting={displayedPermission ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
{state === 'done' ? (
|
|
||||||
<Text variant="hover-enter" className="typography-ui-header">
|
|
||||||
Done
|
|
||||||
</Text>
|
|
||||||
) : state === 'aborted' ? (
|
|
||||||
<Text variant="hover-enter" className="typography-ui-header text-status-error">
|
|
||||||
Aborted
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text variant="shine" className="typography-ui-header">
|
<Text variant="shine" className="typography-ui-header">
|
||||||
{displayText}
|
{displayText}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,704 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloudOffLine,
|
||||||
|
RiEarthLine,
|
||||||
|
RiLoader4Line,
|
||||||
|
RiMore2Line,
|
||||||
|
RiPencilLine,
|
||||||
|
RiRefreshLine,
|
||||||
|
RiServerLine,
|
||||||
|
RiShieldKeyholeLine,
|
||||||
|
RiStarFill,
|
||||||
|
RiStarLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
} from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
|
||||||
|
import {
|
||||||
|
desktopHostProbe,
|
||||||
|
desktopHostsGet,
|
||||||
|
desktopHostsSet,
|
||||||
|
type DesktopHost,
|
||||||
|
type HostProbeResult,
|
||||||
|
} from '@/lib/desktopHosts';
|
||||||
|
|
||||||
|
const LOCAL_HOST_ID = 'local';
|
||||||
|
|
||||||
|
type HostStatus = {
|
||||||
|
status: HostProbeResult['status'];
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeHostUrl = (raw: string): string | null => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
} catch {
|
||||||
|
// Tauri/WebKit edge: accept origin without trailing slash.
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNavigationUrl = (origin: string): string => {
|
||||||
|
const trimmed = origin.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalOrigin = (): string => {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeId = (): string => {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
|
||||||
|
if (status === 'ok') return 'bg-status-success';
|
||||||
|
if (status === 'auth') return 'bg-status-warning';
|
||||||
|
if (status === 'unreachable') return 'bg-status-error';
|
||||||
|
return 'bg-muted-foreground/40';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: HostProbeResult['status'] | null): string => {
|
||||||
|
if (status === 'ok') return 'Connected';
|
||||||
|
if (status === 'auth') return 'Auth required';
|
||||||
|
if (status === 'unreachable') return 'Unreachable';
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcon = (status: HostProbeResult['status'] | null) => {
|
||||||
|
if (status === 'ok') return <RiCheckLine className="h-4 w-4" />;
|
||||||
|
if (status === 'auth') return <RiShieldKeyholeLine className="h-4 w-4" />;
|
||||||
|
if (status === 'unreachable') return <RiCloudOffLine className="h-4 w-4" />;
|
||||||
|
return <RiEarthLine className="h-4 w-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLocalHost = (): DesktopHost => ({
|
||||||
|
id: LOCAL_HOST_ID,
|
||||||
|
label: 'Local',
|
||||||
|
url: getLocalOrigin(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
||||||
|
const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin;
|
||||||
|
const localOrigin = getLocalOrigin();
|
||||||
|
const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin;
|
||||||
|
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
|
||||||
|
|
||||||
|
if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) {
|
||||||
|
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = hosts.find((h) => {
|
||||||
|
const normalized = normalizeHostUrl(h.url);
|
||||||
|
return normalized && normalized === normalizedCurrent;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'custom',
|
||||||
|
label: normalizedCurrent || 'Instance',
|
||||||
|
url: normalizedCurrent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DesktopHostSwitcherDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) {
|
||||||
|
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
||||||
|
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
||||||
|
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [isProbing, setIsProbing] = React.useState(false);
|
||||||
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = React.useState('');
|
||||||
|
const [editUrl, setEditUrl] = React.useState('');
|
||||||
|
|
||||||
|
const [newLabel, setNewLabel] = React.useState('');
|
||||||
|
const [newUrl, setNewUrl] = React.useState('');
|
||||||
|
|
||||||
|
const allHosts = React.useMemo(() => {
|
||||||
|
const local = buildLocalHost();
|
||||||
|
const normalizedRemote = configHosts.map((h) => ({
|
||||||
|
...h,
|
||||||
|
url: normalizeHostUrl(h.url) || h.url,
|
||||||
|
}));
|
||||||
|
return [local, ...normalizedRemote];
|
||||||
|
}, [configHosts]);
|
||||||
|
|
||||||
|
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
|
||||||
|
const currentDefaultLabel = React.useMemo(() => {
|
||||||
|
const id = defaultHostId || LOCAL_HOST_ID;
|
||||||
|
return allHosts.find((h) => h.id === id)?.label || 'Local';
|
||||||
|
}, [allHosts, defaultHostId]);
|
||||||
|
|
||||||
|
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
|
||||||
|
if (!isTauriShell()) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// Persist only remote hosts; Local is derived.
|
||||||
|
const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID);
|
||||||
|
await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId });
|
||||||
|
setConfigHosts(remote);
|
||||||
|
setDefaultHostId(nextDefaultHostId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = React.useCallback(async () => {
|
||||||
|
if (!isTauriShell()) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const cfg = await desktopHostsGet();
|
||||||
|
setConfigHosts(cfg.hosts || []);
|
||||||
|
setDefaultHostId(cfg.defaultHostId ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||||
|
setConfigHosts([]);
|
||||||
|
setDefaultHostId(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
|
||||||
|
if (!isTauriShell()) return;
|
||||||
|
setIsProbing(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
hosts.map(async (h) => {
|
||||||
|
const url = normalizeHostUrl(h.url);
|
||||||
|
if (!url) {
|
||||||
|
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
|
||||||
|
}
|
||||||
|
const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||||
|
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const next: Record<string, HostStatus> = {};
|
||||||
|
for (const [id, val] of results) {
|
||||||
|
next[id] = val;
|
||||||
|
}
|
||||||
|
setStatusById(next);
|
||||||
|
} finally {
|
||||||
|
setIsProbing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditLabel('');
|
||||||
|
setEditUrl('');
|
||||||
|
setNewLabel('');
|
||||||
|
setNewUrl('');
|
||||||
|
setError('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refresh();
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void probeAll(allHosts);
|
||||||
|
}, [open, allHosts, probeAll]);
|
||||||
|
|
||||||
|
const handleSwitch = React.useCallback((host: DesktopHost) => {
|
||||||
|
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
|
||||||
|
if (!origin) return;
|
||||||
|
const target = toNavigationUrl(origin);
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.location.assign(target);
|
||||||
|
} catch {
|
||||||
|
window.location.href = target;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const beginEdit = React.useCallback((host: DesktopHost) => {
|
||||||
|
setEditingId(host.id);
|
||||||
|
setEditLabel(host.label);
|
||||||
|
setEditUrl(host.url);
|
||||||
|
setError('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelEdit = React.useCallback(() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditLabel('');
|
||||||
|
setEditUrl('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commitEdit = React.useCallback(async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
if (editingId === LOCAL_HOST_ID) {
|
||||||
|
cancelEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = normalizeHostUrl(editUrl);
|
||||||
|
if (!url) {
|
||||||
|
setError('Invalid URL (must be http/https)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (editLabel || url).trim();
|
||||||
|
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
|
||||||
|
await persist(nextHosts, defaultHostId);
|
||||||
|
cancelEdit();
|
||||||
|
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
|
||||||
|
|
||||||
|
const addHost = React.useCallback(async () => {
|
||||||
|
const url = normalizeHostUrl(newUrl);
|
||||||
|
if (!url) {
|
||||||
|
setError('Invalid URL (must be http/https)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = (newLabel || url).trim();
|
||||||
|
const id = makeId();
|
||||||
|
|
||||||
|
const nextHosts = [{ id, label, url }, ...configHosts];
|
||||||
|
await persist(nextHosts, defaultHostId);
|
||||||
|
setNewLabel('');
|
||||||
|
setNewUrl('');
|
||||||
|
}, [configHosts, defaultHostId, newLabel, newUrl, persist]);
|
||||||
|
|
||||||
|
const deleteHost = React.useCallback(async (id: string) => {
|
||||||
|
if (id === LOCAL_HOST_ID) return;
|
||||||
|
const nextHosts = configHosts.filter((h) => h.id !== id);
|
||||||
|
const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
|
||||||
|
await persist(nextHosts, nextDefault);
|
||||||
|
}, [configHosts, defaultHostId, persist]);
|
||||||
|
|
||||||
|
const setDefault = React.useCallback(async (id: string) => {
|
||||||
|
const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
|
||||||
|
await persist(configHosts, next);
|
||||||
|
}, [configHosts, persist]);
|
||||||
|
|
||||||
|
if (!isDesktopShell()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriAvailable = isTauriShell();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RiServerLine className="h-5 w-5" />
|
||||||
|
Instance
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Switch between Local and remote OpenChamber servers
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="typography-meta text-muted-foreground">Current:</span>
|
||||||
|
<span className="typography-ui-label text-foreground truncate">{current.label}</span>
|
||||||
|
<span className="typography-meta text-muted-foreground">Current default:</span>
|
||||||
|
<span className="typography-ui-label text-foreground truncate">{currentDefaultLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void probeAll(allHosts)}
|
||||||
|
disabled={!tauriAvailable || isLoading || isProbing}
|
||||||
|
>
|
||||||
|
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!tauriAvailable && (
|
||||||
|
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
Instance switcher is limited on this page. Use Local to recover.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-2 py-2 text-muted-foreground text-sm">Loading…</div>
|
||||||
|
) : (
|
||||||
|
allHosts.map((host) => {
|
||||||
|
const isLocal = host.id === LOCAL_HOST_ID;
|
||||||
|
const isActive = host.id === current.id;
|
||||||
|
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
|
||||||
|
const status = statusById[host.id] || null;
|
||||||
|
const isEditing = editingId === host.id;
|
||||||
|
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={host.id}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 px-2.5 py-2 rounded-md overflow-hidden',
|
||||||
|
isEditing ? 'bg-interactive-hover/20' : 'hover:bg-interactive-hover/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 flex-1 min-w-0 text-left',
|
||||||
|
isEditing && 'pointer-events-none opacity-70'
|
||||||
|
)}
|
||||||
|
onClick={() => handleSwitch(host)}
|
||||||
|
aria-label={`Switch to ${host.label}`}
|
||||||
|
>
|
||||||
|
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
|
||||||
|
{host.label}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="typography-micro text-muted-foreground">Current</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
|
||||||
|
{statusIcon(status?.status ?? null)}
|
||||||
|
<span>
|
||||||
|
{statusLabel(status?.status ?? null)}
|
||||||
|
{status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="typography-micro text-muted-foreground truncate font-mono">
|
||||||
|
{effectiveUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Tooltip delayDuration={700}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
|
||||||
|
isDefault
|
||||||
|
? 'text-primary hover:text-primary/80'
|
||||||
|
: 'text-muted-foreground/60 hover:text-primary/80',
|
||||||
|
)}
|
||||||
|
onClick={() => void setDefault(host.id)}
|
||||||
|
aria-label={isDefault ? 'Default instance' : 'Set as default'}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={6}>
|
||||||
|
{isDefault ? 'Default' : 'Set as default'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{!isLocal && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
|
||||||
|
aria-label="Instance actions"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<RiMore2Line className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-fit min-w-28">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
beginEdit(host);
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<RiPencilLine className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void deleteHost(host.id);
|
||||||
|
}}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLocal && (
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 opacity-0 pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
|
||||||
|
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="typography-ui-label font-medium text-foreground">Edit instance</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={cancelEdit} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={() => void commitEdit()} disabled={isSaving}>
|
||||||
|
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
placeholder="Label"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={editUrl}
|
||||||
|
onChange={(e) => setEditUrl(e.target.value)}
|
||||||
|
placeholder="https://host:port"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void addHost()}
|
||||||
|
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
|
||||||
|
>
|
||||||
|
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
disabled={!tauriAvailable || isSaving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={newUrl}
|
||||||
|
onChange={(e) => setNewUrl(e.target.value)}
|
||||||
|
placeholder="https://host:port"
|
||||||
|
disabled={!tauriAvailable || isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DesktopHostSwitcherButtonProps = {
|
||||||
|
headerIconButtonClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [label, setLabel] = React.useState('Local');
|
||||||
|
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isTauriShell()) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await desktopHostsGet();
|
||||||
|
const local = buildLocalHost();
|
||||||
|
const all = [local, ...(cfg.hosts || [])];
|
||||||
|
const current = resolveCurrentHost(all);
|
||||||
|
if (cancelled) return;
|
||||||
|
setLabel(current.label || 'Instance');
|
||||||
|
const normalized = normalizeHostUrl(current.url);
|
||||||
|
if (!normalized) {
|
||||||
|
setStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus(res.status);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLabel('Instance');
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void run();
|
||||||
|
}, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isDesktopShell()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentlyLocal = (() => {
|
||||||
|
try {
|
||||||
|
const current = normalizeHostUrl(window.location.origin);
|
||||||
|
const local = normalizeHostUrl(getLocalOrigin());
|
||||||
|
return Boolean(current && local && current === local);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Fallback label when Tauri IPC is temporarily unavailable.
|
||||||
|
const fallbackLabel = (() => {
|
||||||
|
try {
|
||||||
|
const host = typeof window !== 'undefined' ? window.location.hostname : '';
|
||||||
|
return host ? host : 'Instance';
|
||||||
|
} catch {
|
||||||
|
return 'Instance';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const effectiveLabel = isCurrentlyLocal
|
||||||
|
? 'Local'
|
||||||
|
: label === 'Local'
|
||||||
|
? fallbackLabel
|
||||||
|
: label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip delayDuration={500}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="Switch instance"
|
||||||
|
data-oc-host-switcher
|
||||||
|
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
|
||||||
|
>
|
||||||
|
<RiServerLine className="h-5 w-5" />
|
||||||
|
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
|
||||||
|
{effectiveLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
|
||||||
|
statusDotClass(status)
|
||||||
|
)}
|
||||||
|
aria-label="Instance status"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Instance</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopHostSwitcherInline() {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
if (!isDesktopShell()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-oc-host-switcher
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<RiServerLine className="h-4 w-4" />
|
||||||
|
Switch instance
|
||||||
|
</Button>
|
||||||
|
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
|||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import type { UsageWindow } from '@/types';
|
import type { UsageWindow } from '@/types';
|
||||||
import type { GitHubAuthStatus } from '@/lib/api/types';
|
import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||||
|
import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher';
|
||||||
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
|
|
||||||
const formatTime = (timestamp: number | null) => {
|
const formatTime = (timestamp: number | null) => {
|
||||||
if (!timestamp) return '-';
|
if (!timestamp) return '-';
|
||||||
@@ -124,7 +126,7 @@ export const Header: React.FC = () => {
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
return isDesktopShell();
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMacPlatform = React.useMemo(() => {
|
const isMacPlatform = React.useMemo(() => {
|
||||||
@@ -138,11 +140,12 @@ export const Header: React.FC = () => {
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Use Tauri-provided version if available (accurate), otherwise fall back to UA parsing
|
|
||||||
const desktopApi = (window as typeof window & { opencodeDesktop?: { macosMajorVersion?: number | null } }).opencodeDesktop;
|
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
|
||||||
if (desktopApi?.macosMajorVersion != null) {
|
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
|
||||||
return desktopApi.macosMajorVersion;
|
return injected;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix
|
// Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix
|
||||||
if (typeof navigator === 'undefined') {
|
if (typeof navigator === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
@@ -163,8 +166,7 @@ export const Header: React.FC = () => {
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
setIsDesktopApp(isDesktopShell());
|
||||||
setIsDesktopApp(detected);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentModel = getCurrentModel();
|
const currentModel = getCurrentModel();
|
||||||
@@ -625,6 +627,9 @@ export const Header: React.FC = () => {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="flex items-center gap-1 pr-3">
|
<div className="flex items-center gap-1 pr-3">
|
||||||
|
{isDesktopApp && (
|
||||||
|
<DesktopHostSwitcherButton headerIconButtonClass={headerIconButtonClass} />
|
||||||
|
)}
|
||||||
<Tooltip delayDuration={500}>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -770,7 +775,6 @@ export const Header: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
|
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
|
||||||
|
|
||||||
<Tooltip delayDuration={500}>
|
<Tooltip delayDuration={500}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar';
|
|||||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||||
import { CommandPalette } from '../ui/CommandPalette';
|
import { CommandPalette } from '../ui/CommandPalette';
|
||||||
import { HelpDialog } from '../ui/HelpDialog';
|
import { HelpDialog } from '../ui/HelpDialog';
|
||||||
|
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
|
||||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||||
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
||||||
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||||
@@ -313,6 +314,7 @@ export const MainLayout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<HelpDialog />
|
<HelpDialog />
|
||||||
|
<OpenCodeStatusDialog />
|
||||||
<SessionDialogs />
|
<SessionDialogs />
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -45,8 +45,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
setIsDesktopApp(Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__));
|
||||||
setIsDesktopApp(detected);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -55,9 +54,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuUpdateCheck = () => {
|
const handleMenuUpdateCheck = () => {
|
||||||
const hasDesktopApi =
|
if (!(window as unknown as { __TAURI__?: unknown }).__TAURI__) {
|
||||||
typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
|
||||||
if (!hasDesktopApi) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingMenuUpdateCheckRef.current = true;
|
pendingMenuUpdateCheckRef.current = true;
|
||||||
|
|||||||
@@ -169,6 +169,18 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
if (!configInitialized) {
|
if (!configInitialized) {
|
||||||
await initializeConfig();
|
await initializeConfig();
|
||||||
}
|
}
|
||||||
|
const configStore = useConfigStore.getState();
|
||||||
|
|
||||||
|
// Keep trying to fetch core datasets on cold starts.
|
||||||
|
if (configStore.isConnected) {
|
||||||
|
if (configStore.providers.length === 0) {
|
||||||
|
await configStore.loadProviders();
|
||||||
|
}
|
||||||
|
if (configStore.agents.length === 0) {
|
||||||
|
await configStore.loadAgents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configState = useConfigStore.getState();
|
const configState = useConfigStore.getState();
|
||||||
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
|
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
|
||||||
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
|
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multi
|
|||||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
|
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
|
||||||
import { BranchSelector, useBranchOptions } from './BranchSelector';
|
import { BranchSelector, useBranchOptions } from './BranchSelector';
|
||||||
import { AgentSelector } from './AgentSelector';
|
import { AgentSelector } from './AgentSelector';
|
||||||
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
|
|
||||||
/** Max file size in bytes (10MB) */
|
/** Max file size in bytes (10MB) */
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
@@ -95,7 +96,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
return isDesktopShell();
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMacPlatform = React.useMemo(() => {
|
const isMacPlatform = React.useMemo(() => {
|
||||||
@@ -109,8 +110,33 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
setIsDesktopApp(isDesktopShell());
|
||||||
setIsDesktopApp(detected);
|
}, []);
|
||||||
|
|
||||||
|
const macosMajorVersion = React.useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
|
||||||
|
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
|
||||||
|
return injected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._](\d+)/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const first = Number.parseInt(match[1], 10);
|
||||||
|
const second = Number.parseInt(match[2], 10);
|
||||||
|
if (Number.isNaN(first)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return first === 10 ? second : first;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const desktopHeaderPaddingClass = React.useMemo(() => {
|
const desktopHeaderPaddingClass = React.useMemo(() => {
|
||||||
@@ -121,6 +147,19 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
return 'pl-3';
|
return 'pl-3';
|
||||||
}, [isDesktopApp, isMacPlatform]);
|
}, [isDesktopApp, isMacPlatform]);
|
||||||
|
|
||||||
|
const macosHeaderSizeClass = React.useMemo(() => {
|
||||||
|
if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (macosMajorVersion >= 26) {
|
||||||
|
return 'h-12';
|
||||||
|
}
|
||||||
|
if (macosMajorVersion <= 15) {
|
||||||
|
return 'h-14';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
|
||||||
|
|
||||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
|
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
|
||||||
return;
|
return;
|
||||||
@@ -321,7 +360,8 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-12 items-center justify-center border-b app-region-drag select-none',
|
'relative flex h-12 items-center justify-center border-b app-region-drag select-none',
|
||||||
desktopHeaderPaddingClass
|
desktopHeaderPaddingClass,
|
||||||
|
macosHeaderSizeClass,
|
||||||
)}
|
)}
|
||||||
style={{ borderColor: 'var(--interactive-border)' }}
|
style={{ borderColor: 'var(--interactive-border)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
|
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
|
||||||
|
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||||
|
|
||||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
@@ -35,6 +36,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
|||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
const [showHint, setShowHint] = React.useState(false);
|
const [showHint, setShowHint] = React.useState(false);
|
||||||
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
||||||
|
const [isRetrying, setIsRetrying] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
|
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
|
||||||
@@ -42,7 +44,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined');
|
setIsDesktopApp(isDesktopShell());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||||
@@ -50,7 +52,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (isDesktopApp) {
|
if (isDesktopApp && isTauriShell()) {
|
||||||
try {
|
try {
|
||||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
const window = getCurrentWindow();
|
const window = getCurrentWindow();
|
||||||
@@ -66,12 +68,21 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
|||||||
const response = await fetch('/health');
|
const response = await fetch('/health');
|
||||||
if (!response.ok) return false;
|
if (!response.ok) return false;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.cliAvailable === true;
|
return data.openCodeRunning === true || data.isOpenCodeReady === true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = React.useCallback(async () => {
|
||||||
|
setIsRetrying(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/config/reload', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => setIsRetrying(false), 1000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = React.useCallback(async () => {
|
const handleCopy = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||||
@@ -146,6 +157,17 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
|||||||
<p className="text-sm text-muted-foreground animate-pulse">
|
<p className="text-sm text-muted-foreground animate-pulse">
|
||||||
Waiting for OpenCode installation...
|
Waiting for OpenCode installation...
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{isRetrying ? 'Retrying…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showHint && (
|
{showHint && (
|
||||||
|
|||||||
@@ -116,27 +116,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
const { setSidebarOpen } = useUIStore();
|
const { setSidebarOpen } = useUIStore();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadAgents();
|
loadAgents();
|
||||||
}, [loadAgents]);
|
}, [loadAgents]);
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
// Generate unique name
|
// Generate unique name
|
||||||
|
|||||||
@@ -46,27 +46,13 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
|||||||
const { setSidebarOpen } = useUIStore();
|
const { setSidebarOpen } = useUIStore();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadCommands();
|
loadCommands();
|
||||||
}, [loadCommands]);
|
}, [loadCommands]);
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
// Generate unique name
|
// Generate unique name
|
||||||
|
|||||||
@@ -67,18 +67,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
const { setSidebarOpen } = useUIStore();
|
const { setSidebarOpen } = useUIStore();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const unimportedCredentials = getUnimportedCredentials();
|
const unimportedCredentials = getUnimportedCredentials();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -98,11 +88,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
const handleCreateProfile = () => {
|
const handleCreateProfile = () => {
|
||||||
setSelectedProfile('new');
|
setSelectedProfile('new');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { AgentSelector } from '@/components/sections/commands/AgentSelector';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { getModifierLabel } from '@/lib/utils';
|
import { getModifierLabel } from '@/lib/utils';
|
||||||
@@ -67,11 +67,8 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } | null = null;
|
let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } | null = null;
|
||||||
|
|
||||||
// 1. Desktop runtime (Tauri)
|
// 1. Runtime settings API (VSCode)
|
||||||
if (isDesktopRuntime()) {
|
if (!data) {
|
||||||
data = await getDesktopSettings();
|
|
||||||
} else {
|
|
||||||
// 2. Runtime settings API (VSCode)
|
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
try {
|
try {
|
||||||
@@ -85,11 +82,12 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to fetch
|
// fall through
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch API (Web)
|
// 2. Fetch API (Web/server)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
const response = await fetch('/api/config/settings', {
|
const response = await fetch('/api/config/settings', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -99,7 +97,6 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
data = await response.json();
|
data = await response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const model = typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0 ? data.defaultModel.trim() : undefined;
|
const model = typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0 ? data.defaultModel.trim() : undefined;
|
||||||
@@ -153,7 +150,7 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
defaultVariant: '',
|
defaultVariant: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isDesktopRuntime()) {
|
{
|
||||||
const response = await fetch('/api/config/settings', {
|
const response = await fetch('/api/config/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -41,13 +41,12 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
|
||||||
if (desktop?.openExternal) {
|
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
||||||
|
if (tauri?.shell?.open) {
|
||||||
try {
|
try {
|
||||||
const result = await desktop.openExternal(url);
|
await tauri.shell.open(url);
|
||||||
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { RiInformationLine } from '@remixicon/react';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
|
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
@@ -22,11 +21,8 @@ export const GitSettings: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
let data: { gitmojiEnabled?: boolean } | null = null;
|
let data: { gitmojiEnabled?: boolean } | null = null;
|
||||||
|
|
||||||
// 1. Desktop runtime (Tauri)
|
// 1. Runtime settings API (VSCode)
|
||||||
if (isDesktopRuntime()) {
|
if (!data) {
|
||||||
data = await getDesktopSettings();
|
|
||||||
} else {
|
|
||||||
// 2. Runtime settings API (VSCode)
|
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
try {
|
try {
|
||||||
@@ -40,11 +36,12 @@ export const GitSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to fetch
|
// fall through
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch API (Web)
|
// 2. Fetch API (Web/server)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
const response = await fetch('/api/config/settings', {
|
const response = await fetch('/api/config/settings', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -54,7 +51,6 @@ export const GitSettings: React.FC = () => {
|
|||||||
data = await response.json();
|
data = await response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
if (typeof data.gitmojiEnabled === 'boolean') {
|
if (typeof data.gitmojiEnabled === 'boolean') {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
|
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes';
|
import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes';
|
||||||
|
|
||||||
@@ -34,11 +33,8 @@ export const MemoryLimitsSettings: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null;
|
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null;
|
||||||
|
|
||||||
// 1. Desktop runtime (Tauri)
|
// 1. Runtime settings API (VSCode)
|
||||||
if (isDesktopRuntime()) {
|
if (!data) {
|
||||||
data = await getDesktopSettings();
|
|
||||||
} else {
|
|
||||||
// 2. Runtime settings API (VSCode)
|
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
try {
|
try {
|
||||||
@@ -52,11 +48,12 @@ export const MemoryLimitsSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to fetch
|
// fall through
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch API (Web)
|
// 2. Fetch API (Web/server)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
const response = await fetch('/api/config/settings', {
|
const response = await fetch('/api/config/settings', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -66,7 +63,6 @@ export const MemoryLimitsSettings: React.FC = () => {
|
|||||||
data = await response.json();
|
data = await response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
if (typeof data.memoryLimitHistorical === 'number') {
|
if (typeof data.memoryLimitHistorical === 'number') {
|
||||||
@@ -91,17 +87,6 @@ export const MemoryLimitsSettings: React.FC = () => {
|
|||||||
const persistSetting = React.useCallback(async (key: string, value: number) => {
|
const persistSetting = React.useCallback(async (key: string, value: number) => {
|
||||||
try {
|
try {
|
||||||
await updateDesktopSettings({ [key]: value });
|
await updateDesktopSettings({ [key]: value });
|
||||||
|
|
||||||
if (!isDesktopRuntime()) {
|
|
||||||
const response = await fetch('/api/config/settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ [key]: value }),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn(`Failed to save ${key} to server:`, response.status, response.statusText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to save ${key}:`, error);
|
console.warn(`Failed to save ${key}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
@@ -8,7 +8,9 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|||||||
import { GridLoader } from '@/components/ui/grid-loader';
|
import { GridLoader } from '@/components/ui/grid-loader';
|
||||||
|
|
||||||
export const NotificationSettings: React.FC = () => {
|
export const NotificationSettings: React.FC = () => {
|
||||||
const isWeb = isWebRuntime();
|
const isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||||
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
const isBrowser = !isDesktop && !isVSCode;
|
||||||
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
|
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
|
||||||
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
|
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
|
||||||
const notificationMode = useUIStore(state => state.notificationMode);
|
const notificationMode = useUIStore(state => state.notificationMode);
|
||||||
@@ -22,7 +24,7 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
const [pushBusy, setPushBusy] = React.useState(false);
|
const [pushBusy, setPushBusy] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isWeb) {
|
if (!isBrowser) {
|
||||||
setPushSupported(false);
|
setPushSupported(false);
|
||||||
setPushSubscribed(false);
|
setPushSubscribed(false);
|
||||||
return;
|
return;
|
||||||
@@ -58,10 +60,16 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [isWeb]);
|
}, [isBrowser]);
|
||||||
|
|
||||||
const handleToggleChange = async (checked: boolean) => {
|
const handleToggleChange = async (checked: boolean) => {
|
||||||
if (!isWeb) {
|
if (isDesktop) {
|
||||||
|
setNativeNotificationsEnabled(checked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBrowser) {
|
||||||
|
setNativeNotificationsEnabled(checked);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
||||||
@@ -86,7 +94,7 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canShowNotifications = isWeb && typeof Notification !== 'undefined' && Notification.permission === 'granted';
|
const canShowNotifications = isDesktop || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
|
||||||
|
|
||||||
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
|
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
|
||||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||||
@@ -365,23 +373,45 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* General Notification Settings */}
|
<div className="space-y-1 pt-2">
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||||
Notification Preferences
|
When to notify
|
||||||
</h3>
|
</h3>
|
||||||
<p className="typography-ui text-muted-foreground">
|
<p className="typography-ui text-muted-foreground">
|
||||||
Configure how and when you receive notifications.
|
Customize when notifications show up.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="typography-ui text-foreground">
|
<span className="typography-ui text-foreground">
|
||||||
Notify for subtasks
|
Enable notifications
|
||||||
</span>
|
</span>
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
When off, no notifications for child sessions created during multi-run.
|
Turns notifications on or off.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||||
|
onCheckedChange={handleToggleChange}
|
||||||
|
className="data-[state=checked]:bg-status-info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBrowser && (
|
||||||
|
<p className="typography-micro text-muted-foreground">
|
||||||
|
Your browser may ask for permission the first time.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nativeNotificationsEnabled && canShowNotifications && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="typography-ui text-foreground">
|
||||||
|
Include subagent results
|
||||||
|
</span>
|
||||||
|
<p className="typography-micro text-muted-foreground">
|
||||||
|
Also notify for child sessions started by the main one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -390,38 +420,16 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
className="data-[state=checked]:bg-status-info"
|
className="data-[state=checked]:bg-status-info"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{isWeb && (
|
|
||||||
<>
|
|
||||||
{/* Foreground Notifications */}
|
|
||||||
<div className="space-y-1 pt-4">
|
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
|
||||||
Foreground Notifications
|
|
||||||
</h3>
|
|
||||||
<p className="typography-ui text-muted-foreground">
|
|
||||||
Uses the browser Notification API while OpenChamber is open.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="typography-ui text-foreground">
|
|
||||||
Enable foreground notifications
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={nativeNotificationsEnabled && canShowNotifications}
|
|
||||||
onCheckedChange={handleToggleChange}
|
|
||||||
className="data-[state=checked]:bg-status-info"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{nativeNotificationsEnabled && canShowNotifications && (
|
{nativeNotificationsEnabled && canShowNotifications && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="typography-ui text-foreground">
|
<span className="typography-ui text-foreground">
|
||||||
Notify even when visible
|
Notify while app is focused
|
||||||
</span>
|
</span>
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
When off, only notifies when the tab is hidden or the window is not focused.
|
When off, only notify when you are not looking at OpenChamber.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -432,25 +440,26 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isBrowser && (
|
||||||
|
<>
|
||||||
{notificationPermission === 'denied' && (
|
{notificationPermission === 'denied' && (
|
||||||
<p className="typography-micro text-destructive">
|
<p className="typography-micro text-destructive">
|
||||||
Notification permission denied. Enable notifications in your browser settings.
|
Notification permission denied. Enable it in your browser settings.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
Permission granted, but foreground notifications are disabled.
|
Permission granted, but notifications are disabled.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Background Notifications */}
|
|
||||||
<div className="space-y-1 pt-4">
|
<div className="space-y-1 pt-4">
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||||
Background Notifications (Push)
|
Background (Push)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="typography-ui text-muted-foreground">
|
<p className="typography-ui text-muted-foreground">
|
||||||
Uses push notifications; works when OpenChamber is closed.
|
Get notified even if this page is closed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -460,7 +469,7 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA.
|
Desktop Chrome/Edge and Android support push. iOS requires an installed PWA.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -468,10 +477,10 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<span className="typography-ui text-foreground">
|
<span className="typography-ui text-foreground">
|
||||||
Enable background notifications
|
Enable push notifications
|
||||||
</span>
|
</span>
|
||||||
<p className="typography-micro text-muted-foreground">
|
<p className="typography-micro text-muted-foreground">
|
||||||
Opens chat with /?session=<id> deep link.
|
Clicking a notification opens the relevant session.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -499,6 +508,17 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isVSCode && (
|
||||||
|
<div className="space-y-1 pt-4">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||||
|
Delivery
|
||||||
|
</h3>
|
||||||
|
<p className="typography-ui text-muted-foreground">
|
||||||
|
VS Code runtime handles notifications separately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,19 +62,9 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
|||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const showAbout = isMobile && isWebRuntime();
|
const showAbout = isMobile && isWebRuntime();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const isWeb = React.useMemo(() => isWebRuntime(), []);
|
const isWeb = React.useMemo(() => isWebRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const visibleSections = React.useMemo(() => {
|
const visibleSections = React.useMemo(() => {
|
||||||
return OPENCHAMBER_SECTION_GROUPS.filter((group) => {
|
return OPENCHAMBER_SECTION_GROUPS.filter((group) => {
|
||||||
if (group.webOnly && !isWeb) return false;
|
if (group.webOnly && !isWeb) return false;
|
||||||
@@ -86,11 +76,7 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
|||||||
// Desktop app: transparent for blur effect
|
// Desktop app: transparent for blur effect
|
||||||
// VS Code: bg-background (same as page content)
|
// VS Code: bg-background (same as page content)
|
||||||
// Web/mobile: bg-sidebar
|
// Web/mobile: bg-sidebar
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||||
|
|||||||
@@ -20,23 +20,9 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
|||||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||||
|
|||||||
@@ -33,26 +33,12 @@ export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Desktop app: transparent for blur effect
|
// Desktop app: transparent for blur effect
|
||||||
// VS Code: bg-background (same as page content)
|
// VS Code: bg-background (same as page content)
|
||||||
// Web/mobile: bg-sidebar
|
// Web/mobile: bg-sidebar
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -47,27 +47,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
const { setSidebarOpen } = useUIStore();
|
const { setSidebarOpen } = useUIStore();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadSkills();
|
loadSkills();
|
||||||
}, [loadSkills]);
|
}, [loadSkills]);
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
// Generate unique name
|
// Generate unique name
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { RiGitRepositoryLine } from '@remixicon/react';
|
import { RiGitRepositoryLine } from '@remixicon/react';
|
||||||
|
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||||
@@ -50,10 +50,6 @@ type IdentityOption = { id: string; name: string };
|
|||||||
|
|
||||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||||
try {
|
try {
|
||||||
if (isDesktopRuntime()) {
|
|
||||||
return await getDesktopSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
const result = await runtimeSettings.load();
|
const result = await runtimeSettings.load();
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
|||||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||||
|
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
|
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||||
|
|
||||||
@@ -33,10 +32,6 @@ interface SkillsCatalogPageProps {
|
|||||||
|
|
||||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||||
try {
|
try {
|
||||||
if (isDesktopRuntime()) {
|
|
||||||
return await getDesktopSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
const result = await runtimeSettings.load();
|
const result = await runtimeSettings.load();
|
||||||
|
|||||||
@@ -43,18 +43,8 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
|||||||
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
|
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void loadUsageSettings();
|
void loadUsageSettings();
|
||||||
}, [loadUsageSettings]);
|
}, [loadUsageSettings]);
|
||||||
@@ -89,14 +79,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
|||||||
void persistUsageSettings({ usageDisplayMode: value });
|
void persistUsageSettings({ usageDisplayMode: value });
|
||||||
}, [persistUsageSettings, setUsageDisplayMode]);
|
}, [persistUsageSettings, setUsageDisplayMode]);
|
||||||
|
|
||||||
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
|
|
||||||
|
|
||||||
const bgClass = isDesktopRuntime
|
|
||||||
? 'bg-transparent'
|
|
||||||
: isVSCode
|
|
||||||
? 'bg-background'
|
|
||||||
: 'bg-sidebar';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLin
|
|||||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
|
|
||||||
import type { DesktopSettings } from '@/lib/desktop';
|
import type { DesktopSettings } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
@@ -54,7 +53,6 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
isRootReady,
|
isRootReady,
|
||||||
alwaysShowActions = false,
|
alwaysShowActions = false,
|
||||||
}) => {
|
}) => {
|
||||||
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
|
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
|
||||||
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
|
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
|
||||||
@@ -247,10 +245,6 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
try {
|
try {
|
||||||
let pinned: string[] = [];
|
let pinned: string[] = [];
|
||||||
|
|
||||||
if (desktopRuntime) {
|
|
||||||
const settings = await getDesktopSettings();
|
|
||||||
pinned = Array.isArray(settings?.pinnedDirectories) ? settings.pinnedDirectories : [];
|
|
||||||
} else {
|
|
||||||
const response = await fetch('/api/config/settings', {
|
const response = await fetch('/api/config/settings', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
@@ -259,7 +253,6 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : [];
|
pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -287,7 +280,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
window.removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
};
|
};
|
||||||
}, [desktopRuntime, stripTrailingSlashes]);
|
}, [stripTrailingSlashes]);
|
||||||
|
|
||||||
const isInitialPinnedSync = React.useRef(true);
|
const isInitialPinnedSync = React.useRef(true);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
import { isDesktopRuntime } from '@/lib/desktop';
|
import { isTauriShell } from '@/lib/desktop';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
setHasShownInitialDirectoryPrompt(true);
|
setHasShownInitialDirectoryPrompt(true);
|
||||||
|
|
||||||
if (isDesktopRuntime()) {
|
if (isTauriShell()) {
|
||||||
requestAccess('')
|
requestAccess('')
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
if (!result.success || !result.path) {
|
if (!result.success || !result.path) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
|
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
@@ -64,6 +65,7 @@ import { checkIsGitRepository } from '@/lib/gitApi';
|
|||||||
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||||
@@ -135,7 +137,7 @@ interface SortableProjectItemProps {
|
|||||||
isActiveProject: boolean;
|
isActiveProject: boolean;
|
||||||
isRepo: boolean;
|
isRepo: boolean;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
isDesktopRuntime: boolean;
|
isDesktopShell: boolean;
|
||||||
isStuck: boolean;
|
isStuck: boolean;
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
@@ -161,7 +163,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
isActiveProject,
|
isActiveProject,
|
||||||
isRepo,
|
isRepo,
|
||||||
isHovered,
|
isHovered,
|
||||||
isDesktopRuntime,
|
isDesktopShell,
|
||||||
isStuck,
|
isStuck,
|
||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
@@ -190,7 +192,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} className={cn('relative', isDragging && 'opacity-40')}>
|
<div ref={setNodeRef} className={cn('relative', isDragging && 'opacity-40')}>
|
||||||
{/* Sentinel for sticky detection */}
|
{/* Sentinel for sticky detection */}
|
||||||
{isDesktopRuntime && (
|
{isDesktopShell && (
|
||||||
<div
|
<div
|
||||||
ref={sentinelRef}
|
ref={sentinelRef}
|
||||||
data-project-id={id}
|
data-project-id={id}
|
||||||
@@ -203,10 +205,10 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
||||||
!isDesktopRuntime && 'bg-sidebar',
|
!isDesktopShell && 'bg-sidebar',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDesktopRuntime
|
backgroundColor: isDesktopShell
|
||||||
? isStuck ? 'var(--sidebar-stuck-bg)' : 'transparent'
|
? isStuck ? 'var(--sidebar-stuck-bg)' : 'transparent'
|
||||||
: undefined,
|
: undefined,
|
||||||
borderColor: isHovered
|
borderColor: isHovered
|
||||||
@@ -428,6 +430,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||||
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
const ignoreIntersectionUntil = React.useRef<number>(0);
|
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||||
|
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||||
|
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
@@ -455,22 +459,64 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const shareSession = useSessionStore((state) => state.shareSession);
|
const shareSession = useSessionStore((state) => state.shareSession);
|
||||||
const unshareSession = useSessionStore((state) => state.unshareSession);
|
const unshareSession = useSessionStore((state) => state.unshareSession);
|
||||||
const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState);
|
const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState);
|
||||||
const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase);
|
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
||||||
const permissions = useSessionStore((state) => state.permissions);
|
const permissions = useSessionStore((state) => state.permissions);
|
||||||
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
|
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
|
||||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
||||||
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
|
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
|
||||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
||||||
|
|
||||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||||
if (typeof window === 'undefined') {
|
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return typeof window.opencodeDesktop !== 'undefined';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
|
|
||||||
|
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||||
|
if (isVSCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const collapsed = pendingCollapsedProjects.current;
|
||||||
|
pendingCollapsedProjects.current = null;
|
||||||
|
persistCollapsedProjectsTimer.current = null;
|
||||||
|
if (!collapsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projects } = useProjectsStore.getState();
|
||||||
|
const updatedProjects = projects.map((project) => ({
|
||||||
|
...project,
|
||||||
|
sidebarCollapsed: collapsed.has(project.id),
|
||||||
|
}));
|
||||||
|
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||||
|
}, [isVSCode]);
|
||||||
|
|
||||||
|
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVSCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCollapsedProjects.current = collapsed;
|
||||||
|
if (persistCollapsedProjectsTimer.current !== null) {
|
||||||
|
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||||
|
}
|
||||||
|
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||||
|
flushCollapsedProjectsPersist();
|
||||||
|
}, 700);
|
||||||
|
}, [flushCollapsedProjectsPersist, isVSCode]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) {
|
||||||
|
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||||
|
}
|
||||||
|
persistCollapsedProjectsTimer.current = null;
|
||||||
|
pendingCollapsedProjects.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const storedParents = safeStorage.getItem(SESSION_EXPANDED_STORAGE_KEY);
|
const storedParents = safeStorage.getItem(SESSION_EXPANDED_STORAGE_KEY);
|
||||||
@@ -490,13 +536,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
}, [safeStorage]);
|
}, [safeStorage]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sortedSessions = React.useMemo(() => {
|
const sortedSessions = React.useMemo(() => {
|
||||||
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
||||||
}, [sessions]);
|
}, [sessions]);
|
||||||
@@ -863,9 +902,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenDirectoryDialog = React.useCallback(() => {
|
const handleOpenDirectoryDialog = React.useCallback(() => {
|
||||||
if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) {
|
if (!tauriIpcAvailable) {
|
||||||
window.opencodeDesktop
|
sessionEvents.requestDirectoryDialog();
|
||||||
.requestDirectoryAccess('')
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
import('@/lib/desktop')
|
||||||
|
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
const added = addProject(result.path, { id: result.projectId });
|
const added = addProject(result.path, { id: result.projectId });
|
||||||
@@ -884,10 +927,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
console.error('Desktop: Error selecting directory:', error);
|
console.error('Desktop: Error selecting directory:', error);
|
||||||
toast.error('Failed to select directory');
|
toast.error('Failed to select directory');
|
||||||
});
|
});
|
||||||
} else {
|
}, [addProject, tauriIpcAvailable]);
|
||||||
sessionEvents.requestDirectoryDialog();
|
|
||||||
}
|
|
||||||
}, [addProject, isDesktopRuntime]);
|
|
||||||
|
|
||||||
const toggleParent = React.useCallback((sessionId: string) => {
|
const toggleParent = React.useCallback((sessionId: string) => {
|
||||||
setExpandedParents((prev) => {
|
setExpandedParents((prev) => {
|
||||||
@@ -1019,9 +1059,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
try {
|
try {
|
||||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
// Persist collapse state to server settings (web + desktop local/remote).
|
||||||
|
if (!isVSCode) {
|
||||||
|
scheduleCollapsedProjectsPersist(next);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [safeStorage]);
|
}, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
|
||||||
|
|
||||||
const normalizedProjects = React.useMemo(() => {
|
const normalizedProjects = React.useMemo(() => {
|
||||||
return projects
|
return projects
|
||||||
@@ -1081,7 +1126,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
// Track when project sticky headers become "stuck"
|
// Track when project sticky headers become "stuck"
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isDesktopRuntime) return;
|
if (!isDesktopShellRuntime) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -1108,7 +1153,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [isDesktopRuntime, projectSections]);
|
}, [isDesktopShellRuntime, projectSections]);
|
||||||
|
|
||||||
const renderSessionNode = React.useCallback(
|
const renderSessionNode = React.useCallback(
|
||||||
(node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => {
|
(node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => {
|
||||||
@@ -1202,8 +1247,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = sessionActivityPhase?.get(session.id) ?? 'idle';
|
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
||||||
const isStreaming = phase === 'busy' || phase === 'cooldown';
|
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||||
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
|
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
|
||||||
|
|
||||||
const streamingIndicator = (() => {
|
const streamingIndicator = (() => {
|
||||||
@@ -1427,7 +1472,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
[
|
[
|
||||||
directoryStatus,
|
directoryStatus,
|
||||||
sessionMemoryState,
|
sessionMemoryState,
|
||||||
sessionActivityPhase,
|
sessionStatus,
|
||||||
permissions,
|
permissions,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
expandedParents,
|
expandedParents,
|
||||||
@@ -1571,7 +1616,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
onClick={handleOpenDirectoryDialog}
|
onClick={handleOpenDirectoryDialog}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||||
!isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
!isDesktopShellRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
||||||
)}
|
)}
|
||||||
aria-label="Add project"
|
aria-label="Add project"
|
||||||
title="Add project"
|
title="Add project"
|
||||||
@@ -1650,7 +1695,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
isActiveProject={isActiveProject}
|
isActiveProject={isActiveProject}
|
||||||
isRepo={Boolean(isRepo)}
|
isRepo={Boolean(isRepo)}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isDesktopRuntime={isDesktopRuntime}
|
isDesktopShell={isDesktopShellRuntime}
|
||||||
isStuck={stuckProjectHeaders.has(projectKey)}
|
isStuck={stuckProjectHeaders.has(projectKey)}
|
||||||
hideDirectoryControls={hideDirectoryControls}
|
hideDirectoryControls={hideDirectoryControls}
|
||||||
mobileVariant={mobileVariant}
|
mobileVariant={mobileVariant}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const isDesktop = typeof window !== 'undefined' && !!window.opencodeDesktop;
|
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
const fetchVersion = async () => {
|
const fetchVersion = async () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react';
|
import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react';
|
||||||
import { useDesktopServerInfo } from '@/hooks/useDesktopServerInfo';
|
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
interface MemoryDebugPanelProps {
|
interface MemoryDebugPanelProps {
|
||||||
@@ -20,7 +19,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
|||||||
trimToViewportWindow,
|
trimToViewportWindow,
|
||||||
evictLeastRecentlyUsed
|
evictLeastRecentlyUsed
|
||||||
} = useSessionStore();
|
} = useSessionStore();
|
||||||
const desktopInfo = useDesktopServerInfo(4000);
|
|
||||||
|
|
||||||
const totalMessages = React.useMemo(() => {
|
const totalMessages = React.useMemo(() => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -81,25 +79,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{desktopInfo && (
|
{null}
|
||||||
<div className="grid grid-cols-2 gap-2 typography-meta border-t pt-2">
|
|
||||||
<div className="bg-muted/50 rounded p-2">
|
|
||||||
<div className="text-muted-foreground">Desktop Host</div>
|
|
||||||
<div className="typography-markdown font-mono text-xs">
|
|
||||||
{desktopInfo.host ?? 'unknown'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 rounded p-2">
|
|
||||||
<div className="text-muted-foreground">OpenCode Port</div>
|
|
||||||
<div className="typography-markdown font-semibold">
|
|
||||||
{desktopInfo.openCodePort ?? 'n/a'}
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
{desktopInfo.ready ? 'ready' : 'starting'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{}
|
{}
|
||||||
<div className="typography-meta space-y-1 border-t pt-2">
|
<div className="typography-meta space-y-1 border-t pt-2">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
|
export const OpenCodeStatusDialog: React.FC = () => {
|
||||||
|
const {
|
||||||
|
isOpenCodeStatusDialogOpen,
|
||||||
|
setOpenCodeStatusDialogOpen,
|
||||||
|
openCodeStatusText,
|
||||||
|
} = useUIStore();
|
||||||
|
|
||||||
|
const handleCopy = React.useCallback(() => {
|
||||||
|
if (!openCodeStatusText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(openCodeStatusText)
|
||||||
|
.then(() => {
|
||||||
|
toast.success('Copied', { description: 'OpenCode status copied to clipboard.' });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
});
|
||||||
|
}, [openCodeStatusText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpenCodeStatusDialogOpen} onOpenChange={setOpenCodeStatusDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>OpenCode Status</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Diagnostic snapshot for support and debugging.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="app-region-no-drag inline-flex h-9 items-center justify-center rounded-md px-3 typography-ui-label font-medium text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre className="max-h-[60vh] overflow-auto rounded-lg bg-surface-muted p-4 typography-code text-foreground whitespace-pre-wrap">
|
||||||
|
{openCodeStatusText || 'No data.'}
|
||||||
|
</pre>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ export type ScrollShadowProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
size?: number;
|
size?: number;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
hideBottomShadow?: boolean;
|
hideBottomShadow?: boolean;
|
||||||
|
observeMutations?: boolean;
|
||||||
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
|
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
|||||||
size = 48,
|
size = 48,
|
||||||
isEnabled = true,
|
isEnabled = true,
|
||||||
hideBottomShadow = false,
|
hideBottomShadow = false,
|
||||||
|
observeMutations = true,
|
||||||
onVisibilityChange,
|
onVisibilityChange,
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
@@ -119,13 +121,13 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
|||||||
const handleScroll = () => checkOverflow(); // Scroll should be immediate
|
const handleScroll = () => checkOverflow(); // Scroll should be immediate
|
||||||
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
|
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
|
||||||
const mutationObserver =
|
const mutationObserver =
|
||||||
typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
|
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
|
||||||
|
|
||||||
checkOverflow();
|
checkOverflow();
|
||||||
|
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
resizeObserver?.observe(el);
|
resizeObserver?.observe(el);
|
||||||
mutationObserver?.observe(el, { childList: true, subtree: true, characterData: true });
|
mutationObserver?.observe(el, { childList: true, subtree: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
@@ -133,7 +135,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
|||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
mutationObserver?.disconnect();
|
mutationObserver?.disconnect();
|
||||||
};
|
};
|
||||||
}, [checkOverflow]);
|
}, [checkOverflow, observeMutations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export const PlanView: React.FC = () => {
|
|||||||
setLineSelection(null);
|
setLineSelection(null);
|
||||||
|
|
||||||
toast.success('Comment saved');
|
toast.success('Comment saved');
|
||||||
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey]);
|
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey, extractSelectedCode]);
|
||||||
|
|
||||||
const editorExtensions = React.useMemo(() => {
|
const editorExtensions = React.useMemo(() => {
|
||||||
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||||
|
|||||||
@@ -104,10 +104,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
const startXRef = React.useRef(0);
|
const startXRef = React.useRef(0);
|
||||||
const startWidthRef = React.useRef(sidebarWidth);
|
const startWidthRef = React.useRef(sidebarWidth);
|
||||||
|
|
||||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
const isTauri = React.useMemo(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
|
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
const isMacPlatform = React.useMemo(() => {
|
const isMacPlatform = React.useMemo(() => {
|
||||||
if (typeof navigator === 'undefined') return false;
|
if (typeof navigator === 'undefined') return false;
|
||||||
@@ -118,10 +118,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
|
|
||||||
const settingsSections = React.useMemo(() => getSettingsSections(isVSCode), [isVSCode]);
|
const settingsSections = React.useMemo(() => getSettingsSections(isVSCode), [isVSCode]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const isDesktopApp = isTauri;
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Track container width for responsive tab labels
|
// Track container width for responsive tab labels
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { isDesktopRuntime, isWebRuntime } from '@/lib/desktop';
|
|
||||||
|
|
||||||
type Modifier = 'ctrl' | 'cmd';
|
type Modifier = 'ctrl' | 'cmd';
|
||||||
type MobileKey =
|
type MobileKey =
|
||||||
@@ -74,12 +73,13 @@ const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string |
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TerminalView: React.FC = () => {
|
export const TerminalView: React.FC = () => {
|
||||||
const { terminal } = useRuntimeAPIs();
|
const { terminal, runtime } = useRuntimeAPIs();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const { monoFont } = useFontPreferences();
|
const { monoFont } = useFontPreferences();
|
||||||
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
||||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
const enableTabs = !isMobile && (isWebRuntime() || isDesktopRuntime());
|
// Tabs are supported for web + desktop runtimes (not VSCode).
|
||||||
|
const enableTabs = !isMobile && runtime.platform !== 'vscode';
|
||||||
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
|
||||||
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
|
||||||
|
|
||||||
|
|||||||
@@ -73,19 +73,25 @@ type PullRequestDraftSnapshot = {
|
|||||||
|
|
||||||
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
||||||
|
|
||||||
|
type TauriShell = {
|
||||||
|
shell?: {
|
||||||
|
open?: (url: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const openExternal = async (url: string) => {
|
const openExternal = async (url: string) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
|
||||||
if (desktop?.openExternal) {
|
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
||||||
|
if (tauri?.shell?.open) {
|
||||||
try {
|
try {
|
||||||
const result = await desktop.openExternal(url);
|
await tauri.shell.open(url);
|
||||||
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import type { Theme, ThemeMode } from '@/types/theme';
|
import type { Theme, ThemeMode } from '@/types/theme';
|
||||||
import type { DesktopSettings } from '@/lib/desktop';
|
import type { DesktopSettings } from '@/lib/desktop';
|
||||||
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
|
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import {
|
import {
|
||||||
@@ -190,7 +190,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
return existing || null;
|
return existing || null;
|
||||||
});
|
});
|
||||||
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
|
||||||
const isDesktop = useMemo(() => isDesktopRuntime(), []);
|
const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []);
|
||||||
|
|
||||||
const availableThemes = useMemo(() => {
|
const availableThemes = useMemo(() => {
|
||||||
const merged: Theme[] = [];
|
const merged: Theme[] = [];
|
||||||
@@ -256,7 +256,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/config/themes', {
|
const res = await fetch('/api/config/themes', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: isDesktop ? 'omit' : 'include',
|
credentials: isLocalDesktopOrigin ? 'omit' : 'include',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
@@ -280,7 +280,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
} finally {
|
} finally {
|
||||||
setCustomThemesLoading(false);
|
setCustomThemesLoading(false);
|
||||||
}
|
}
|
||||||
}, [isDesktop, isVSCode]);
|
}, [isLocalDesktopOrigin, isVSCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reloadCustomThemes();
|
void reloadCustomThemes();
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const { phase: activityPhase, isWorking: isPhaseWorking, isCooldown: isPhaseCooldown } = useCurrentSessionActivity();
|
const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity();
|
||||||
|
|
||||||
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
|
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
@@ -298,7 +298,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
|
|
||||||
const isWorking = isPhaseWorking;
|
const isWorking = isPhaseWorking;
|
||||||
const isStreaming = activityPhase === 'busy';
|
const isStreaming = activityPhase === 'busy';
|
||||||
const isCooldown = isPhaseCooldown;
|
const isCooldown = false;
|
||||||
|
|
||||||
let activity: AssistantActivity = 'idle';
|
let activity: AssistantActivity = 'idle';
|
||||||
if (isWorking) {
|
if (isWorking) {
|
||||||
@@ -327,9 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
wasAborted: false,
|
wasAborted: false,
|
||||||
abortActive: false,
|
abortActive: false,
|
||||||
lastCompletionId: null,
|
lastCompletionId: null,
|
||||||
isComplete: isCooldown,
|
isComplete: false,
|
||||||
};
|
};
|
||||||
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
|
}, [activityPhase, isPhaseWorking, parsedStatus, abortState]);
|
||||||
|
|
||||||
const forming = React.useMemo<FormingSummary>(() => {
|
const forming = React.useMemo<FormingSummary>(() => {
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
fetchDesktopServerInfo,
|
|
||||||
isDesktopRuntime,
|
|
||||||
type DesktopServerInfo
|
|
||||||
} from "@/lib/desktop";
|
|
||||||
|
|
||||||
export const useDesktopServerInfo = (pollInterval = 5000): DesktopServerInfo | null => {
|
|
||||||
const [info, setInfo] = useState<DesktopServerInfo | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDesktopRuntime()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
const payload = await fetchDesktopServerInfo();
|
|
||||||
if (!cancelled) {
|
|
||||||
setInfo(payload);
|
|
||||||
timer = setTimeout(poll, pollInterval);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [pollInterval]);
|
|
||||||
|
|
||||||
return info;
|
|
||||||
};
|
|
||||||
@@ -15,7 +15,7 @@ import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
|||||||
import { useMcpStore } from '@/stores/useMcpStore';
|
import { useMcpStore } from '@/stores/useMcpStore';
|
||||||
import { useContextStore } from '@/stores/contextStore';
|
import { useContextStore } from '@/stores/contextStore';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -99,36 +99,6 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
|||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatModelID = (raw: string): string => {
|
|
||||||
if (!raw) {
|
|
||||||
return 'Assistant';
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens: string[] = raw.split(/[-_]/);
|
|
||||||
const result: string[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < tokens.length) {
|
|
||||||
const current = tokens[i];
|
|
||||||
|
|
||||||
if (/^\d+$/.test(current)) {
|
|
||||||
if (i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) {
|
|
||||||
const combined = `${current}.${tokens[i + 1]}`;
|
|
||||||
result.push(combined);
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(current);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useEventStream = () => {
|
export const useEventStream = () => {
|
||||||
const {
|
const {
|
||||||
addStreamingPart,
|
addStreamingPart,
|
||||||
@@ -149,8 +119,6 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
const { checkConnection } = useConfigStore();
|
const { checkConnection } = useConfigStore();
|
||||||
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
||||||
const notificationMode = useUIStore((state) => state.notificationMode);
|
|
||||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
|
||||||
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
|
||||||
const activeSessionDirectory = React.useMemo(() => {
|
const activeSessionDirectory = React.useMemo(() => {
|
||||||
@@ -412,7 +380,6 @@ export const useEventStream = () => {
|
|||||||
(sessionId: string, reason: string, limit?: number) => Promise<void>
|
(sessionId: string, reason: string, limit?: number) => Promise<void>
|
||||||
>(() => Promise.resolve());
|
>(() => Promise.resolve());
|
||||||
const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {});
|
const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {});
|
||||||
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
|
||||||
|
|
||||||
const maybeBootstrapIfStale = React.useCallback(
|
const maybeBootstrapIfStale = React.useCallback(
|
||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
@@ -425,17 +392,6 @@ export const useEventStream = () => {
|
|||||||
[bootstrapState]
|
[bootstrapState]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isDesktop?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
|
||||||
if (apis?.runtime?.isDesktop) {
|
|
||||||
isDesktopRuntimeRef.current = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sessionCooldownTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
|
||||||
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
|
|
||||||
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
||||||
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||||
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
||||||
@@ -509,15 +465,46 @@ export const useEventStream = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
|
type SessionStatusPayload = {
|
||||||
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
type: 'idle' | 'busy' | 'retry';
|
||||||
if (storePhase === phase) {
|
attempt?: number;
|
||||||
sessionActivityPhaseRef.current = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
message?: string;
|
||||||
return;
|
next?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSessionStatus = React.useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
status: SessionStatusPayload,
|
||||||
|
source: string = 'unknown'
|
||||||
|
) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const storeStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||||
|
const prevType = storeStatus?.type ?? 'idle';
|
||||||
|
const nextType = status?.type ?? 'idle';
|
||||||
|
|
||||||
|
if (prevType !== nextType) {
|
||||||
|
try {
|
||||||
|
console.info('[SESSION-STATUS]', {
|
||||||
|
sessionId,
|
||||||
|
from: prevType,
|
||||||
|
to: nextType,
|
||||||
|
source,
|
||||||
|
...(nextType === 'retry'
|
||||||
|
? {
|
||||||
|
attempt: status.attempt,
|
||||||
|
next: status.next,
|
||||||
|
message: status.message,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldArmMessageStallCheck = storePhase === 'idle' && (phase === 'busy' || phase === 'cooldown');
|
const shouldArmMessageStallCheck = prevType === 'idle' && (nextType === 'busy' || nextType === 'retry');
|
||||||
const shouldDisarmMessageStallCheck = phase === 'idle';
|
const shouldDisarmMessageStallCheck = nextType === 'idle';
|
||||||
|
|
||||||
if (shouldDisarmMessageStallCheck) {
|
if (shouldDisarmMessageStallCheck) {
|
||||||
const pending = pendingMessageStallTimersRef.current.get(sessionId);
|
const pending = pendingMessageStallTimersRef.current.get(sessionId);
|
||||||
@@ -536,8 +523,8 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
const startAt = Date.now();
|
const startAt = Date.now();
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
const current = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||||
if (currentPhase !== 'busy' && currentPhase !== 'cooldown') {
|
if (current?.type !== 'busy' && current?.type !== 'retry') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,43 +539,25 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now());
|
lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now());
|
||||||
|
void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getActiveSessionWindow())
|
||||||
void scheduleSoftResyncRef.current(sessionId, 'activity_started_no_message', getActiveSessionWindow())
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
scheduleReconnectRef.current('No message events after activity start');
|
scheduleReconnectRef.current('No message events after busy status');
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
pendingMessageStallTimersRef.current.set(sessionId, timer);
|
pendingMessageStallTimersRef.current.set(sessionId, timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingTimer = sessionCooldownTimersRef.current.get(sessionId);
|
const next = new Map(useSessionStore.getState().sessionStatus ?? new Map());
|
||||||
if (existingTimer) {
|
if (nextType === 'idle') {
|
||||||
clearTimeout(existingTimer);
|
next.delete(sessionId);
|
||||||
sessionCooldownTimersRef.current.delete(sessionId);
|
} else {
|
||||||
}
|
next.set(sessionId, status);
|
||||||
|
|
||||||
const next = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
|
||||||
next.set(sessionId, phase);
|
|
||||||
sessionActivityPhaseRef.current = next;
|
|
||||||
useSessionStore.setState({ sessionActivityPhase: next });
|
|
||||||
|
|
||||||
if (phase === 'cooldown') {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
sessionCooldownTimersRef.current.delete(sessionId);
|
|
||||||
const current = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
|
||||||
if (current === 'cooldown') {
|
|
||||||
const latest = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
|
||||||
latest.set(sessionId, 'idle');
|
|
||||||
sessionActivityPhaseRef.current = latest;
|
|
||||||
useSessionStore.setState({ sessionActivityPhase: latest });
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
sessionCooldownTimersRef.current.set(sessionId, timer);
|
|
||||||
}
|
}
|
||||||
|
useSessionStore.setState({ sessionStatus: next });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshSessionActivityStatus = React.useCallback(async () => {
|
const refreshSessionStatus = React.useCallback(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (sessionStatusRefreshInFlightRef.current) {
|
if (sessionStatusRefreshInFlightRef.current) {
|
||||||
return sessionStatusRefreshInFlightRef.current;
|
return sessionStatusRefreshInFlightRef.current;
|
||||||
@@ -607,38 +576,37 @@ export const useEventStream = () => {
|
|||||||
for (const [sessionId, raw] of Object.entries(statusMap)) {
|
for (const [sessionId, raw] of Object.entries(statusMap)) {
|
||||||
if (!sessionId || !raw) continue;
|
if (!sessionId || !raw) continue;
|
||||||
observed.add(sessionId);
|
observed.add(sessionId);
|
||||||
const phase: 'idle' | 'busy' =
|
const typeRaw = raw.type;
|
||||||
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
|
const status: SessionStatusPayload =
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
typeRaw === 'retry'
|
||||||
|
? {
|
||||||
|
type: 'retry',
|
||||||
|
attempt: (raw as { attempt?: unknown }).attempt as number | undefined,
|
||||||
|
message: (raw as { message?: unknown }).message as string | undefined,
|
||||||
|
next: (raw as { next?: unknown }).next as number | undefined,
|
||||||
|
}
|
||||||
|
: typeRaw === 'busy' || typeRaw === 'cooldown'
|
||||||
|
? { type: 'busy' }
|
||||||
|
: { type: 'idle' };
|
||||||
|
updateSessionStatus(sessionId, status, 'poll:/session/status');
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
|
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
|
||||||
// Treat missing entries as idle to avoid sessions getting stuck "working".
|
// Treat missing entries as idle to avoid sessions getting stuck "working".
|
||||||
const currentPhases = useSessionStore.getState().sessionActivityPhase;
|
const currentStatuses = useSessionStore.getState().sessionStatus;
|
||||||
if (!currentPhases) return;
|
if (!currentStatuses) return;
|
||||||
|
|
||||||
for (const [sessionId, phase] of currentPhases.entries()) {
|
for (const [sessionId, status] of currentStatuses.entries()) {
|
||||||
if (!knownSessionIds.has(sessionId)) continue;
|
if (!knownSessionIds.has(sessionId)) continue;
|
||||||
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
|
if ((status.type === 'busy' || status.type === 'retry') && !observed.has(sessionId)) {
|
||||||
updateSessionActivityPhase(sessionId, 'idle');
|
updateSessionStatus(sessionId, { type: 'idle' }, 'poll:missing->idle');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const task = (async (): Promise<void> => {
|
const task = (async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Try web server's tracked activity first - more reliable on visibility restore
|
// OpenCode global session status (busy/retry only; idle omitted)
|
||||||
// because it tracks activity even when UI is not listening to SSE.
|
|
||||||
// Only available in web runtime (desktop/vscode use native events instead).
|
|
||||||
if (isWebRuntime()) {
|
|
||||||
const webServerActivity = await opencodeClient.getWebServerSessionActivity();
|
|
||||||
if (webServerActivity && Object.keys(webServerActivity).length > 0) {
|
|
||||||
applyStatusMap(webServerActivity);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to OpenCode's global session status
|
|
||||||
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
|
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
|
||||||
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
|
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
|
||||||
applyStatusMap(globalStatusMap);
|
applyStatusMap(globalStatusMap);
|
||||||
@@ -677,11 +645,11 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(merged).length === 0) {
|
if (Object.keys(merged).length === 0) {
|
||||||
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
const hasActiveStatuses = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some(
|
||||||
(phase) => phase === 'busy' || phase === 'cooldown'
|
(status) => status?.type === 'busy' || status?.type === 'retry'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasActivePhases) {
|
if (hasActiveStatuses) {
|
||||||
const healthy = await opencodeClient.checkHealth().catch(() => false);
|
const healthy = await opencodeClient.checkHealth().catch(() => false);
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
return;
|
return;
|
||||||
@@ -699,7 +667,7 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
sessionStatusRefreshInFlightRef.current = task;
|
sessionStatusRefreshInFlightRef.current = task;
|
||||||
return task;
|
return task;
|
||||||
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionActivityPhase]);
|
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionStatus]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const nextSessionId = currentSessionId ?? null;
|
const nextSessionId = currentSessionId ?? null;
|
||||||
@@ -709,13 +677,13 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
previousSessionIdRef.current = nextSessionId;
|
previousSessionIdRef.current = nextSessionId;
|
||||||
previousSessionDirectoryRef.current = nextDirectory;
|
previousSessionDirectoryRef.current = nextDirectory;
|
||||||
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
|
}, [currentSessionId, refreshSessionStatus, resolveSessionDirectoryForStatus]);
|
||||||
|
|
||||||
const handleEvent = React.useCallback((event: EventData) => {
|
const handleEvent = React.useCallback((event: EventData) => {
|
||||||
lastEventTimestampRef.current = Date.now();
|
lastEventTimestampRef.current = Date.now();
|
||||||
@@ -766,15 +734,6 @@ export const useEventStream = () => {
|
|||||||
void bootstrapState('server_disposed_event');
|
void bootstrapState('server_disposed_event');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'openchamber:session-activity': {
|
|
||||||
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
|
|
||||||
const phase = typeof props.phase === 'string' ? props.phase : null;
|
|
||||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
|
||||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'mcp.tools.changed': {
|
case 'mcp.tools.changed': {
|
||||||
const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory;
|
const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory;
|
||||||
@@ -783,17 +742,25 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'session.status':
|
case 'session.status':
|
||||||
if (isDesktopRuntimeRef.current) break;
|
|
||||||
{
|
{
|
||||||
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
||||||
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
|
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
|
||||||
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
|
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
|
||||||
|
const statusInfo = statusObj ?? {};
|
||||||
|
|
||||||
if (sessionId && statusType) {
|
if (sessionId && statusType) {
|
||||||
updateSessionActivityPhase(
|
if (statusType === 'busy') {
|
||||||
sessionId,
|
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||||
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
} else if (statusType === 'retry') {
|
||||||
);
|
updateSessionStatus(sessionId, {
|
||||||
|
type: 'retry',
|
||||||
|
attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : undefined,
|
||||||
|
message: typeof statusInfo.message === 'string' ? statusInfo.message : undefined,
|
||||||
|
next: typeof statusInfo.next === 'number' ? statusInfo.next : undefined,
|
||||||
|
}, 'sse:session.status');
|
||||||
|
} else {
|
||||||
|
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
|
||||||
|
}
|
||||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,6 +844,25 @@ export const useEventStream = () => {
|
|||||||
type: part.type || 'text',
|
type: part.type || 'text',
|
||||||
} as Part;
|
} as Part;
|
||||||
|
|
||||||
|
// Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy.
|
||||||
|
if (roleInfo === 'assistant') {
|
||||||
|
const partType = (messagePart as { type?: unknown }).type;
|
||||||
|
const isStreamingPart =
|
||||||
|
partType === 'step-start' ||
|
||||||
|
partType === 'text' ||
|
||||||
|
partType === 'tool' ||
|
||||||
|
partType === 'reasoning' ||
|
||||||
|
partType === 'file' ||
|
||||||
|
partType === 'patch';
|
||||||
|
|
||||||
|
if (isStreamingPart) {
|
||||||
|
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||||
|
if (!currentStatus || currentStatus.type === 'idle') {
|
||||||
|
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
trackMessage(messageId, 'addStreamingPart_called');
|
trackMessage(messageId, 'addStreamingPart_called');
|
||||||
addStreamingPart(sessionId, messageId, messagePart, roleInfo);
|
addStreamingPart(sessionId, messageId, messagePart, roleInfo);
|
||||||
break;
|
break;
|
||||||
@@ -926,7 +912,7 @@ export const useEventStream = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDesktopRuntimeRef.current && streamDebugEnabled()) {
|
if (streamDebugEnabled()) {
|
||||||
try {
|
try {
|
||||||
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
|
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
|
||||||
const textParts = Array.isArray(serverParts)
|
const textParts = Array.isArray(serverParts)
|
||||||
@@ -1137,21 +1123,6 @@ export const useEventStream = () => {
|
|||||||
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
|
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDesktopRuntimeRef.current) {
|
|
||||||
const zeroToleranceShrink = existingLen > 0 && incomingLen < existingLen;
|
|
||||||
const hasUsefulText = partsArray.some((p) => {
|
|
||||||
if (!p || p.type !== 'text') return false;
|
|
||||||
const textPart = p as { text?: string };
|
|
||||||
return typeof textPart.text === 'string' && textPart.text.length > 0;
|
|
||||||
});
|
|
||||||
const shrinkAllowed = eventHasStopFinish && hasUsefulText;
|
|
||||||
|
|
||||||
if (zeroToleranceShrink && !shrinkAllowed) {
|
|
||||||
trackMessage(messageId, 'desktop_shrinking_update_suppressed', { incomingLen, existingLen });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMessageInfo(sessionId, messageId, message as unknown as Message);
|
updateMessageInfo(sessionId, messageId, message as unknown as Message);
|
||||||
@@ -1168,9 +1139,7 @@ export const useEventStream = () => {
|
|||||||
{ count: partsArray.length }
|
{ count: partsArray.length }
|
||||||
);
|
);
|
||||||
|
|
||||||
const partsToInject = isDesktopRuntimeRef.current && (messageExt as { role?: unknown }).role === 'assistant'
|
const partsToInject = partsArray;
|
||||||
? partsArray.filter((serverPart) => serverPart?.type !== 'text')
|
|
||||||
: partsArray;
|
|
||||||
|
|
||||||
for (let i = 0; i < partsToInject.length; i++) {
|
for (let i = 0; i < partsToInject.length; i++) {
|
||||||
const serverPart = partsToInject[i];
|
const serverPart = partsToInject[i];
|
||||||
@@ -1213,11 +1182,6 @@ export const useEventStream = () => {
|
|||||||
const isActiveSession = currentSessionId === sessionId;
|
const isActiveSession = currentSessionId === sessionId;
|
||||||
if (isActiveSession && messageId !== latestAssistantMessageId) break;
|
if (isActiveSession && messageId !== latestAssistantMessageId) break;
|
||||||
|
|
||||||
if (!stopMarkerPresent && isDesktopRuntimeRef.current) {
|
|
||||||
trackMessage(messageId, 'desktop_completion_without_stop');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeCompleted =
|
const timeCompleted =
|
||||||
hasCompletedTimestamp
|
hasCompletedTimestamp
|
||||||
? (completedCandidate as number)
|
? (completedCandidate as number)
|
||||||
@@ -1306,53 +1270,6 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
completeStreamingMessage(sessionId, messageId);
|
completeStreamingMessage(sessionId, messageId);
|
||||||
|
|
||||||
// Only notify when entire message is finished (finish === 'stop')
|
|
||||||
if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) {
|
|
||||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
|
||||||
|
|
||||||
if (shouldNotify) {
|
|
||||||
// Check if this is a subtask and if we should notify for subtasks
|
|
||||||
if (!notifyOnSubtasks) {
|
|
||||||
const sessions = useSessionStore.getState().sessions;
|
|
||||||
const session = sessions.find(s => s.id === sessionId);
|
|
||||||
const isSubtask = session && 'parentID' in session && Boolean((session as { parentID?: string }).parentID);
|
|
||||||
if (isSubtask) {
|
|
||||||
// Skip notification for subtasks
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifiedMessages = notifiedMessagesRef.current;
|
|
||||||
|
|
||||||
if (!notifiedMessages.has(messageId)) {
|
|
||||||
notifiedMessages.add(messageId);
|
|
||||||
|
|
||||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
|
||||||
|
|
||||||
if (runtimeAPIs?.notifications) {
|
|
||||||
const rawMode = (messageExt as { mode?: string }).mode || 'agent';
|
|
||||||
const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant';
|
|
||||||
|
|
||||||
const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`;
|
|
||||||
const body = `${formatModelID(rawModel)} completed the task`;
|
|
||||||
|
|
||||||
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For web/vscode: trigger cooldown only when assistant message has finish === "stop"
|
|
||||||
// to match desktop backend semantics.
|
|
||||||
if (!isDesktopRuntimeRef.current) {
|
|
||||||
if (finish === 'stop') {
|
|
||||||
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
|
||||||
if (currentPhase === 'busy') {
|
|
||||||
updateSessionActivityPhase(sessionId, 'cooldown');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
||||||
const messageSessionId: string =
|
const messageSessionId: string =
|
||||||
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
|
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
|
||||||
@@ -1481,19 +1398,6 @@ export const useEventStream = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isWebRuntime() && nativeNotificationsEnabled) {
|
|
||||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
|
||||||
if (shouldNotify) {
|
|
||||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
|
||||||
if (runtimeAPIs?.notifications) {
|
|
||||||
void runtimeAPIs.notifications.notifyAgentCompletion({
|
|
||||||
title: 'Permission required',
|
|
||||||
body: sessionTitle,
|
|
||||||
tag: `permission-${toastKey}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1513,39 +1417,7 @@ export const useEventStream = () => {
|
|||||||
|
|
||||||
const toastKey = `${request.sessionID}:${request.id}`;
|
const toastKey = `${request.sessionID}:${request.id}`;
|
||||||
|
|
||||||
if (isWebRuntime() && nativeNotificationsEnabled) {
|
// notifications are emitted server-side (see openchamber:notification)
|
||||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
|
||||||
|
|
||||||
if (shouldNotify) {
|
|
||||||
const notifiedQuestions = notifiedQuestionsRef.current;
|
|
||||||
|
|
||||||
if (!notifiedQuestions.has(toastKey)) {
|
|
||||||
notifiedQuestions.add(toastKey);
|
|
||||||
|
|
||||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
|
||||||
|
|
||||||
if (runtimeAPIs?.notifications) {
|
|
||||||
const first = Array.isArray(request.questions) ? request.questions[0] : undefined;
|
|
||||||
const header = typeof first?.header === 'string' ? first.header.trim() : '';
|
|
||||||
const questionText = typeof first?.question === 'string' ? first.question.trim() : '';
|
|
||||||
|
|
||||||
const title = /plan\s*mode/i.test(header)
|
|
||||||
? 'Switch to plan mode'
|
|
||||||
: /build\s*agent/i.test(header)
|
|
||||||
? 'Switch to build mode'
|
|
||||||
: header || 'Input needed';
|
|
||||||
|
|
||||||
const body = questionText || 'Agent is waiting for your response';
|
|
||||||
|
|
||||||
void runtimeAPIs.notifications.notifyAgentCompletion({
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
tag: toastKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!questionToastShownRef.current.has(toastKey)) {
|
if (!questionToastShownRef.current.has(toastKey)) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1606,6 +1478,34 @@ export const useEventStream = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'openchamber:notification': {
|
||||||
|
const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : '';
|
||||||
|
const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : '';
|
||||||
|
const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined;
|
||||||
|
const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden);
|
||||||
|
|
||||||
|
if (requireHidden && visibilityStateRef.current !== 'hidden') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop local instance uses native notifications via sidecar stdout.
|
||||||
|
// Avoid duplicating via UI runtime notifications.
|
||||||
|
if (isDesktopLocalOriginActive()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nativeNotificationsEnabled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
||||||
|
if (runtimeAPIs?.notifications && title) {
|
||||||
|
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag });
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'todo.updated': {
|
case 'todo.updated': {
|
||||||
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
||||||
const todos = Array.isArray(props.todos) ? props.todos : null;
|
const todos = Array.isArray(props.todos) ? props.todos : null;
|
||||||
@@ -1621,8 +1521,6 @@ export const useEventStream = () => {
|
|||||||
}, [
|
}, [
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
nativeNotificationsEnabled,
|
nativeNotificationsEnabled,
|
||||||
notificationMode,
|
|
||||||
notifyOnSubtasks,
|
|
||||||
addStreamingPart,
|
addStreamingPart,
|
||||||
completeStreamingMessage,
|
completeStreamingMessage,
|
||||||
updateMessageInfo,
|
updateMessageInfo,
|
||||||
@@ -1635,7 +1533,7 @@ export const useEventStream = () => {
|
|||||||
applySessionMetadata,
|
applySessionMetadata,
|
||||||
trackMessage,
|
trackMessage,
|
||||||
reportMessage,
|
reportMessage,
|
||||||
updateSessionActivityPhase,
|
updateSessionStatus,
|
||||||
updateSession,
|
updateSession,
|
||||||
removeSessionFromStore,
|
removeSessionFromStore,
|
||||||
bootstrapState,
|
bootstrapState,
|
||||||
@@ -1651,7 +1549,6 @@ export const useEventStream = () => {
|
|||||||
const debugConnectionState = React.useCallback(() => {
|
const debugConnectionState = React.useCallback(() => {
|
||||||
if (streamDebugEnabled()) {
|
if (streamDebugEnabled()) {
|
||||||
console.debug('[useEventStream] Connection state:', {
|
console.debug('[useEventStream] Connection state:', {
|
||||||
isDesktopRuntime: isDesktopRuntimeRef.current,
|
|
||||||
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
||||||
currentSessionId: currentSessionIdRef.current,
|
currentSessionId: currentSessionIdRef.current,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
@@ -1663,8 +1560,6 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}, [effectiveDirectory]);
|
}, [effectiveDirectory]);
|
||||||
|
|
||||||
const waitForDesktopBridge = React.useCallback(async (): Promise<boolean> => true, []);
|
|
||||||
|
|
||||||
const stopStream = React.useCallback(() => {
|
const stopStream = React.useCallback(() => {
|
||||||
if (isCleaningUpRef.current) {
|
if (isCleaningUpRef.current) {
|
||||||
if (streamDebugEnabled()) {
|
if (streamDebugEnabled()) {
|
||||||
@@ -1708,13 +1603,6 @@ export const useEventStream = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDesktopRuntimeRef.current) {
|
|
||||||
const bridgeReady = await waitForDesktopBridge();
|
|
||||||
if (!bridgeReady) {
|
|
||||||
console.warn('[useEventStream] Desktop bridge not ready, falling back to SDK');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.resetAttempts) {
|
if (options?.resetAttempts) {
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
}
|
}
|
||||||
@@ -1740,9 +1628,9 @@ export const useEventStream = () => {
|
|||||||
publishStatus('connected', null);
|
publishStatus('connected', null);
|
||||||
checkConnection();
|
checkConnection();
|
||||||
|
|
||||||
// Always refresh session activity status on connect to detect any
|
// Always refresh session status on connect to detect any
|
||||||
// already-running sessions (e.g., started via CLI before UI opened)
|
// already-running sessions (e.g., started via CLI before UI opened)
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionStatus();
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
void bootstrapState('sse_reconnected');
|
void bootstrapState('sse_reconnected');
|
||||||
@@ -1825,8 +1713,7 @@ export const useEventStream = () => {
|
|||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
effectiveDirectory,
|
effectiveDirectory,
|
||||||
refreshSessionActivityStatus,
|
refreshSessionStatus,
|
||||||
waitForDesktopBridge,
|
|
||||||
debugConnectionState,
|
debugConnectionState,
|
||||||
bootstrapState
|
bootstrapState
|
||||||
]);
|
]);
|
||||||
@@ -1872,24 +1759,13 @@ export const useEventStream = () => {
|
|||||||
}, [scheduleReconnect]);
|
}, [scheduleReconnect]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const cooldownTimers = sessionCooldownTimersRef.current;
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.__messageTracker = trackMessage;
|
window.__messageTracker = trackMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
let desktopActivityHandler: ((event: CustomEvent<{ sessionId?: string; phase?: string }>) => void) | null = null;
|
// No-op
|
||||||
if (isDesktopRuntimeRef.current && typeof window !== 'undefined') {
|
|
||||||
desktopActivityHandler = (event: CustomEvent<{ sessionId?: string; phase?: string }>) => {
|
const desktopActivityHandler = null;
|
||||||
const sessionId = typeof event.detail?.sessionId === 'string' ? event.detail.sessionId : null;
|
|
||||||
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
|
|
||||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
|
||||||
updateSessionActivityPhase(sessionId, phase);
|
|
||||||
requestSessionMetadataRefresh(sessionId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearPauseTimeout = () => {
|
const clearPauseTimeout = () => {
|
||||||
if (pauseTimeoutRef.current) {
|
if (pauseTimeoutRef.current) {
|
||||||
@@ -1924,7 +1800,7 @@ export const useEventStream = () => {
|
|||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionStatus();
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
@@ -1949,7 +1825,7 @@ export const useEventStream = () => {
|
|||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow());
|
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow());
|
||||||
}
|
}
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionStatus();
|
||||||
|
|
||||||
publishStatus('connecting', 'Resuming stream');
|
publishStatus('connecting', 'Resuming stream');
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
@@ -1989,7 +1865,7 @@ export const useEventStream = () => {
|
|||||||
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow());
|
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow());
|
||||||
requestSessionMetadataRefresh(sessionId);
|
requestSessionMetadataRefresh(sessionId);
|
||||||
}
|
}
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionStatus();
|
||||||
startStream({ resetAttempts: true });
|
startStream({ resetAttempts: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2018,12 +1894,12 @@ export const useEventStream = () => {
|
|||||||
if (!shouldHoldConnection()) return;
|
if (!shouldHoldConnection()) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
const hasBusySessions = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some(
|
||||||
(phase) => phase === 'busy' || phase === 'cooldown'
|
(status) => status?.type === 'busy' || status?.type === 'retry'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasBusySessions) {
|
if (hasBusySessions) {
|
||||||
void refreshSessionActivityStatus();
|
void refreshSessionStatus();
|
||||||
}
|
}
|
||||||
if (now - lastEventTimestampRef.current > 45000) {
|
if (now - lastEventTimestampRef.current > 45000) {
|
||||||
Promise.resolve().then(async () => {
|
Promise.resolve().then(async () => {
|
||||||
@@ -2048,9 +1924,7 @@ export const useEventStream = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(startTimer);
|
clearTimeout(startTimer);
|
||||||
|
|
||||||
if (desktopActivityHandler && typeof window !== 'undefined') {
|
void desktopActivityHandler;
|
||||||
window.removeEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
@@ -2071,8 +1945,6 @@ export const useEventStream = () => {
|
|||||||
staleCheckIntervalRef.current = null;
|
staleCheckIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
cooldownTimers.forEach((timer) => clearTimeout(timer));
|
|
||||||
cooldownTimers.clear();
|
|
||||||
messageCache.clear();
|
messageCache.clear();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
||||||
notifiedMessagesRef.current.clear();
|
notifiedMessagesRef.current.clear();
|
||||||
@@ -2102,13 +1974,12 @@ export const useEventStream = () => {
|
|||||||
scheduleReconnect,
|
scheduleReconnect,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
requestSessionMetadataRefresh,
|
requestSessionMetadataRefresh,
|
||||||
updateSessionActivityPhase,
|
updateSessionStatus,
|
||||||
refreshSessionActivityStatus,
|
refreshSessionStatus,
|
||||||
shouldHoldConnection,
|
shouldHoldConnection,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
maybeBootstrapIfStale,
|
maybeBootstrapIfStale,
|
||||||
resyncMessages,
|
resyncMessages,
|
||||||
scheduleSoftResync,
|
scheduleSoftResync,
|
||||||
notifyOnSubtasks,
|
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { isDesktopRuntime, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
import { isTauriShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
||||||
|
|
||||||
export const useFileSystemAccess = () => {
|
export const useFileSystemAccess = () => {
|
||||||
const [isDesktop, setIsDesktop] = useState(false);
|
const [isDesktop, setIsDesktop] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDesktop(isDesktopRuntime());
|
setIsDesktop(isTauriShell());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
||||||
import { hasModifier } from '@/lib/utils';
|
import { hasModifier } from '@/lib/utils';
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||||
|
|
||||||
export const useKeyboardShortcuts = () => {
|
export const useKeyboardShortcuts = () => {
|
||||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
||||||
@@ -24,7 +24,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||||
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isDownloadingLogsRef = React.useRef(false);
|
|
||||||
|
|
||||||
const resetAbortPriming = React.useCallback(() => {
|
const resetAbortPriming = React.useCallback(() => {
|
||||||
if (abortPrimedTimeoutRef.current) {
|
if (abortPrimedTimeoutRef.current) {
|
||||||
@@ -44,35 +43,8 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
|
||||||
const diagnostics = runtimeAPIs?.diagnostics;
|
|
||||||
if (!diagnostics) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isDownloadingLogsRef.current) {
|
void showOpenCodeStatus();
|
||||||
return;
|
|
||||||
}
|
|
||||||
isDownloadingLogsRef.current = true;
|
|
||||||
|
|
||||||
diagnostics
|
|
||||||
.downloadLogs()
|
|
||||||
.then(({ fileName, content }) => {
|
|
||||||
const finalFileName = fileName || 'openchamber.log';
|
|
||||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = finalFileName;
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
document.body.removeChild(anchor);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isDownloadingLogsRef.current = false;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,25 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { isDesktopRuntime } from '@/lib/desktop';
|
import { isTauriShell } from '@/lib/desktop';
|
||||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||||
|
|
||||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||||
|
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
||||||
|
|
||||||
|
type TauriEventApi = {
|
||||||
|
listen?: (
|
||||||
|
event: string,
|
||||||
|
handler: (evt: { payload?: unknown }) => void
|
||||||
|
) => Promise<() => void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TauriGlobal = {
|
||||||
|
event?: TauriEventApi;
|
||||||
|
};
|
||||||
|
|
||||||
type MenuAction =
|
type MenuAction =
|
||||||
| 'about'
|
| 'about'
|
||||||
@@ -21,6 +33,7 @@ type MenuAction =
|
|||||||
| 'change-workspace'
|
| 'change-workspace'
|
||||||
| 'open-git-tab'
|
| 'open-git-tab'
|
||||||
| 'open-diff-tab'
|
| 'open-diff-tab'
|
||||||
|
| 'open-files-tab'
|
||||||
| 'open-terminal-tab'
|
| 'open-terminal-tab'
|
||||||
| 'theme-light'
|
| 'theme-light'
|
||||||
| 'theme-dark'
|
| 'theme-dark'
|
||||||
@@ -46,10 +59,9 @@ export const useMenuActions = (
|
|||||||
const { addProject } = useProjectsStore();
|
const { addProject } = useProjectsStore();
|
||||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { setThemeMode } = useThemeSystem();
|
const { setThemeMode } = useThemeSystem();
|
||||||
const isDownloadingLogsRef = React.useRef(false);
|
|
||||||
|
|
||||||
const handleChangeWorkspace = React.useCallback(() => {
|
const handleChangeWorkspace = React.useCallback(() => {
|
||||||
if (isDesktopRuntime()) {
|
if (isTauriShell()) {
|
||||||
requestAccess('')
|
requestAccess('')
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
if (!result.success || !result.path) {
|
if (!result.success || !result.path) {
|
||||||
@@ -80,15 +92,13 @@ export const useMenuActions = (
|
|||||||
console.error('Desktop: Error selecting directory:', error);
|
console.error('Desktop: Error selecting directory:', error);
|
||||||
toast.error('Failed to select directory');
|
toast.error('Failed to select directory');
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
sessionEvents.requestDirectoryDialog();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionEvents.requestDirectoryDialog();
|
||||||
}, [addProject, requestAccess, startAccessing]);
|
}, [addProject, requestAccess, startAccessing]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleAction = React.useCallback(
|
||||||
const handleMenuAction = (event: Event) => {
|
(action: MenuAction) => {
|
||||||
const action = (event as CustomEvent<MenuAction>).detail;
|
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'about':
|
case 'about':
|
||||||
setAboutDialogOpen(true);
|
setAboutDialogOpen(true);
|
||||||
@@ -130,6 +140,12 @@ export const useMenuActions = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'open-files-tab': {
|
||||||
|
const { activeMainTab } = useUIStore.getState();
|
||||||
|
setActiveMainTab(activeMainTab === 'files' ? 'chat' : 'files');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'open-terminal-tab': {
|
case 'open-terminal-tab': {
|
||||||
const { activeMainTab } = useUIStore.getState();
|
const { activeMainTab } = useUIStore.getState();
|
||||||
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
|
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
|
||||||
@@ -161,54 +177,86 @@ export const useMenuActions = (
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'download-logs': {
|
case 'download-logs': {
|
||||||
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
void showOpenCodeStatus().catch(() => {
|
||||||
const diagnostics = runtimeAPIs?.diagnostics;
|
toast.error('Failed to collect OpenCode status');
|
||||||
if (!diagnostics || isDownloadingLogsRef.current) {
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handleChangeWorkspace,
|
||||||
|
onToggleMemoryDebug,
|
||||||
|
openNewSessionDraft,
|
||||||
|
setAboutDialogOpen,
|
||||||
|
setActiveMainTab,
|
||||||
|
setSessionSwitcherOpen,
|
||||||
|
setSettingsDialogOpen,
|
||||||
|
setThemeMode,
|
||||||
|
toggleCommandPalette,
|
||||||
|
toggleHelpDialog,
|
||||||
|
toggleSidebar,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
isDownloadingLogsRef.current = true;
|
React.useEffect(() => {
|
||||||
diagnostics
|
const handleMenuAction = (event: Event) => {
|
||||||
.downloadLogs()
|
const action = (event as CustomEvent<MenuAction>).detail;
|
||||||
.then(({ fileName, content }) => {
|
if (!action) return;
|
||||||
const finalFileName = fileName || 'openchamber.log';
|
handleAction(action);
|
||||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = finalFileName;
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
document.body.removeChild(anchor);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
toast.success('Logs saved', {
|
|
||||||
description: `Downloaded to ~/Downloads/${finalFileName}`,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error('Failed to download logs');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isDownloadingLogsRef.current = false;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||||
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||||
}, [
|
}, [handleAction]);
|
||||||
openNewSessionDraft,
|
|
||||||
toggleCommandPalette,
|
React.useEffect(() => {
|
||||||
toggleHelpDialog,
|
if (typeof window === 'undefined') return;
|
||||||
toggleSidebar,
|
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||||
setSessionSwitcherOpen,
|
const listen = tauri?.event?.listen;
|
||||||
setActiveMainTab,
|
if (typeof listen !== 'function') return;
|
||||||
setSettingsDialogOpen,
|
|
||||||
setAboutDialogOpen,
|
let unlistenMenu: null | (() => void | Promise<void>) = null;
|
||||||
setThemeMode,
|
let unlistenUpdate: null | (() => void | Promise<void>) = null;
|
||||||
onToggleMemoryDebug,
|
|
||||||
handleChangeWorkspace,
|
listen('openchamber:menu-action', (evt) => {
|
||||||
]);
|
const action = evt?.payload;
|
||||||
|
if (typeof action !== 'string') return;
|
||||||
|
handleAction(action as MenuAction);
|
||||||
|
})
|
||||||
|
.then((fn) => {
|
||||||
|
unlistenMenu = fn;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
listen('openchamber:check-for-updates', () => {
|
||||||
|
window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT));
|
||||||
|
})
|
||||||
|
.then((fn) => {
|
||||||
|
unlistenUpdate = fn;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const cleanup = async () => {
|
||||||
|
try {
|
||||||
|
const a = unlistenMenu?.();
|
||||||
|
if (a instanceof Promise) await a;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const b = unlistenUpdate?.();
|
||||||
|
if (b instanceof Promise) await b;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void cleanup();
|
||||||
|
};
|
||||||
|
}, [handleAction]);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user