diff --git a/.github/last-synced-tag b/.github/last-synced-tag index ed64856bbe5..d4c36391866 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.0.204 +v1.0.207 diff --git a/CONTEXT/PLAN-210-211-212-open-issues-2025-12-28.md b/CONTEXT/PLAN-210-211-212-open-issues-2025-12-28.md new file mode 100644 index 00000000000..cefc97247b6 --- /dev/null +++ b/CONTEXT/PLAN-210-211-212-open-issues-2025-12-28.md @@ -0,0 +1,172 @@ +# Project Plan: Resolve Open Issues 210, 211, 212 (2025-12-28) + +## Scope Overview +This plan addresses three open issues in Latitudes-Dev/shuvcode: +- #210: Local dev mode skips installing .opencode tool dependencies +- #211: Add directory picker + name field to Create New project flow +- #212: Archived sessions reappear due to client-side filtering gaps and unused pipe() result + +## Context & Decisions Captured +### Issue 210: .opencode tool dependencies not installed in local dev +- Root cause: `installDependencies()` in `packages/opencode/src/config/config.ts` returns early when `Installation.isLocal()` is true, so `@opencode-ai/plugin` is not installed for `.opencode/tool` in `bun dev`. +- Production builds do install dependencies; local dev skips them, causing import errors in tool registry loading. +- Acceptance criteria from issue: + - Custom tools in `.opencode/tool/` load without errors when running `bun dev`. + - Fix must not break production builds. + - Dependencies should install for local dev without conflicting with global config. + - Backward compatible with existing `.opencode` configs. +- Options explicitly discussed in issue: + - A) Remove local early return (simple; may cause repeated installs). + - B) Add docs/setup script (manual, not automatic). + - C) Bundle dependencies during local dev (extend plugin bundling pattern). + - D) Install on first tool load failure and retry. +- Decision: Align with upstream by implementing Option A (remove local early return) with a per-directory, concurrency-safe guard to avoid repeated installs while keeping behavior consistent. +- Guard definition (required): + - Use a deterministic marker file in each config dir (e.g., `.opencode/.deps-installed.json`) storing the installed version and timestamp. + - Skip install when marker matches the expected version (`Installation.BASE_VERSION` for non-local, `latest` for local). + - Serialize installs per directory to avoid concurrent `bun add` calls. + +### Issue 211: Add directory picker + name field to Create New flow +- Current UI in `packages/app/src/components/dialog-create-project.tsx` has two tabs: Add Existing and Create New. Create New uses a single absolute path field and optional repo URL. +- Desired behavior: + - Three distinct flows: Add Existing, Create New, Git Clone. + - Create New: pick parent directory + enter project name; show resolved path with validation; creates new folder. + - Git Clone: repo URL + target folder picker; derive folder name from repo if blank; optional degit. + - Add Existing stays unchanged. +- API integration: `project.create` endpoint in `packages/opencode/src/server/project.ts` and `packages/opencode/src/project/project.ts` accepts `path`, optional `repo`, optional `degit`, optional `name`. +- Critical semantic clarification: + - The server does NOT build the directory path from `name`; it only uses `path` to create folders. + - The client must construct the absolute `path` (parent + folder name) before calling `project.create`. + - The `name` field is metadata only (display name) and must not be relied on for path composition. +- Platform constraints: + - `openDirectoryPickerDialog` is Tauri-only; web must use manual path input. + - The UI must gate picker usage on availability and keep a text input fallback. + +### Issue 212: Archived sessions reappear +- Root causes: + - Client-side: `packages/app/src/context/sync.tsx` lacks archived filtering during initial session load and pagination fetch. + - Server-side: `packages/opencode/src/server/server.ts` calls `pipe(...)` but discards its result, returning unfiltered sessions. +- Upstream works due to `global-sync.tsx` filtering; fork uses `sync.tsx` without filtering. +- Acceptance criteria: + - Filter archived sessions in `sync.tsx` initial load and fetch. + - Fix server endpoint to return filtered sessions. + - Avoid duplicated listing and preserve intended sort order. + +## Code References (Internal) +- Issue 210: + - `packages/opencode/src/config/config.ts` + - `packages/opencode/src/tool/registry.ts` + - `packages/opencode/src/plugin/index.ts` + - `packages/opencode/src/installation/index.ts` +- Issue 211: + - `packages/app/src/components/dialog-create-project.tsx` + - `packages/opencode/src/server/project.ts` + - `packages/opencode/src/project/project.ts` + - `packages/app/src/context/platform.tsx` (for `openDirectoryPickerDialog` usage) +- Issue 212: + - `packages/app/src/context/sync.tsx` + - `packages/opencode/src/server/server.ts` + - `packages/app/src/context/global-sync.tsx` (existing filtering behavior) + +## External References (Git URLs) +- Directory picker usage example (React/Web File System Access API): + - https://github.com/dinoosauro/easy-backup/blob/a20006f442103b52521419255fe20331760b6d2e/src/App.tsx +- Cross-runtime directory picker abstraction: + - https://github.com/ayonli/jsext/blob/ccf374f99a190ad497b5cec974ea20badbe1c8de/dialog/file.ts + +## Technical Specifications +### API Endpoints +- `GET /project/browse` (browse directories) +- `POST /project` (`project.create`) +- `GET /session` (`session.list`) + +### Data Models +- `Project.Create` request fields (from `Project.create.schema`): + - `path: string` (absolute, required, client-composed from parent + folder name) + - `name?: string` (display name only) + - `repo?: string` + - `degit?: boolean` +- `Project.CreateResult`: + - `project: Project.Info` + - `created: boolean` +- `Session.Info` includes `time.archived?: number` (used for filtering) + +### Configuration & Flags +| Item | Location | Purpose | Notes | +| --- | --- | --- | --- | +| `Installation.isLocal()` | `packages/opencode/src/installation/index.ts` | Detect local dev channel | `OPENCODE_CHANNEL === "local"` | +| `installDependencies()` | `packages/opencode/src/config/config.ts` | Adds `@opencode-ai/plugin` | Needs per-dir guard and concurrency control | + +## Milestones & Implementation Order +### Milestone 1: Fix archived session filtering (Issue 212) +- Rationale: Small, isolated change with clear acceptance criteria; reduces user-facing bug quickly. + +### Milestone 2: Update Add Project modal flows (Issue 211) +- Rationale: Larger UI change with dependencies on picker behavior and validation; benefits from stable session list behavior. + +### Milestone 3: Fix local tool dependency install (Issue 210) +- Rationale: Dev workflow improvement; can be implemented after UI work without impacting feature logic. + +## Detailed Task Breakdown +### Issue 212: Archived sessions filtering +- [x] Add `.filter((s) => !s.time.archived)` to initial session load in `packages/app/src/context/sync.tsx`. +- [x] Add `.filter((s) => !s.time.archived)` to `fetch` in `packages/app/src/context/sync.tsx`. +- [x] Fix server-side filtering to return filtered sessions by assigning `pipe(...)` result to `sessions` in `packages/opencode/src/server/server.ts`. +- [x] Remove duplicate `Session.list()` call and preserve intended sort order (use the filtered list). +- [x] Add/adjust tests in `packages/opencode/test/` to ensure archived sessions are excluded from list responses. +- [x] Validate UI list no longer shows archived sessions when reloading or paginating. + +### Issue 211: Add Project modal flows +- [x] Expand tab model to three states: `existing`, `create`, `clone` in `packages/app/src/components/dialog-create-project.tsx`. +- [x] Extract shared path preview/validation helper for Create New + Git Clone where feasible. +- [x] Create New flow: + - [x] Add parent directory picker using `platform.openDirectoryPickerDialog` when available; otherwise use text input. + - [x] Add required project name input. + - [x] Compute and display resolved path (`parentDir + projectName`) on the client. + - [x] Validate name/path (non-empty; no path separators; no traversal; resolved path absolute) and show inline errors. +- [x] Git Clone flow: + - [x] Add repo URL input. + - [x] Add parent directory picker with fallback text input. + - [x] Add optional project name; derive from repo URL when empty (no path separators). + - [x] Add optional degit toggle. + - [x] Show resolved target path before submission. +- [x] Wire Create New and Git Clone to `globalSDK.client.project.create({ path, repo?, degit?, name? })`. + - `path` must be fully composed on the client; `name` is display-only metadata. +- [x] Ensure Add Existing flow behavior remains unchanged. +- [x] Add/adjust tests in `packages/opencode/test/` covering `project.create` path composition and validation for create/clone scenarios. +- [x] Smoke test all three flows: Add Existing, Create New, Git Clone. + +### Issue 210: Local dev tool dependency install +- [x] Modify `installDependencies()` in `packages/opencode/src/config/config.ts` to allow local install with a per-directory guard (align with upstream behavior). +- [x] Implement a deterministic marker file (e.g., `.opencode/.deps-installed.json`) storing installed version and timestamp; skip when valid. +- [x] Serialize installs per directory to avoid concurrent `bun add` when multiple config directories load in parallel. +- [x] Ensure `.opencode/tool` installs do not affect global config. +- [x] Add/adjust tests in `packages/opencode/test/` to verify tool loading works in local dev mode without manual install. + - Avoid network-dependent installs; mock or stub install paths where needed. +- [x] Update any dev docs if required (only if solution adds a manual step). + +## Validation Criteria +### Functional Validation +- Issue 212: + - Archived sessions never appear in session list after load or fetch. + - Server `/session` returns filtered (and sorted) list without duplicate listing. +- Issue 211: + - Create New builds correct path from parent + name and creates directory. + - Git Clone derives folder name from repo URL when blank. + - Name input rejects path separators and traversal sequences. + - Add Existing is unchanged and still works. +- Issue 210: + - Running `bun dev` in repo loads `.opencode/tool` without import errors. + - Install guard prevents repeated installs on subsequent config loads. + - Production build behavior unaffected. + +### Test Execution (Required) +- [x] Add tests for new/changed behaviors in `packages/opencode/test/`. +- [x] Run tests before committing: + - `bun test` in `packages/opencode` (preferred for targeted tests) + - or `bun turbo test` at repo root + +## Open Questions / Decisions Needed +- None. Decisions locked: Issue 210 uses Option A with guard; Issue 211 composes `path` in the client and uses `name` as metadata; Issue 212 includes server-side filtering and removes duplicate listing. + +(End of file) diff --git a/CONTEXT/PLAN-213-custom-server-url-settings-2025-12-28.md b/CONTEXT/PLAN-213-custom-server-url-settings-2025-12-28.md new file mode 100644 index 00000000000..6213d5127df --- /dev/null +++ b/CONTEXT/PLAN-213-custom-server-url-settings-2025-12-28.md @@ -0,0 +1,487 @@ +# Plan: Custom Server URL Settings + +## Plan Overview + +Add custom server URL configuration to the web/desktop app while preserving our existing sophisticated URL resolution logic. This feature allows users to manually override the server URL via query parameter or a settings dialog, with localStorage persistence. + +**Approach**: Instead of merging upstream PR #6312 directly, we implement the feature natively using our existing patterns and architecture. + +## Issue Context + +- **Upstream Reference**: https://github.com/sst/opencode/pull/6312 (for feature inspiration only) +- **Goal**: Allow custom server URLs when OpenCode is served on non-standard paths (e.g., `https://domain.com/workspace/1`) +- **Key Use Case**: Connect `desktop.shuv.ai` (Cloudflare Pages hosted) to a local OpenCode server via `?url=http://localhost:4096` +- **Constraint**: Must preserve our fork's richer URL resolution (HTTPS detection, shuv.ai host, same-origin rules, web command logic) + +## Decisions & Rationale + +| Decision | Rationale | +|----------|-----------| +| **Use dialog instead of separate route** | Fits our existing dialog patterns (DialogSelectProvider, DialogCreateProject). Avoids routing complexity with `/:dir` pattern. | +| **Module-level localStorage read** | URL must be resolved before React/Solid renders. Cannot use `persisted()` hook at module level. | +| **Shared localStorage keys** | Module-level and component-level code share keys for consistency. | +| **Page reload on URL change** | SDK is initialized once with URL. Changing URL requires full reload to reinitialize. | +| **URL validation before storage** | Prevent storing malformed URLs that would break the app. | +| **Keep existing fallback chain** | Our HTTPS/same-origin/known-host logic handles reverse proxies correctly. | + +## URL Resolution Priority (New) + +``` +1. ?url= query parameter → Use and persist to localStorage +2. localStorage stored URL → Use if present (NEW) +3. Tauri injected port → Desktop app with local server +4. Same-origin mode → HTTPS, known hosts, web command +5. Host:port fallback → Dev mode explicit server +``` + +**Same-origin triggers** (preserved from current logic): +- `location.protocol === "https:"` (avoid mixed content) +- Hostname includes `opencode.ai` or `shuv.ai` +- Hostname ends with `.local` +- Loopback in non-dev mode +- Web command mode (`!import.meta.env.DEV`) + +## Technical Specifications + +### LocalStorage Configuration + +| Key | Type | Default | Purpose | +|-----|------|---------|---------| +| `opencode:server-url` | `string \| null` | `null` | Current active server URL override | +| `opencode:server-url-history` | `string[]` (JSON) | `[]` | Last 5 unique URLs for quick selection | + +**Note**: Using `opencode:` prefix to namespace our keys, following pattern similar to `layout.v3`. + +### URL Validation + +```typescript +function isValidServerUrl(url: string): boolean { + if (!url || !url.trim()) return false + try { + const parsed = new URL(url) + return ['http:', 'https:'].includes(parsed.protocol) + } catch { + return false + } +} +``` + +### Mixed Content Detection + +When the app is served over HTTPS (e.g., `desktop.shuv.ai`), browsers block HTTP API requests to non-localhost origins. We detect this and warn users in the dialog. + +```typescript +/** + * Check if setting this URL would cause mixed content issues. + * Returns true if: + * - Current page is HTTPS, AND + * - Target URL is HTTP, AND + * - Target is NOT localhost/127.0.0.1 (browsers allow this exception) + */ +function hasMixedContentRisk(targetUrl: string): boolean { + if (location.protocol !== 'https:') return false + + try { + const parsed = new URL(targetUrl) + if (parsed.protocol !== 'http:') return false + + // Localhost is allowed even from HTTPS (secure context exception) + const isLocalhost = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) + return !isLocalhost + } catch { + return false + } +} +``` + +**Allowed from HTTPS pages**: +| Target URL | Allowed | Reason | +|------------|---------|--------| +| `http://localhost:4096` | Yes | Localhost exception | +| `http://127.0.0.1:4096` | Yes | Loopback exception | +| `https://my-server.com` | Yes | HTTPS to HTTPS | +| `http://192.168.1.100:4096` | No | Mixed content blocked | +| `http://my-server.com` | No | Mixed content blocked | + +### History Management + +- Max 5 entries, most recent first +- Deduplicate by normalized URL (lowercase, trailing slash stripped) +- Add to history on successful URL set (query param or dialog) + +```typescript +function normalizeUrl(url: string): string { + return url.toLowerCase().replace(/\/+$/, '') +} +``` + +## Affected Files + +| File | Change Type | Description | +|------|-------------|-------------| +| `packages/app/src/app.tsx` | Modify | Add localStorage read/write in URL resolution | +| `packages/app/src/lib/server-url.ts` | **New** | Shared URL utilities (validation, history, constants) | +| `packages/app/src/components/dialog-server-settings.tsx` | **New** | Server settings dialog component | +| `packages/app/src/pages/layout.tsx` | Modify | Add sidebar button to open dialog | +| `packages/app/src/pages/error.tsx` | Modify | Add reset button for connection errors | +| `packages/app/src/context/command.tsx` | Modify | Add command palette entry (optional) | + +## Implementation Plan + +### Milestone 1: Server URL Utilities + +Create `packages/app/src/lib/server-url.ts` with shared logic: + +```typescript +// Constants +export const SERVER_URL_KEY = "opencode:server-url" +export const SERVER_URL_HISTORY_KEY = "opencode:server-url-history" +export const MAX_HISTORY = 5 + +// Validation +export function isValidServerUrl(url: string): boolean + +// Mixed content detection +export function hasMixedContentRisk(targetUrl: string): boolean + +// Normalization +export function normalizeUrl(url: string): string + +// History management +export function getServerUrlHistory(): string[] +export function addToServerUrlHistory(url: string): void +export function clearServerUrlHistory(): void + +// Current URL management +export function getStoredServerUrl(): string | null +export function setStoredServerUrl(url: string): void +export function clearStoredServerUrl(): void +``` + +**Tasks**: +- [x] Create `packages/app/src/lib/server-url.ts` +- [x] Implement URL validation with protocol check +- [x] Implement mixed content risk detection +- [x] Implement history load/save with max limit +- [x] Implement URL normalization for deduplication +- [x] Export all utilities + +### Milestone 2: App URL Resolution Integration + +Modify `packages/app/src/app.tsx` to integrate localStorage: + +**Current code (lines 31-68)**: +```typescript +const host = import.meta.env.VITE_OPENCODE_SERVER_HOST || ... +const port = window.__OPENCODE__?.port ?? ... +// ... same-origin logic ... +const url = new URLSearchParams(document.location.search).get("url") || ... +``` + +**New code**: +```typescript +import { iife } from "@opencode-ai/util/iife" +import { + getStoredServerUrl, + setStoredServerUrl, + isValidServerUrl, + addToServerUrlHistory, + SERVER_URL_KEY +} from "@/lib/server-url" + +const url = iife(() => { + // 1. Query parameter (highest priority) - persist if valid + const queryUrl = new URLSearchParams(document.location.search).get("url") + if (queryUrl && isValidServerUrl(queryUrl)) { + setStoredServerUrl(queryUrl) + addToServerUrlHistory(queryUrl) + return queryUrl + } + + // 2. Stored URL override + const storedUrl = getStoredServerUrl() + if (storedUrl) return storedUrl + + // 3-5. Existing logic (preserved exactly) + const host = import.meta.env.VITE_OPENCODE_SERVER_HOST || location.hostname || "127.0.0.1" + const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? location.port ?? "4096" + + const isSecure = location.protocol === "https:" + const isKnownHost = location.hostname.includes("opencode.ai") || + location.hostname.includes("shuv.ai") || + location.hostname.endsWith(".local") + const isLoopback = ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) + const isWebCommand = !import.meta.env.DEV + const useSameOrigin = isSecure || isKnownHost || (isLoopback && !import.meta.env.DEV) || isWebCommand + + // 3. Tauri desktop + if (window.__OPENCODE__?.port) { + return `http://${host}:${window.__OPENCODE__.port}` + } + + // 4. Same-origin mode + if (useSameOrigin) { + return "/" + } + + // 5. Explicit host:port (dev mode) + return `http://${host}:${port}` +}) +``` + +**Tasks**: +- [x] Add `iife` import from `@opencode-ai/util/iife` +- [x] Add imports from `@/lib/server-url` +- [x] Wrap URL resolution in `iife()` for cleaner structure +- [x] Add query param persistence with validation +- [x] Add localStorage read after query param check +- [x] Preserve all existing same-origin/Tauri/fallback logic + +### Milestone 3: Server Settings Dialog + +Create `packages/app/src/components/dialog-server-settings.tsx`: + +**Features**: +- Display current effective URL (computed or stored) +- Input field for custom URL with validation +- **Mixed content warning** when setting HTTP URL from HTTPS page (except localhost) +- "Apply" button that saves and reloads +- History list for quick selection (last 5) +- "Clear" button to remove override and reload +- Visual indicator if using stored vs computed URL + +**UI Structure**: +``` +┌─────────────────────────────────────────┐ +│ Server Settings [X]│ +├─────────────────────────────────────────┤ +│ Current server URL │ +│ ┌─────────────────────────────────────┐ │ +│ │ https://custom-server.com ✓ │ │ (green check = stored override) +│ └─────────────────────────────────────┘ │ (gray = computed default) +│ │ +│ Set custom URL │ +│ ┌───────────────────────────────┐ ┌───┐ │ +│ │ http://192.168.1.5:4096 │ │Set│ │ +│ └───────────────────────────────┘ └───┘ │ +│ ⚠ This HTTP URL will be blocked by │ <- Warning shown conditionally +│ your browser (mixed content). Use │ +│ localhost or HTTPS instead. │ +│ │ +│ Recent URLs │ +│ ┌─────────────────────────────────────┐ │ +│ │ https://server-1.com │ │ +│ │ http://localhost:4096 ✓ │ │ (checkmark = safe) +│ └─────────────────────────────────────┘ │ +│ │ +│ [Clear override] │ +└─────────────────────────────────────────┘ +``` + +**Mixed Content Warning Logic**: +```typescript +import { hasMixedContentRisk } from "@/lib/server-url" + +// In component: +const showMixedContentWarning = () => hasMixedContentRisk(inputUrl()) + +// In JSX: + +
+ + + This HTTP URL will be blocked by your browser (mixed content). + Use localhost or an HTTPS URL instead. + +
+
+``` + +**Tasks**: +- [x] Create `packages/app/src/components/dialog-server-settings.tsx` +- [x] Add current URL display with override indicator +- [x] Add input field with real-time validation +- [x] Add mixed content warning (shown when `hasMixedContentRisk()` returns true) +- [x] Add "Set" button with `showToast` feedback and reload +- [x] Add history list from localStorage +- [x] Add "Clear override" button +- [x] Style consistent with existing dialogs (DialogSelectProvider pattern) + +### Milestone 4: Sidebar Integration + +Modify `packages/app/src/pages/layout.tsx` to add dialog trigger: + +**Location**: After "Create project" button (around line 1158), before "Share feedback" + +```typescript +import { DialogServerSettings } from "@/components/dialog-server-settings" + +// In sidebar actions section: + + + +``` + +**Tasks**: +- [x] Import `DialogServerSettings` component +- [x] Add sidebar button after "Create project" (line ~1158) +- [x] Use `dialog.show()` pattern consistent with other dialogs +- [x] Use `settings-gear` icon (or `server` if available) + +### Milestone 5: Error Page Recovery + +Modify `packages/app/src/pages/error.tsx` to add reset option: + +**Connection error detection**: +```typescript +function isConnectionError(error: unknown): boolean { + const message = formatError(error).toLowerCase() + return message.includes("could not connect") || + message.includes("econnrefused") || + message.includes("fetch failed") || + message.includes("network error") +} +``` + +**Add reset button** (only shown for connection errors when override is set): +```typescript +import { getStoredServerUrl, clearStoredServerUrl } from "@/lib/server-url" + +// In ErrorPage component: +const hasServerOverride = () => !!getStoredServerUrl() + +function resetServerUrl() { + clearStoredServerUrl() + platform.restart() +} + +// In JSX, after Restart button: + + +

+ Using custom server URL. Reset to use default. +

+
+``` + +**Tasks**: +- [x] Import server URL utilities +- [x] Add `isConnectionError` helper function +- [x] Add `hasServerOverride` check +- [x] Add conditional reset button +- [x] Add explanatory text for users + +### Milestone 6: Command Palette Entry (Optional) + +Add command to open server settings: + +```typescript +// In layout.tsx command.register(): +{ + id: "settings.server", + title: "Server settings", + category: "Settings", + onSelect: () => dialog.show(() => ), +} +``` + +**Tasks**: +- [x] Add command registration in layout.tsx +- [ ] Test command palette search + +## Step-by-Step Implementation Order + +1. [x] Create `packages/app/src/lib/server-url.ts` with all utilities +2. [x] Modify `packages/app/src/app.tsx` to use new URL resolution +3. [x] Create `packages/app/src/components/dialog-server-settings.tsx` +4. [x] Add sidebar button in `packages/app/src/pages/layout.tsx` +5. [x] Add error recovery in `packages/app/src/pages/error.tsx` +6. [x] (Optional) Add command palette entry +7. [ ] Manual testing of all scenarios +8. [x] Run `bun typecheck` in packages/app + +## Validation Criteria + +### Functional Tests (Manual) + +- [ ] **Default behavior preserved**: Without stored URL, app uses existing resolution logic +- [ ] **Query param override**: `/?url=http://localhost:5000` sets and persists URL +- [ ] **Query param validation**: Invalid URLs (e.g., `/?url=not-a-url`) are ignored +- [ ] **Stored URL used**: After setting via query/dialog, reload uses stored URL +- [ ] **Dialog set URL**: Setting URL via dialog shows toast and reloads +- [ ] **Dialog clear URL**: Clearing override reloads with default resolution +- [ ] **History tracking**: Last 5 URLs appear in dialog +- [ ] **History deduplication**: Same URL doesn't appear twice +- [ ] **Error recovery**: Connection error shows reset button when override set +- [ ] **HTTPS preserved**: On HTTPS host, same-origin still works without override +- [ ] **Tauri preserved**: Desktop app with `__OPENCODE__.port` still works +- [ ] **shuv.ai preserved**: Known host detection still triggers same-origin + +### Mixed Content Warning Tests (on desktop.shuv.ai or any HTTPS host) + +- [ ] **No warning for localhost**: Entering `http://localhost:4096` shows NO warning +- [ ] **No warning for 127.0.0.1**: Entering `http://127.0.0.1:4096` shows NO warning +- [ ] **No warning for HTTPS**: Entering `https://my-server.com` shows NO warning +- [ ] **Warning for HTTP LAN IP**: Entering `http://192.168.1.100:4096` shows warning +- [ ] **Warning for HTTP domain**: Entering `http://my-server.com` shows warning +- [ ] **No warning on HTTP page**: When app is served over HTTP, no warnings shown + +### Build Verification + +- [x] `bun typecheck` passes in packages/app +- [ ] `bun build` succeeds +- [ ] No console errors on load + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Regression in URL resolution | High | Preserve existing logic exactly; only add localStorage read before it | +| Invalid URL breaks app | High | Validate URLs before storage; provide reset mechanism | +| Mixed content on HTTPS | Medium | Validate protocol; warn if setting HTTP URL on HTTPS page | +| Reload loop if bad URL | Medium | Error page reset button; clear localStorage in devtools docs | +| History grows unbounded | Low | Cap at 5 entries; oldest removed automatically | + +## Security Considerations + +1. **XSS via URL parameter**: URLs are used as SDK base URL, not rendered as HTML. Low risk. +2. **Open redirect**: Not applicable - URL is for API calls, not navigation. +3. **Mixed content**: + - Browser blocks HTTP API calls from HTTPS pages (except localhost) + - Dialog shows warning when user enters a risky URL + - URL is still saved (user may know what they're doing), but warning educates + - `localhost` and `127.0.0.1` are exempt (browser secure context exception) +4. **localStorage access**: Standard browser storage, no sensitive data stored. + +## Future Enhancements (Out of Scope) + +- URL connection test before saving (ping endpoint) +- Multiple server profiles with names +- Import/export settings +- Sync settings across devices + +## File Structure After Implementation + +``` +packages/app/src/ +├── lib/ +│ └── server-url.ts # NEW - URL utilities +├── components/ +│ ├── dialog-server-settings.tsx # NEW - Settings dialog +│ └── ...existing... +├── pages/ +│ ├── layout.tsx # MODIFIED - sidebar button +│ ├── error.tsx # MODIFIED - reset button +│ └── ...existing... +└── app.tsx # MODIFIED - URL resolution +``` diff --git a/STATS.md b/STATS.md index 41c93525478..6c155da05ad 100644 --- a/STATS.md +++ b/STATS.md @@ -183,3 +183,4 @@ | 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | | 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | | 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | +| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | diff --git a/bun.lock b/bun.lock index 3015c33511f..d0d9cb32195 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -71,7 +71,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -99,7 +99,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -126,7 +126,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -150,7 +150,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -174,7 +174,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -201,7 +201,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -230,7 +230,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -246,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.204", + "version": "1.0.207", "bin": { "opencode": "./bin/opencode", }, @@ -349,7 +349,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -369,7 +369,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.204", + "version": "1.0.207", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -380,7 +380,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -393,7 +393,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -428,7 +428,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "zod": "catalog:", }, @@ -439,7 +439,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/flake.lock b/flake.lock index 8bba6eeb3df..dcc8c594a70 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766747458, - "narHash": "sha256-m63jjuo/ygo8ztkCziYh5OOIbTSXUDkKbqw3Vuqu4a4=", + "lastModified": 1766840161, + "narHash": "sha256-Ss/LHpJJsng8vz1Pe33RSGIWUOcqM1fjrehjUkdrWio=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c633f572eded8c4f3c75b8010129854ed404a6ce", + "rev": "3edc4a30ed3903fdf6f90c837f961fa6b49582d1", "type": "github" }, "original": { diff --git a/packages/app/package.json b/packages/app/package.json index 88708a37c44..dc4a8cf99fe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.204", + "version": "1.0.207", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 612f0921f6f..12a28b39587 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -8,6 +8,7 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { CodeComponentProvider } from "@opencode-ai/ui/context/code" import { Diff } from "@opencode-ai/ui/diff" import { Code } from "@opencode-ai/ui/code" +import { ThemeProvider } from "@opencode-ai/ui/theme" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" @@ -21,6 +22,8 @@ import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" import Session from "@/pages/session" import { ErrorPage } from "./pages/error" +import { iife } from "@opencode-ai/util/iife" +import { getStoredServerUrl, setStoredServerUrl, isValidServerUrl, addToServerUrlHistory } from "@/lib/server-url" declare global { interface Window { @@ -28,91 +31,130 @@ declare global { } } -const host = import.meta.env.VITE_OPENCODE_SERVER_HOST || location.hostname || "127.0.0.1" -const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? location.port ?? "4096" +// URL priority: +// 1. ?url= query parameter (explicit override) - persist if valid +// 2. Stored URL override from localStorage +// 3. Tauri injected port (desktop app with local server) +// 4. Same-origin mode uses relative "/" to hit the proxy +// 5. Other cases fall back to explicit host:port (dev mode) +const OPENCODE_THEME_STORAGE_KEYS = [ + "opencode-theme-id", + "opencode-color-scheme", + "opencode-theme-css-light", + "opencode-theme-css-dark", +] -// Check if we should use same-origin requests (relative "/" URL) -// This is needed when: -// - Running behind a reverse proxy (HTTPS) that proxies API requests -// - Running on known production hosts -// - Running the web command (API and frontend served from same server) -// In local dev mode with HTTP, we can hit the API server directly -const isSecure = location.protocol === "https:" -const isKnownHost = - location.hostname.includes("opencode.ai") || - location.hostname.includes("shuv.ai") || - location.hostname.endsWith(".local") -const isLoopback = ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) -// When accessed via non-loopback IP (e.g., LAN IP), we're still on the same server -// so we should use same-origin mode. Dev mode with Vite needs explicit host:port. -const isWebCommand = !import.meta.env.DEV +if (typeof window !== "undefined") { + for (const key of OPENCODE_THEME_STORAGE_KEYS) { + localStorage.removeItem(key) + } + document.getElementById("oc-theme")?.remove() + document.getElementById("oc-theme-preload")?.remove() + document.documentElement.removeAttribute("data-color-scheme") +} -// Use same-origin when: -// - On HTTPS (must use same-origin to avoid mixed content) -// - On known production hosts -// - On loopback in non-dev mode (production build) -// - On any host in non-dev mode (web command serves API and frontend together) -const useSameOrigin = isSecure || isKnownHost || (isLoopback && !import.meta.env.DEV) || isWebCommand +const url = iife(() => { + // 1. Query parameter (highest priority) - persist if valid + const queryUrl = new URLSearchParams(document.location.search).get("url") + if (queryUrl && isValidServerUrl(queryUrl)) { + setStoredServerUrl(queryUrl) + addToServerUrlHistory(queryUrl) + return queryUrl + } -// URL priority: -// 1. ?url= query parameter (explicit override) -// 2. Tauri injected port (desktop app with local server) -// 3. Same-origin mode uses relative "/" to hit the proxy -// 4. Other cases fall back to explicit host:port (dev mode) -const url = - new URLSearchParams(document.location.search).get("url") || - (window.__OPENCODE__?.port - ? `http://${host}:${window.__OPENCODE__.port}` - : useSameOrigin - ? "/" - : `http://${host}:${port}`) + // 2. Stored URL override + const storedUrl = getStoredServerUrl() + if (storedUrl) return storedUrl + + // 3-5. Existing logic (preserved exactly) + const host = import.meta.env.VITE_OPENCODE_SERVER_HOST || location.hostname || "127.0.0.1" + const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? location.port ?? "4096" + + // Check if we should use same-origin requests (relative "/" URL) + // This is needed when: + // - Running behind a reverse proxy (HTTPS) that proxies API requests + // - Running on known production hosts + // - Running the web command (API and frontend served from same server) + // In local dev mode with HTTP, we can hit the API server directly + const isSecure = location.protocol === "https:" + const isKnownHost = + location.hostname.includes("opencode.ai") || + location.hostname.includes("shuv.ai") || + location.hostname.endsWith(".local") + const isLoopback = ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) + // When accessed via non-loopback IP (e.g., LAN IP), we're still on the same server + // so we should use same-origin mode. Dev mode with Vite needs explicit host:port. + const isWebCommand = !import.meta.env.DEV + + // Use same-origin when: + // - On HTTPS (must use same-origin to avoid mixed content) + // - On known production hosts + // - On loopback in non-dev mode (production build) + // - On any host in non-dev mode (web command serves API and frontend together) + const useSameOrigin = isSecure || isKnownHost || (isLoopback && !import.meta.env.DEV) || isWebCommand + + // 3. Tauri desktop + if (window.__OPENCODE__?.port) { + return `http://${host}:${window.__OPENCODE__.port}` + } + + // 4. Same-origin mode + if (useSameOrigin) { + return "/" + } + + // 5. Explicit host:port (dev mode) + return `http://${host}:${port}` +}) export function App() { return ( - }> - - - - - - - - - ( - - {props.children} - - )} - > - - - } /> - ( - - - - - - - - )} - /> - - - - - - - - - - - + + }> + + + + + + + + + ( + + {props.children} + + )} + > + + + } /> + ( + + + + + + + + )} + /> + + + + + + + + + + + + ) } diff --git a/packages/app/src/components/dialog-create-project.tsx b/packages/app/src/components/dialog-create-project.tsx index f743824cc69..d406b617fb8 100644 --- a/packages/app/src/components/dialog-create-project.tsx +++ b/packages/app/src/components/dialog-create-project.tsx @@ -23,6 +23,34 @@ interface DirectoryInfo { isExistingProject: boolean } +// Helper to validate project name (no path separators, no traversal) +function validateProjectName(name: string): string | undefined { + if (!name.trim()) return "Project name is required" + if (name.includes("/") || name.includes("\\")) return "Project name cannot contain path separators" + if (name === "." || name === "..") return "Invalid project name" + if (name.includes("..")) return "Project name cannot contain path traversal" + return undefined +} + +// Helper to derive folder name from repo URL +function deriveFolderNameFromRepo(repoUrl: string): string { + if (!repoUrl.trim()) return "" + // Remove trailing slashes, .git suffix, and get the last path segment + const cleaned = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "") + const parts = cleaned.split("/") + const lastPart = parts[parts.length - 1] || "" + // Remove any remaining path separators from the derived name + return lastPart.replace(/[/\\]/g, "") +} + +// Helper to compute resolved path +function computeResolvedPath(parentDir: string, projectName: string): string { + if (!parentDir || !projectName.trim()) return "" + // Normalize parent dir (remove trailing slash) + const normalizedParent = parentDir.replace(/\/+$/, "") + return `${normalizedParent}/${projectName.trim()}` +} + export const DialogCreateProject: Component = () => { const dialog = useDialog() const globalSDK = useGlobalSDK() @@ -31,19 +59,61 @@ export const DialogCreateProject: Component = () => { const platform = usePlatform() const sync = useGlobalSync() - const [activeTab, setActiveTab] = createSignal<"create" | "existing">("existing") + const [activeTab, setActiveTab] = createSignal<"existing" | "create" | "clone">("existing") const [selectedDir, setSelectedDir] = createSignal(null) const [store, setStore] = createStore({ - path: "", - repo: "", - degit: false, + // Common error: undefined as string | undefined, loading: false, + // Create New flow + createParentDir: "", + createProjectName: "", + // Clone flow + cloneRepoUrl: "", + cloneParentDir: "", + cloneProjectName: "", // Optional, derived from repo if empty + cloneDegit: false, }) const homedir = createMemo(() => sync.data.path.home || "~") + // Computed resolved paths + const createResolvedPath = createMemo(() => computeResolvedPath(store.createParentDir, store.createProjectName)) + + const cloneDerivedName = createMemo(() => { + if (store.cloneProjectName.trim()) return store.cloneProjectName.trim() + return deriveFolderNameFromRepo(store.cloneRepoUrl) + }) + + const cloneResolvedPath = createMemo(() => computeResolvedPath(store.cloneParentDir, cloneDerivedName())) + + // Validation for Create New + const createNameError = createMemo(() => validateProjectName(store.createProjectName)) + const createPathError = createMemo(() => { + const path = createResolvedPath() + if (!path) return undefined + if (!path.startsWith("/") && !path.startsWith("~")) return "Path must be absolute" + return undefined + }) + + // Validation for Clone + const cloneRepoError = createMemo(() => { + if (!store.cloneRepoUrl.trim()) return "Repository URL is required" + return undefined + }) + const cloneNameError = createMemo(() => { + const name = cloneDerivedName() + if (!name) return "Project name is required (enter manually or provide valid repo URL)" + return validateProjectName(name) + }) + const clonePathError = createMemo(() => { + const path = cloneResolvedPath() + if (!path) return undefined + if (!path.startsWith("/") && !path.startsWith("~")) return "Path must be absolute" + return undefined + }) + // Fetch directories for browsing - returns a function for the List component async function fetchDirectories(query: string): Promise { const result = await globalSDK.client.project.browse({ @@ -62,14 +132,25 @@ export const DialogCreateProject: Component = () => { async function handleCreateSubmit(e: SubmitEvent) { e.preventDefault() - const path = store.path?.trim() - if (!path) { - setStore("error", "Project path is required") + // Validate + const nameError = createNameError() + if (nameError) { + setStore("error", nameError) + return + } + if (!store.createParentDir) { + setStore("error", "Parent directory is required") + return + } + const pathError = createPathError() + if (pathError) { + setStore("error", pathError) return } - if (!path.startsWith("/") && !path.startsWith("~/")) { - setStore("error", "Path must be absolute (start with / or ~)") + const resolvedPath = createResolvedPath() + if (!resolvedPath) { + setStore("error", "Could not resolve project path") return } @@ -78,9 +159,8 @@ export const DialogCreateProject: Component = () => { try { const result = await globalSDK.client.project.create({ - path, - repo: store.repo.trim() || undefined, - degit: store.degit, + path: resolvedPath, + name: store.createProjectName.trim(), }) if (result.error) { @@ -109,6 +189,73 @@ export const DialogCreateProject: Component = () => { } } + async function handleCloneSubmit(e: SubmitEvent) { + e.preventDefault() + + // Validate + const repoError = cloneRepoError() + if (repoError) { + setStore("error", repoError) + return + } + if (!store.cloneParentDir) { + setStore("error", "Parent directory is required") + return + } + const nameError = cloneNameError() + if (nameError) { + setStore("error", nameError) + return + } + const pathError = clonePathError() + if (pathError) { + setStore("error", pathError) + return + } + + const resolvedPath = cloneResolvedPath() + if (!resolvedPath) { + setStore("error", "Could not resolve project path") + return + } + + setStore("error", undefined) + setStore("loading", true) + + try { + const result = await globalSDK.client.project.create({ + path: resolvedPath, + repo: store.cloneRepoUrl.trim(), + degit: store.cloneDegit, + name: cloneDerivedName(), + }) + + if (result.error) { + const errorMessage = (result.error as { message?: string }).message || "Failed to clone repository" + setStore("error", errorMessage) + setStore("loading", false) + return + } + + const { project, created } = result.data! + dialog.close() + openProject(project.worktree) + + showToast({ + variant: "success", + icon: "circle-check", + title: created ? "Repository cloned" : "Project added", + description: created + ? `Cloned to ${project.worktree.replace(homedir(), "~")}` + : `Added ${project.worktree.replace(homedir(), "~")}`, + }) + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : "Failed to clone repository" + setStore("error", errorMessage) + setStore("loading", false) + } + } + async function handleAddExisting(dir?: DirectoryInfo | null) { const directory = dir ?? selectedDir() if (!directory) return @@ -144,7 +291,7 @@ export const DialogCreateProject: Component = () => { } } - async function handleBrowse() { + async function handleBrowseExisting() { const result = await platform.openDirectoryPickerDialog?.({ title: "Select folder to add as project", multiple: false, @@ -177,6 +324,26 @@ export const DialogCreateProject: Component = () => { } } + async function handleBrowseCreateParent() { + const result = await platform.openDirectoryPickerDialog?.({ + title: "Select parent directory for new project", + multiple: false, + }) + if (result && typeof result === "string") { + setStore("createParentDir", result) + } + } + + async function handleBrowseCloneParent() { + const result = await platform.openDirectoryPickerDialog?.({ + title: "Select parent directory for cloned repository", + multiple: false, + }) + if (result && typeof result === "string") { + setStore("cloneParentDir", result) + } + } + function handleSelect(dir: DirectoryInfo | undefined) { if (!dir) return if (dir.isExistingProject) { @@ -210,6 +377,14 @@ export const DialogCreateProject: Component = () => { Create New + {/* Add Existing tab content */} @@ -221,7 +396,7 @@ export const DialogCreateProject: Component = () => { {/* Directory list with search */} - class="flex-1 min-h-0 [&_[data-slot=list-item]]:h-auto [&_[data-slot=list-item]]:py-2" + class="flex-1 min-h-0 [&_[data-slot=list-item]]:h-auto [&_[data-slot=list-item]]:py-1" items={fetchDirectories} key={(dir) => dir.path} filterKeys={["name", "path"]} @@ -231,21 +406,14 @@ export const DialogCreateProject: Component = () => { emptyMessage="No folders found" > {(dir) => ( -
- - - -
-
{dir.name}
-
{dir.path.replace(homedir(), "~")}
-
+
+ + {dir.path.replace(homedir(), "~")} - git + git - - open - + open
)} @@ -253,8 +421,8 @@ export const DialogCreateProject: Component = () => { {/* Browse button */} - @@ -273,51 +441,224 @@ export const DialogCreateProject: Component = () => { {/* Create New tab content */} -
+
- Enter the full path where you want to create your new project. A new directory will be created and + Select a parent directory and enter a name for your new project. A new folder will be created and initialized as a git repository.
+ + {/* Parent directory browser */} +
+ + +
+ + + {store.createParentDir.replace(homedir(), "~")} + + +
+
+ + + class="flex-1 min-h-0 max-h-40 [&_[data-slot=list-item]]:h-auto [&_[data-slot=list-item]]:py-1" + items={fetchDirectories} + key={(dir) => dir.path} + filterKeys={["name", "path"]} + onSelect={(dir) => dir && setStore("createParentDir", dir.path)} + search={{ placeholder: "Search folders...", autofocus: activeTab() === "create" }} + emptyMessage="No folders found" + > + {(dir) => ( +
+ + {dir.path.replace(homedir(), "~")} +
+ )} + + + + +
+
+ + {/* Project name */} setStore("path", value)} - validationState={store.error && activeTab() === "create" ? "invalid" : undefined} - error={activeTab() === "create" ? store.error : undefined} + label="Project name" + placeholder="my-new-app" + name="createProjectName" + value={store.createProjectName} + onChange={(value) => setStore("createProjectName", value)} + validationState={createNameError() ? "invalid" : undefined} + error={createNameError()} /> + + {/* Resolved path preview */} + +
+ +
+ {createResolvedPath().replace(homedir(), "~")} +
+
+
+ + +
{store.error}
+
+ +
+ + +
+ +
+ + {/* Git Clone tab content */} + +
+
+ Clone a git repository into a new project folder. +
+ + {/* Repository URL */} setStore("repo", value)} + name="cloneRepoUrl" + value={store.cloneRepoUrl} + onChange={(value) => setStore("cloneRepoUrl", value)} + /> + + {/* Parent directory browser */} +
+ + +
+ + + {store.cloneParentDir.replace(homedir(), "~")} + + +
+
+ + + class="flex-1 min-h-0 max-h-40 [&_[data-slot=list-item]]:h-auto [&_[data-slot=list-item]]:py-1" + items={fetchDirectories} + key={(dir) => dir.path} + filterKeys={["name", "path"]} + onSelect={(dir) => dir && setStore("cloneParentDir", dir.path)} + search={{ placeholder: "Search folders..." }} + emptyMessage="No folders found" + > + {(dir) => ( +
+ + {dir.path.replace(homedir(), "~")} +
+ )} + + + + +
+
+ + {/* Project name (optional, derived from repo) */} + setStore("cloneProjectName", value)} + validationState={store.cloneProjectName.trim() && cloneNameError() ? "invalid" : undefined} + error={store.cloneProjectName.trim() ? cloneNameError() : undefined} /> + + {/* Resolved path preview */} + +
+ +
+ {cloneResolvedPath().replace(homedir(), "~")} +
+
+
+ + {/* Degit toggle */}
- setStore("degit", checked)} - disabled={!store.repo.trim()} - > + setStore("cloneDegit", checked)}> Degit (remove .git history after cloning)
+ + +
{store.error}
+
+
-
diff --git a/packages/app/src/components/dialog-server-settings.tsx b/packages/app/src/components/dialog-server-settings.tsx new file mode 100644 index 00000000000..a8e0d569513 --- /dev/null +++ b/packages/app/src/components/dialog-server-settings.tsx @@ -0,0 +1,187 @@ +import { Component, createMemo, createSignal, For, Show } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { usePlatform } from "@/context/platform" +import { + getStoredServerUrl, + setStoredServerUrl, + clearStoredServerUrl, + getServerUrlHistory, + addToServerUrlHistory, + isValidServerUrl, + hasMixedContentRisk, +} from "@/lib/server-url" + +export const DialogServerSettings: Component = () => { + const platform = usePlatform() + + // Get current stored URL (if any) + const storedUrl = getStoredServerUrl() + const history = getServerUrlHistory() + + // Track the input value + const [inputUrl, setInputUrl] = createSignal("") + const [isSubmitting, setIsSubmitting] = createSignal(false) + + // Current effective URL display + const currentUrl = createMemo(() => { + if (storedUrl) return storedUrl + // Show what the default URL would be + return window.location.origin === "file://" ? "http://localhost:4096" : window.location.origin + }) + + const hasOverride = createMemo(() => !!storedUrl) + + // Validation + const inputValid = createMemo(() => { + const url = inputUrl().trim() + if (!url) return false + return isValidServerUrl(url) + }) + + const showMixedContentWarning = createMemo(() => { + const url = inputUrl().trim() + if (!url) return false + return hasMixedContentRisk(url) + }) + + // Set a new server URL + async function handleSetUrl() { + const url = inputUrl().trim() + if (!isValidServerUrl(url)) { + showToast({ + variant: "error", + icon: "circle-x", + title: "Invalid URL", + description: "Please enter a valid HTTP or HTTPS URL.", + }) + return + } + + setIsSubmitting(true) + setStoredServerUrl(url) + addToServerUrlHistory(url) + + showToast({ + variant: "success", + icon: "circle-check", + title: "Server URL updated", + description: "Reloading to apply changes...", + }) + + // Short delay to show the toast, then reload + setTimeout(() => { + platform.restart?.() ?? window.location.reload() + }, 500) + } + + // Select a URL from history + function handleSelectHistory(url: string) { + setInputUrl(url) + } + + // Clear the override and reload + function handleClearOverride() { + clearStoredServerUrl() + showToast({ + variant: "success", + icon: "circle-check", + title: "Server URL reset", + description: "Reloading to apply changes...", + }) + + setTimeout(() => { + platform.restart?.() ?? window.location.reload() + }, 500) + } + + return ( + +
+ {/* Current URL display */} +
+
Current server URL
+
+ {currentUrl()} + + + + + (default) + +
+
+ + {/* Set custom URL */} +
+
Set custom URL
+
+ setInputUrl(e.currentTarget.value)} + placeholder="http://localhost:4096" + class="flex-1 font-mono" + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter" && inputValid()) { + handleSetUrl() + } + }} + /> + +
+ + {/* Mixed content warning */} + +
+ + + This HTTP URL will be blocked by your browser (mixed content). Use{" "} + localhost or an HTTPS URL instead. + +
+
+
+ + {/* History */} + 0}> +
+
Recent URLs
+
+ + {(url) => { + const isSafe = !hasMixedContentRisk(url) + return ( + + ) + }} + +
+
+
+ + {/* Clear override button */} + +
+ +
+
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 257f788e208..7ddea7a4e7e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -248,6 +248,7 @@ export const PromptInput: Component = (props) => { } const handlePaste = async (event: ClipboardEvent) => { + if (!isFocused()) return const clipboardData = event.clipboardData if (!clipboardData) return @@ -270,7 +271,7 @@ export const PromptInput: Component = (props) => { addPart({ type: "text", content: plainText, start: 0, end: 0 }) } - const handleDragOver = (event: DragEvent) => { + const handleGlobalDragOver = (event: DragEvent) => { event.preventDefault() const hasFiles = event.dataTransfer?.types.includes("Files") if (hasFiles) { @@ -278,15 +279,14 @@ export const PromptInput: Component = (props) => { } } - const handleDragLeave = (event: DragEvent) => { - const related = event.relatedTarget as Node | null - const form = event.currentTarget as HTMLElement - if (!related || !form.contains(related)) { + const handleGlobalDragLeave = (event: DragEvent) => { + // relatedTarget is null when leaving the document window + if (!event.relatedTarget) { setStore("dragging", false) } } - const handleDrop = async (event: DragEvent) => { + const handleGlobalDrop = async (event: DragEvent) => { event.preventDefault() setStore("dragging", false) @@ -302,9 +302,15 @@ export const PromptInput: Component = (props) => { onMount(() => { editorRef.addEventListener("paste", handlePaste) + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) }) onCleanup(() => { editorRef.removeEventListener("paste", handlePaste) + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) }) createEffect(() => { @@ -1044,9 +1050,6 @@ export const PromptInput: Component = (props) => { { const isTouchDevice = "ontouchstart" in window const isMobileInputEnabled = isCoarsePointer || isTouchDevice const [socket, setSocket] = createSignal() + const [terminalColors, setTerminalColors] = createSignal(getTerminalTheme()) let isMounted = true let ws: WebSocket let term: Term @@ -85,6 +86,36 @@ export const Terminal = (props: TerminalProps) => { let onTerminalThemeChange: () => void let pendingThemeRefresh: number | undefined + const focusTerminal = () => term?.focus() + const copySelection = () => { + if (!term || !term.hasSelection()) return false + const selection = term.getSelection() + if (!selection) return false + const clipboard = navigator.clipboard + if (clipboard?.writeText) { + clipboard.writeText(selection).catch(() => {}) + return true + } + if (!document.body) return false + const textarea = document.createElement("textarea") + textarea.value = selection + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + document.body.removeChild(textarea) + return copied + } + const handlePointerDown = () => { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && activeElement !== container) { + activeElement.blur() + } + focusTerminal() + } + onMount(async () => { ghostty = await Ghostty.load() if (!isMounted) return @@ -98,17 +129,28 @@ export const Terminal = (props: TerminalProps) => { const buildTerminal = (snapshot?: TerminalSnapshot) => { if (!isMounted) return + const theme = getTerminalTheme() + setTerminalColors(theme) term = new Term({ cursorBlink: true, fontSize: 14, fontFamily: "meslo, Menlo, Monaco, Courier New, monospace", allowTransparency: true, - theme: getTerminalTheme(), + theme, scrollback: 10_000, ghostty, }) term.attachCustomKeyEventHandler((event) => { - if (event.ctrlKey && event.key.toLowerCase() === "`") { + const key = event.key.toLowerCase() + if (key === "c") { + const macCopy = event.metaKey && !event.ctrlKey && !event.altKey + const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey + if ((macCopy || linuxCopy) && copySelection()) { + event.preventDefault() + return true + } + } + if (event.ctrlKey && key === "`") { event.preventDefault() return true } @@ -121,6 +163,8 @@ export const Terminal = (props: TerminalProps) => { term.loadAddon(fitAddon) term.open(container) + container.addEventListener("pointerdown", handlePointerDown) + focusTerminal() if (snapshot?.cols && snapshot?.rows) { term.resize(snapshot.cols, snapshot.rows) @@ -236,6 +280,7 @@ export const Terminal = (props: TerminalProps) => { if (handleResize) { window.removeEventListener("resize", handleResize) } + container.removeEventListener("pointerdown", handlePointerDown) if (onTerminalThemeChange) { document.documentElement.removeEventListener("terminal-theme-changed", onTerminalThemeChange) } @@ -270,6 +315,7 @@ export const Terminal = (props: TerminalProps) => { ref={container} data-component="terminal" data-prevent-autofocus + style={{ "background-color": terminalColors().background }} classList={{ ...(local.classList ?? {}), "size-full px-3 sm:px-6 py-3 font-mono relative": true, diff --git a/packages/app/src/components/theme-picker.tsx b/packages/app/src/components/theme-picker.tsx index 487280d8f7f..b8a2062330f 100644 --- a/packages/app/src/components/theme-picker.tsx +++ b/packages/app/src/components/theme-picker.tsx @@ -8,7 +8,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLayout } from "@/context/layout" import { THEMES, getThemeById, applyTheme, type Theme } from "@/theme/apply-theme" -function DialogSelectTheme(props: { originalTheme: string }) { +export function DialogSelectTheme(props: { originalTheme: string }) { const layout = useLayout() const dialog = useDialog() const [previewTheme, setPreviewTheme] = createSignal(props.originalTheme) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 34248478b1d..f4308d4cef1 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -26,6 +26,7 @@ export interface CommandOption { suggested?: boolean disabled?: boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void + onHighlight?: () => (() => void) | void } export function parseKeybind(config: string): Keybind[] { @@ -115,6 +116,28 @@ export function formatKeybind(config: string): string { function DialogCommand(props: { options: CommandOption[] }) { const dialog = useDialog() + let cleanup: (() => void) | void + let committed = false + + const handleMove = (option: CommandOption | undefined) => { + cleanup?.() + cleanup = option?.onHighlight?.() + } + + const handleSelect = (option: CommandOption | undefined) => { + if (option) { + committed = true + cleanup = undefined + dialog.close() + option.onSelect?.("palette") + } + } + + onCleanup(() => { + if (!committed) { + cleanup?.() + } + }) return ( @@ -125,12 +148,8 @@ function DialogCommand(props: { options: CommandOption[] }) { key={(x) => x?.id} filterKeys={["title", "description", "category"]} groupBy={(x) => x.category ?? ""} - onSelect={(option) => { - if (option) { - dialog.close() - option.onSelect?.("palette") - } - }} + onMove={handleMove} + onSelect={handleSelect} > {(option) => (
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 15fc3908170..58216fcc345 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -86,7 +86,6 @@ function createGlobalSync() { }) const children: Record>> = {} - const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set() function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -336,7 +335,6 @@ function createGlobalSync() { } case "permission.updated": { const permissions = store.permission[event.properties.sessionID] - const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id) if (!permissions) { setStore("permission", event.properties.sessionID, [event.properties]) } else { @@ -353,11 +351,6 @@ function createGlobalSync() { }), ) } - if (isNew) { - for (const listener of permissionListeners) { - listener({ directory, permission: event.properties }) - } - } break } case "permission.replied": { @@ -374,6 +367,15 @@ function createGlobalSync() { ) break } + case "lsp.updated": { + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + directory, + throwOnError: true, + }) + sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])) + break + } } }) @@ -446,12 +448,6 @@ function createGlobalSync() { project: { loadSessions, }, - permission: { - onUpdated(listener: (info: { directory: string; permission: Permission }) => void) { - permissionListeners.add(listener) - return () => permissionListeners.delete(listener) - }, - }, } } diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 3a769c7f155..9ef3b534b7c 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -24,6 +24,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.list().then((x) => { const sessions = (x.data ?? []) .slice() + .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) .slice(0, store.limit) setStore("session", sessions) @@ -113,6 +114,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ await sdk.client.session.list().then((x) => { const sessions = (x.data ?? []) .slice() + .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) .slice(0, store.limit) setStore("session", sessions) diff --git a/packages/app/src/lib/server-url.ts b/packages/app/src/lib/server-url.ts new file mode 100644 index 00000000000..c3e609cffa9 --- /dev/null +++ b/packages/app/src/lib/server-url.ts @@ -0,0 +1,149 @@ +/** + * Server URL management utilities for custom server URL configuration. + * + * This module provides shared logic for: + * - URL validation + * - Mixed content risk detection + * - URL history management + * - Stored URL get/set/clear + */ + +// Constants +export const SERVER_URL_KEY = "opencode:server-url" +export const SERVER_URL_HISTORY_KEY = "opencode:server-url-history" +export const MAX_HISTORY = 5 + +/** + * Validate that a URL is a valid server URL. + * Only HTTP and HTTPS protocols are allowed. + */ +export function isValidServerUrl(url: string): boolean { + if (!url || !url.trim()) return false + try { + const parsed = new URL(url) + return ["http:", "https:"].includes(parsed.protocol) + } catch { + return false + } +} + +/** + * Check if setting this URL would cause mixed content issues. + * + * Returns true if: + * - Current page is HTTPS, AND + * - Target URL is HTTP, AND + * - Target is NOT localhost/127.0.0.1 (browsers allow this exception) + */ +export function hasMixedContentRisk(targetUrl: string): boolean { + if (typeof location === "undefined") return false + if (location.protocol !== "https:") return false + + try { + const parsed = new URL(targetUrl) + if (parsed.protocol !== "http:") return false + + // Localhost is allowed even from HTTPS (secure context exception) + const isLocalhost = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) + return !isLocalhost + } catch { + return false + } +} + +/** + * Normalize a URL for comparison and deduplication. + * Lowercases and strips trailing slashes. + */ +export function normalizeUrl(url: string): string { + return url.toLowerCase().replace(/\/+$/, "") +} + +/** + * Get the list of recent server URLs from localStorage. + */ +export function getServerUrlHistory(): string[] { + try { + const stored = localStorage.getItem(SERVER_URL_HISTORY_KEY) + if (!stored) return [] + const parsed = JSON.parse(stored) + if (!Array.isArray(parsed)) return [] + // Filter to only valid URLs + return parsed.filter((url): url is string => typeof url === "string" && isValidServerUrl(url)) + } catch { + return [] + } +} + +/** + * Add a URL to the history list. + * Deduplicates by normalized URL and keeps only the most recent MAX_HISTORY entries. + */ +export function addToServerUrlHistory(url: string): void { + if (!isValidServerUrl(url)) return + + const history = getServerUrlHistory() + const normalized = normalizeUrl(url) + + // Remove any existing entry with the same normalized URL + const filtered = history.filter((u) => normalizeUrl(u) !== normalized) + + // Add the new URL at the beginning + const updated = [url, ...filtered].slice(0, MAX_HISTORY) + + try { + localStorage.setItem(SERVER_URL_HISTORY_KEY, JSON.stringify(updated)) + } catch { + // Ignore storage errors + } +} + +/** + * Clear all URL history. + */ +export function clearServerUrlHistory(): void { + try { + localStorage.removeItem(SERVER_URL_HISTORY_KEY) + } catch { + // Ignore storage errors + } +} + +/** + * Get the currently stored server URL override. + */ +export function getStoredServerUrl(): string | null { + try { + const stored = localStorage.getItem(SERVER_URL_KEY) + if (!stored) return null + // Validate before returning + if (!isValidServerUrl(stored)) return null + return stored + } catch { + return null + } +} + +/** + * Set a custom server URL override. + * The URL must be valid or it will not be stored. + */ +export function setStoredServerUrl(url: string): void { + if (!isValidServerUrl(url)) return + try { + localStorage.setItem(SERVER_URL_KEY, url) + } catch { + // Ignore storage errors + } +} + +/** + * Clear the stored server URL override. + */ +export function clearStoredServerUrl(): void { + try { + localStorage.removeItem(SERVER_URL_KEY) + } catch { + // Ignore storage errors + } +} diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 37bd5ccd3cb..1700df849b3 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -1,9 +1,17 @@ -import { TextField } from "@opencode-ai/ui/text-field" -import { Logo } from "@opencode-ai/ui/logo" +import { AsciiLogo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component, Show } from "solid-js" +import { TextField } from "@opencode-ai/ui/text-field" +import { Component, createMemo, createSignal, Show } from "solid-js" import { usePlatform } from "@/context/platform" import { Icon } from "@opencode-ai/ui/icon" +import { + addToServerUrlHistory, + clearStoredServerUrl, + getStoredServerUrl, + hasMixedContentRisk, + isValidServerUrl, + setStoredServerUrl, +} from "@/lib/server-url" export type InitError = { name: string @@ -112,16 +120,59 @@ function formatError(error: unknown): string { return formatErrorChain(error, 0) } +function isConnectionError(error: unknown): boolean { + const message = formatError(error).toLowerCase() + return ( + message.includes("could not connect") || + message.includes("econnrefused") || + message.includes("fetch failed") || + message.includes("network error") || + message.includes("failed to fetch") + ) +} + interface ErrorPageProps { error: unknown } export const ErrorPage: Component = (props) => { const platform = usePlatform() + + const hasServerOverride = createMemo(() => !!getStoredServerUrl()) + const showResetButton = createMemo(() => isConnectionError(props.error) && hasServerOverride()) + const showServerConfig = createMemo(() => isConnectionError(props.error)) + const [serverUrl, setServerUrl] = createSignal(getStoredServerUrl() ?? "") + const inputValid = createMemo(() => { + const url = serverUrl().trim() + return url.length > 0 && isValidServerUrl(url) + }) + const showInvalidUrl = createMemo(() => { + const url = serverUrl().trim() + return url.length > 0 && !isValidServerUrl(url) + }) + const showMixedContentWarning = createMemo(() => { + const url = serverUrl().trim() + if (!url) return false + return hasMixedContentRisk(url) + }) + + function resetServerUrl() { + clearStoredServerUrl() + platform.restart?.() ?? window.location.reload() + } + + function applyServerUrl() { + const url = serverUrl().trim() + if (!isValidServerUrl(url)) return + setStoredServerUrl(url) + addToServerUrlHistory(url) + platform.restart?.() ?? window.location.reload() + } + return (
- +

Something went wrong

An error occurred while loading the application.

@@ -135,16 +186,59 @@ export const ErrorPage: Component = (props) => { label="Error Details" hideLabel /> - + +
+
Server
+
+ setServerUrl(e.currentTarget.value)} + placeholder="http://localhost:4096" + class="flex-1 font-mono" + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter" && inputValid()) { + applyServerUrl() + } + }} + /> + +
+ +

Enter a valid HTTP or HTTPS URL.

+
+ +
+ + + This HTTP URL will be blocked by your browser (mixed content). Use{" "} + localhost or an HTTPS URL instead. + +
+
+
+
+
+ + + +

+ Using custom server URL. Reset to use default. +

+
+
- Please report this error to the OpenCode team + Please report this error to the shuvcode team - {/* */} - {/* */} - {/* */} + + + + +
{JSON.stringify(task(), null, 2)}
+
+
+ ) + })()} + +
+ + ) +} diff --git a/packages/console/app/src/routes/bench/index.tsx b/packages/console/app/src/routes/bench/index.tsx new file mode 100644 index 00000000000..9b8d0b8f24f --- /dev/null +++ b/packages/console/app/src/routes/bench/index.tsx @@ -0,0 +1,86 @@ +import { Title } from "@solidjs/meta" +import { A, createAsync, query } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js" +import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" + +interface BenchmarkResult { + averageScore: number + tasks: { averageScore: number; task: { id: string } }[] +} + +async function getBenchmarks() { + "use server" + const rows = await Database.use((tx) => + tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100), + ) + return rows.map((row) => { + const parsed = JSON.parse(row.result) as BenchmarkResult + const taskScores: Record = {} + for (const t of parsed.tasks) { + taskScores[t.task.id] = t.averageScore + } + return { + id: row.id, + agent: row.agent, + model: row.model, + averageScore: parsed.averageScore, + taskScores, + } + }) +} + +const queryBenchmarks = query(getBenchmarks, "benchmarks.list") + +export default function Bench() { + const benchmarks = createAsync(() => queryBenchmarks()) + + const taskIds = createMemo(() => { + const ids = new Set() + for (const row of benchmarks() ?? []) { + for (const id of Object.keys(row.taskScores)) { + ids.add(id) + } + } + return [...ids].sort() + }) + + return ( +
+ Benchmark +

Benchmarks

+ + + + + + + {(id) => } + + + + + {(row) => ( + + + + + + {(id) => ( + + )} + + + )} + + +
AgentModelScore{id}
{row.agent}{row.model}{row.averageScore.toFixed(3)} + + + {row.taskScores[id]?.toFixed(3)} + + +
+
+ ) +} diff --git a/packages/console/app/src/routes/bench/submission.ts b/packages/console/app/src/routes/bench/submission.ts new file mode 100644 index 00000000000..94639439b11 --- /dev/null +++ b/packages/console/app/src/routes/bench/submission.ts @@ -0,0 +1,29 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Database } from "@opencode-ai/console-core/drizzle/index.js" +import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" +import { Identifier } from "@opencode-ai/console-core/identifier.js" + +interface SubmissionBody { + model: string + agent: string + result: string +} + +export async function POST(event: APIEvent) { + const body = (await event.request.json()) as SubmissionBody + + if (!body.model || !body.agent || !body.result) { + return Response.json({ error: "All fields are required" }, { status: 400 }) + } + + await Database.use((tx) => + tx.insert(BenchmarkTable).values({ + id: Identifier.create("benchmark"), + model: body.model, + agent: body.agent, + result: body.result, + }), + ) + + return Response.json({ success: true }, { status: 200 }) +} diff --git a/packages/console/core/migrations/0039_striped_forge.sql b/packages/console/core/migrations/0039_striped_forge.sql new file mode 100644 index 00000000000..ad823197fb1 --- /dev/null +++ b/packages/console/core/migrations/0039_striped_forge.sql @@ -0,0 +1,12 @@ +CREATE TABLE `benchmark` ( + `id` varchar(30) NOT NULL, + `time_created` timestamp(3) NOT NULL DEFAULT (now()), + `time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `time_deleted` timestamp(3), + `model` varchar(64) NOT NULL, + `agent` varchar(64) NOT NULL, + `result` mediumtext NOT NULL, + CONSTRAINT `benchmark_id_pk` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `time_created` ON `benchmark` (`time_created`); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0039_snapshot.json b/packages/console/core/migrations/meta/0039_snapshot.json new file mode 100644 index 00000000000..ba34f1ac490 --- /dev/null +++ b/packages/console/core/migrations/meta/0039_snapshot.json @@ -0,0 +1,1053 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "49a1ac05-78ab-4aae-908e-d4aeeb8196fc", + "prevId": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 8a1a38551fb..e96bf52ed09 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1764110043942, "tag": "0038_famous_magik", "breakpoints": true + }, + { + "idx": 39, + "version": "5", + "when": 1766946179892, + "tag": "0039_striped_forge", + "breakpoints": true } ] } diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f74d28b2e32..1f205090fe1 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.204", + "version": "1.0.207", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/core/src/identifier.ts b/packages/console/core/src/identifier.ts index 8fdf79cde28..f94765ec702 100644 --- a/packages/console/core/src/identifier.ts +++ b/packages/console/core/src/identifier.ts @@ -5,6 +5,7 @@ export namespace Identifier { const prefixes = { account: "acc", auth: "aut", + benchmark: "ben", billing: "bil", key: "key", model: "mod", diff --git a/packages/console/core/src/schema/benchmark.sql.ts b/packages/console/core/src/schema/benchmark.sql.ts new file mode 100644 index 00000000000..8d435eddfd8 --- /dev/null +++ b/packages/console/core/src/schema/benchmark.sql.ts @@ -0,0 +1,14 @@ +import { index, mediumtext, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core" +import { id, timestamps } from "../drizzle/types" + +export const BenchmarkTable = mysqlTable( + "benchmark", + { + id: id(), + ...timestamps, + model: varchar("model", { length: 64 }).notNull(), + agent: varchar("agent", { length: 64 }).notNull(), + result: mediumtext("result").notNull(), + }, + (table) => [primaryKey({ columns: [table.id] }), index("time_created").on(table.timeCreated)], +) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 57b004fb709..e769f1a0e9f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.204", + "version": "1.0.207", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index f2c7c7302f9..74cf1440e2b 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.204", + "version": "1.0.207", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 23aa11091fb..dc619cf198e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.0.204", + "version": "1.0.207", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index e4a7f45beae..8c8336e3868 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.204", + "version": "1.0.207", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 3e01e835339..70da5b4bd8a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.204" +version = "1.0.207" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 44c6ef110ef..1fa670dea65 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.204", + "version": "1.0.207", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index d7b987cbb94..9afe227b326 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -2,3 +2,5 @@ preload = ["@opentui/solid/preload"] [test] preload = ["./test/preload.ts"] +# Enable code coverage +coverage = true diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 539f604a5d9..fe8f37028a2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.204", + "version": "1.0.207", "name": "opencode", "type": "module", "private": true, diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index d6bd84798de..4733efad0fc 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -158,6 +158,29 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string } return { owner: match[1], repo: match[2] } } +/** + * Extracts displayable text from assistant response parts. + * Returns null for tool-only or reasoning-only responses (signals summary needed). + * Throws for truly unusable responses (empty, step-start only, etc.). + */ +export function extractResponseText(parts: MessageV2.Part[]): string | null { + // Priority 1: Look for text parts + const textPart = parts.findLast((p) => p.type === "text") + if (textPart) return textPart.text + + // Priority 2: Reasoning-only - return null to signal summary needed + const reasoningPart = parts.findLast((p) => p.type === "reasoning") + if (reasoningPart) return null + + // Priority 3: Tool-only - return null to signal summary needed + const toolParts = parts.filter((p) => p.type === "tool" && p.state.status === "completed") + if (toolParts.length > 0) return null + + // No usable parts - throw with debug info + const partTypes = parts.map((p) => p.type).join(", ") || "none" + throw new Error(`Failed to parse response. Part types found: [${partTypes}]`) +} + export const GithubCommand = cmd({ command: "github", describe: "manage GitHub agent", @@ -890,10 +913,41 @@ export const GithubRunCommand = cmd({ ) } - const match = result.parts.findLast((p) => p.type === "text") - if (!match) throw new Error("Failed to parse the text response") + const text = extractResponseText(result.parts) + if (text) return text + + // No text part (tool-only or reasoning-only) - ask agent to summarize + console.log("Requesting summary from agent...") + const summary = await SessionPrompt.prompt({ + sessionID: session.id, + messageID: Identifier.ascending("message"), + model: { + providerID, + modelID, + }, + tools: { "*": false }, // Disable all tools to force text response + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.", + }, + ], + }) + + if (summary.info.role === "assistant" && summary.info.error) { + console.error(summary.info) + throw new Error( + `${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`, + ) + } + + const summaryText = extractResponseText(summary.parts) + if (!summaryText) { + throw new Error("Failed to get summary from agent") + } - return match.text + return summaryText } async function getOidcToken() { diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index f41b23ee971..94f1b549f40 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -82,12 +82,21 @@ async function getAllSessions(): Promise { return sessions } -async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { +export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { const sessions = await getAllSessions() - const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 - const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0 + const MS_IN_DAY = 24 * 60 * 60 * 1000 + + const cutoffTime = (() => { + if (days === undefined) return 0 + if (days === 0) { + const now = new Date() + now.setHours(0, 0, 0, 0) + return now.getTime() + } + return Date.now() - days * MS_IN_DAY + })() - let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions + let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions if (projectFilter !== undefined) { if (projectFilter === "") { @@ -198,7 +207,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } } - const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND)) + const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) stats.dateRange = { earliest: earliestTime, latest: latestTime, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index fc0559cd686..bc90dbb5c6e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) { const recents = local.model.recent() const recentList = showExtra() - ? recents - .filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ) - .slice(0, 5) + ? recents.filter( + (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), + ) : [] const favoriteOptions = favorites.flatMap((item) => { @@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) { // Apply fuzzy filtering to each section separately, maintaining section order if (q) { const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) - const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj) + const filteredRecents = fuzzysort + .go(q, recentOptions, { keys: ["title"] }) + .map((x) => x.obj) + .slice(0, 5) const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 121bfcff26f..db030efe8bc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -13,8 +13,37 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" -// Regex to parse line range from file reference (e.g., "file.ts#L10-20") -const LINE_RANGE_REGEX = /^(.+?)(?:#L(\d+)(?:-(\d+))?)?$/ +function removeLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + return hashIndex !== -1 ? input.substring(0, hashIndex) : input +} + +function extractLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + if (hashIndex === -1) { + return { baseQuery: input } + } + + const baseName = input.substring(0, hashIndex) + const linePart = input.substring(hashIndex + 1) + const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/) + + if (!lineMatch) { + return { baseQuery: baseName } + } + + const startLine = Number(lineMatch[1]) + const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined + + return { + lineRange: { + baseName, + startLine, + endLine, + }, + baseQuery: baseName, + } +} export type AutocompleteRef = { onInput: (value: string) => void @@ -147,15 +176,11 @@ export function Autocomplete(props: { async (query) => { if (!store.visible || store.visible === "/") return [] - // Parse line range from query (e.g., "file.ts#L10-20" -> { file: "file.ts", start: 10, end: 20 }) - const lineRangeMatch = (query ?? "").match(LINE_RANGE_REGEX) - const searchQuery = lineRangeMatch?.[1] ?? query ?? "" - const startLine = lineRangeMatch?.[2] - const endLine = lineRangeMatch?.[3] + const { lineRange, baseQuery } = extractLineRange(query ?? "") // Get files from SDK const result = await sdk.client.find.files({ - query: searchQuery, + query: baseQuery, }) const options: AutocompleteOption[] = [] @@ -165,30 +190,25 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...result.data.map((item): AutocompleteOption => { - // Build URL with optional line range query params let url = `file://${process.cwd()}/${item}` - if (startLine) { - const params = new URLSearchParams() - params.set("start", startLine) - if (endLine) { - params.set("end", endLine) + let filename = item + if (lineRange && !item.endsWith("/")) { + filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + const urlObj = new URL(url) + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) } - url += `?${params.toString()}` - } - - // Build display name with line range suffix - let displayName = item - if (startLine) { - displayName += endLine ? `#L${startLine}-${endLine}` : `#L${startLine}` + url = urlObj.toString() } return { - display: Locale.truncateMiddle(displayName, width), + display: Locale.truncateMiddle(filename, width), onSelect: () => { - insertPart(displayName, { + insertPart(filename, { type: "file", mime: "text/plain", - filename: displayName, + filename, url, source: { type: "file", @@ -425,8 +445,8 @@ export function Autocomplete(props: { return prev } - const result = fuzzysort.go(currentFilter, mixed, { - keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], + const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { + keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2588aef264c..45d24d03189 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -63,7 +63,6 @@ import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" -import { DialogPrompt } from "@tui/ui/dialog-prompt" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" @@ -90,6 +89,7 @@ import { DEFAULT_SPINNER_INTERVAL_MS, } from "../../util/spinners" import { DialogAskQuestion } from "../../ui/dialog-askquestion.tsx" +import { DialogExportOptions } from "../../ui/dialog-export-options" import type { AskQuestion } from "@/askquestion" // Re-export for backward compatibility @@ -1073,8 +1073,22 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (showThinking()) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (showDetails() && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (showDetails() && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (showDetails() && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } @@ -1102,6 +1116,14 @@ export function Session() { if (!sessionData) return const sessionMessages = messages() + const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` + + const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails()) + + if (options === null) return + + const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options + let transcript = `# ${sessionData.title}\n\n` transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` @@ -1116,22 +1138,28 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (includeThinking) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (includeToolDetails && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } transcript += `---\n\n` } - // Prompt for optional filename - const customFilename = await DialogPrompt.show(dialog, "Export filename", { - value: `session-${sessionData.id.slice(0, 8)}.md`, - }) - - // Cancel if user pressed escape - if (customFilename === null) return - // Save to file in current working directory const exportDir = process.cwd() const filename = customFilename.trim() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx new file mode 100644 index 00000000000..874a236ee4c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -0,0 +1,148 @@ +import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useDialog, type DialogContext } from "./dialog" +import { createStore } from "solid-js/store" +import { onMount, Show, type JSX } from "solid-js" +import { useKeyboard } from "@opentui/solid" + +export type DialogExportOptionsProps = { + defaultFilename: string + defaultThinking: boolean + defaultToolDetails: boolean + onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void + onCancel?: () => void +} + +export function DialogExportOptions(props: DialogExportOptionsProps) { + const dialog = useDialog() + const { theme } = useTheme() + let textarea: TextareaRenderable + const [store, setStore] = createStore({ + thinking: props.defaultThinking, + toolDetails: props.defaultToolDetails, + active: "filename" as "filename" | "thinking" | "toolDetails", + }) + + useKeyboard((evt) => { + if (evt.name === "return") { + props.onConfirm?.({ + filename: textarea.plainText, + thinking: store.thinking, + toolDetails: store.toolDetails, + }) + } + if (evt.name === "tab") { + const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + evt.preventDefault() + } + if (evt.name === "space") { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + evt.preventDefault() + } + }) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + textarea.focus() + }, 1) + textarea.gotoLineEnd() + }) + + return ( + + + + Export Options + + esc + + + + Filename: + +