diff --git a/.github/last-synced-tag b/.github/last-synced-tag index ae3553b8ac8..ce81bd0b334 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.0.201 +v1.0.203 diff --git a/CONTEXT/PLAN-202-web-app-askquestion-ui-2025-12-24.md b/CONTEXT/PLAN-202-web-app-askquestion-ui-2025-12-24.md deleted file mode 100644 index c260525c69e..00000000000 --- a/CONTEXT/PLAN-202-web-app-askquestion-ui-2025-12-24.md +++ /dev/null @@ -1,359 +0,0 @@ -## Goal and Scope -Create a web app askquestion wizard UI that matches TUI behavior so sessions no longer hang. This plan covers UI, state detection, endpoint wiring in the web app; no implementation is performed here. - -## Source Context and Decisions -### Issue Summary (GitHub #202) -- Problem: Web app has no askquestion UI, so askquestion tool calls hang; resuming a web-app-started session in the TUI shows questions but cannot submit answers. -- Root cause: TUI has complete askquestion handling (detection, UI, submit), web app has none. -- Acceptance criteria: Wizard UI, keyboard/tab navigation, option selection or custom input, submit/cancel behavior, resumed sessions work in both TUI and web app. - -### Key Decisions and Rationale -- Mirror TUI detection logic for pending askquestion tool parts to ensure consistent behavior across web app and TUI. This avoids inconsistent state detection and aligns with existing tool metadata behavior. -- Reuse the askquestion endpoints (`/askquestion/respond`, `/askquestion/cancel`) to keep a single server-side behavior path; avoids inventing new API shape. -- Build a dedicated `AskQuestionWizard` component in the web app (SolidJS), modeled after TUI features (wizard tabs, single/multi-select, text input, keyboard shortcuts) to ensure feature parity. -- Use sync-based detection only (via `message.part.updated` events that update tool metadata). The web app's event architecture differs from TUI's bus-based system; the sync context already handles part updates which contain the tool metadata needed for detection. -- Render the wizard inline in the session page (replacing the prompt input area when active), not as a modal overlay. This matches the TUI behavior where the dialog appears in place of the prompt. - -## Internal Code References -- TUI detection logic: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:398-427` -- TUI dialog implementation: `packages/opencode/src/cli/cmd/tui/ui/dialog-askquestion.tsx` -- AskQuestion types (source of truth): `packages/opencode/src/askquestion/index.ts:4-35` -- Server endpoints: `packages/opencode/src/server/server.ts:1585-1653` -- Web app session page (integration point): `packages/app/src/pages/session.tsx` -- Web app sync context: `packages/app/src/context/sync.tsx` -- Web app global sync (event handling): `packages/app/src/context/global-sync.tsx:154-295` -- New component (to add): `packages/app/src/components/askquestion-wizard.tsx` - -### Existing UI Components to Reuse -- `packages/ui/src/components/tabs.tsx` - Kobalte-based tabs for wizard navigation -- `packages/ui/src/components/checkbox.tsx` - For multi-select options -- `packages/ui/src/components/button.tsx` - For submit/cancel actions -- `packages/ui/src/components/text-field.tsx` - For custom text input -- `packages/ui/src/context/dialog.tsx` - Dialog context (for reference, but wizard renders inline) - -## External References (for UI patterns and APIs) -- https://raw.githubusercontent.com/solidjs-use/solidjs-use/main/packages/core/src/useStepper/index.md -- https://raw.githubusercontent.com/chakra-ui/zag/main/website/data/snippets/solid/tabs/usage.mdx -- https://raw.githubusercontent.com/chakra-ui/zag/main/website/data/snippets/solid/steps/usage.mdx - -## Functional Requirements Mapping -| Requirement | Plan Coverage | Notes | -| --- | --- | --- | -| Web app wizard UI when askquestion invoked | Inline component + session wiring | Matches TUI behavior and issue guidance | -| Tab/arrow navigation between questions | Keyboard and tab UI logic | Mirror TUI controls and shortcuts | -| Select options or enter custom responses | Single-select, multi-select, text input | Use type-safe question model | -| Submit answers continues conversation | POST `/askquestion/respond` | Include `callID`, `sessionID`, `answers` array | -| Cancel dismisses and signals cancellation | POST `/askquestion/cancel` | Include `callID`, `sessionID` | -| Resume sessions in TUI or web app | Sync-based detection of pending asks | Scan message parts for `status: "waiting"` | - -## Technical Specifications - -### API Endpoints -- `POST /askquestion/respond` - - Payload: `{ callID: string, sessionID: string, answers: Answer[] }` - - Purpose: Submit answers and continue tool execution - - Error: Returns error if no pending askquestion found with the given callID -- `POST /askquestion/cancel` - - Payload: `{ callID: string, sessionID: string }` - - Purpose: Cancel tool execution and dismiss UI - - Error: Returns error if no pending askquestion found with the given callID - -### Data Models and Types - -Since `AskQuestion` types are defined in `packages/opencode/src/askquestion/index.ts` and not exported through the SDK, define local types in the web app component: - -```typescript -// Types to define in packages/app/src/components/askquestion-wizard.tsx - -interface AskQuestionOption { - value: string - label: string - description?: string -} - -interface AskQuestionQuestion { - id: string - label: string // Short tab label, e.g. "UI Framework" - question: string // Full question text - options: AskQuestionOption[] // 2-8 options - multiSelect?: boolean -} - -interface AskQuestionAnswer { - questionId: string - values: string[] // Selected option value(s) - customText?: string // Custom text if user typed their own response -} - -interface PendingAskQuestion { - callID: string - messageID: string - questions: AskQuestionQuestion[] -} -``` - -### Detection Logic - -The detection logic must match TUI implementation at `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:398-427`: - -```typescript -// Detection pattern for pending askquestion -const pendingAskQuestion = createMemo(() => { - const sessionMessages = sync.data.message[sessionID] ?? [] - - // Search backwards for the most recent pending question - for (const message of [...sessionMessages].reverse()) { - const parts = sync.data.part[message.id] ?? [] - - for (const part of [...parts].reverse()) { - if (part.type !== "tool") continue - if (part.tool !== "askquestion") continue - if (part.state.status !== "running") continue - - const metadata = part.state.metadata as { - status?: string - questions?: AskQuestionQuestion[] - } | undefined - - if (metadata?.status !== "waiting") continue - - return { - callID: part.callID, - messageID: part.messageID, - questions: metadata.questions ?? [], - } - } - } - - return null -}) -``` - -### Integration Points -- Session page (web app): detect pending askquestion and render wizard inline -- Sync context: already handles `message.part.updated` events which update tool metadata -- Prompt input area: conditionally replaced by wizard when askquestion is pending - -## Implementation Plan - -### Milestone 1: Define Types and Review TUI Parity -- [x] Define local TypeScript types for Question, Option, Answer in web app -- [x] Review TUI askquestion UX behavior at `packages/opencode/src/cli/cmd/tui/ui/dialog-askquestion.tsx` -- [x] Document keyboard shortcuts: 1-8 for quick select, Space to toggle, Enter to confirm/advance, Escape to cancel, Tab/Arrow for navigation -- [x] Confirm existing UI components to reuse: `Tabs`, `Checkbox`, `Button`, `TextField` - -### Milestone 2: Add AskQuestionWizard Component -- [x] Create `packages/app/src/components/askquestion-wizard.tsx` in SolidJS -- [x] Define component props: `questions`, `onSubmit`, `onCancel` -- [x] Implement internal state using `createStore`: - - `activeTab: number` - current question index - - `questionStates: Array<{ selectedOption: number, selectedValues: string[], customText?: string }>` - - `isTypingCustom: boolean` - whether custom input is focused -- [x] Render tab bar showing question labels with completion indicators (filled/empty circle) -- [x] Render current question with options list -- [x] Implement single-select: click/Enter selects and auto-advances -- [x] Implement multi-select: Space toggles, Enter confirms and advances -- [x] Add "Type something..." option at end of options list for custom input -- [x] Implement keyboard navigation: - - Up/Down or Ctrl+P/N: navigate options - - Left/Right or Tab/Shift+Tab: navigate questions - - 1-8: quick select option by number - - Space: toggle selection (multi-select) or select (single-select) - - Enter: confirm and advance or submit if last question - - Escape: cancel wizard - - Ctrl+Enter: submit all answers -- [x] Add footer with navigation hints -- [x] Apply styling consistent with web app design tokens - -### Milestone 3: Detect Pending AskQuestion in Session Page -- [x] Add `pendingAskQuestion` memoized signal in `packages/app/src/pages/session.tsx` -- [x] Scan synced message parts for tool parts where: - - `part.type === "tool"` - - `part.tool === "askquestion"` - - `part.state.status === "running"` - - `part.state.metadata.status === "waiting"` -- [x] Extract `callID`, `messageID`, and `questions` from matching part -- [x] Search backwards through messages to find most recent pending question -- [x] Detection works for both live sessions and resumed sessions - -### Milestone 4: Wire Session Page Integration and API Calls -- [x] Import `AskQuestionWizard` in session page -- [x] Conditionally render wizard instead of `PromptInput` when `pendingAskQuestion()` is truthy -- [x] Use `` or `/` pattern (matches TUI at line 1426-1464) -- [x] Implement `onSubmit` handler: - ```typescript - async (answers: AskQuestionAnswer[]) => { - await fetch(`${sdk.url}/askquestion/respond`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - callID: pendingAskQuestion().callID, - sessionID: params.id, - answers, - }), - }).catch(() => { - showToast({ title: "Failed to submit answers", variant: "error" }) - }) - } - ``` -- [x] Implement `onCancel` handler: - ```typescript - async () => { - await fetch(`${sdk.url}/askquestion/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - callID: pendingAskQuestion().callID, - sessionID: params.id, - }), - }).catch(() => { - showToast({ title: "Failed to cancel", variant: "error" }) - }) - } - ``` -- [x] After submit/cancel, the tool metadata will update via sync, causing `pendingAskQuestion()` to return null and hiding the wizard - -### Milestone 5: Accessibility, UX, and Edge Cases -- [x] Verify tab/arrow navigation and focus management across questions -- [x] Ensure multi-select toggles do not override previous selections -- [x] Handle empty questions list gracefully (don't render wizard) -- [x] Handle missing metadata gracefully (don't render wizard) -- [x] Prevent existing session page keyboard handlers from triggering while wizard is active - - Session page has handlers at `packages/app/src/pages/session.tsx:438-455` - - Check `document.activeElement` or add `data-prevent-autofocus` attribute -- [x] Handle API errors gracefully: - - Show toast on submit/cancel failure - - Handle case where pending request no longer exists (server restarted, session aborted) -- [x] Ensure wizard doesn't render for completed askquestion tool parts - -### Milestone 6: Testing and Validation -- [ ] Test single question with single-select options -- [ ] Test multiple questions with mixed single/multi-select -- [ ] Test custom text input for responses -- [ ] Test cancel mid-wizard -- [ ] Test submit with all questions answered -- [ ] Test resumed session with pending askquestion (web app reload) -- [ ] Test session started in TUI, resumed in web app -- [ ] Test session started in web app, resumed in TUI -- [ ] Test session abort while askquestion is pending - -## Implementation Order and Dependencies -1. Define local types and review TUI implementation (Milestone 1) -2. Build `AskQuestionWizard` component with internal state (Milestone 2) -3. Implement pending detection in session page (Milestone 3) -4. Wire session page integration and API calls (Milestone 4) -5. Address accessibility, keyboard conflicts, and edge cases (Milestone 5) -6. Test all scenarios (Milestone 6) - -## Validation Criteria -- [ ] Triggering askquestion in web app shows wizard UI immediately (replaces prompt input) -- [ ] Users can navigate between questions via tabs, arrows, and Tab key -- [ ] Users can select options via click, Enter, Space, or number keys -- [ ] Users can enter custom text responses -- [ ] Submit sends correct payload format to `/askquestion/respond` and resumes LLM response -- [ ] Cancel sends correct payload to `/askquestion/cancel` and closes wizard -- [ ] Resumed sessions with pending askquestion show the wizard in both web app and TUI -- [ ] No regression in non-askquestion session rendering -- [ ] Keyboard shortcuts in wizard don't conflict with session page shortcuts - -## Risks and Mitigations -- Risk: Web app and TUI detection logic diverge over time - - Mitigation: Document exact detection conditions; consider extracting shared detection logic in future -- Risk: Keyboard shortcuts conflict with session page shortcuts - - Mitigation: Check if wizard is active before handling session-level keyboard events; use `data-prevent-autofocus` pattern -- Risk: Server restarts or session aborts while askquestion is pending - - Mitigation: Handle API errors gracefully; show toast and allow retry or dismiss -- Risk: Type definitions in web app diverge from server schema - - Mitigation: Document that types must match `packages/opencode/src/askquestion/index.ts`; consider extracting to shared package in future -- Risk: Sync data updates race with wizard state - - Mitigation: Derive pending state from sync data (reactive), don't cache separately - -## Resolved Questions - -### Does the web app already have a dialog component or tab system to reuse? -Yes. Use these existing components: -- `packages/ui/src/components/tabs.tsx` - Kobalte-based Tabs with List, Trigger, Content -- `packages/ui/src/components/checkbox.tsx` - For multi-select option checkboxes -- `packages/ui/src/components/button.tsx` - For submit/cancel buttons -- `packages/ui/src/components/text-field.tsx` - For custom text input - -Note: The wizard should render **inline** in the session page (replacing the prompt input), not as a modal overlay via the dialog system. - -### Should the web app explicitly store pending askquestion state in sync context? -No. Derive the pending state from existing sync data using a memoized computation. The sync context already stores `message` and `part` data which contains the tool metadata. Adding separate askquestion state would require keeping it synchronized and could lead to inconsistencies. - -### Are there existing keyboard shortcut handlers in web app that must be preserved? -Yes. The session page has keyboard handlers at `packages/app/src/pages/session.tsx:438-455` that auto-focus the prompt input when typing. When the wizard is active, these handlers should be bypassed. Check `document.activeElement` or use the existing `data-prevent-autofocus` pattern. - -## Appendix: Correct Payload Examples - -### Submit Response Payload -```json -{ - "callID": "call_abc123", - "sessionID": "session_xyz789", - "answers": [ - { - "questionId": "ui_framework", - "values": ["react"], - "customText": null - }, - { - "questionId": "styling", - "values": ["tailwind", "css-modules"], - "customText": null - }, - { - "questionId": "other_requirements", - "values": [], - "customText": "I also need SSR support" - } - ] -} -``` - -### Cancel Payload -```json -{ - "callID": "call_abc123", - "sessionID": "session_xyz789" -} -``` - -### Tool Metadata Structure (from askquestion tool) -When askquestion is waiting for user input: -```json -{ - "title": "Asking 3 questions", - "metadata": { - "status": "waiting", - "questions": [ - { - "id": "ui_framework", - "label": "UI Framework", - "question": "Which UI framework would you like to use?", - "options": [ - { "value": "react", "label": "React", "description": "Popular component library" }, - { "value": "vue", "label": "Vue", "description": "Progressive framework" }, - { "value": "svelte", "label": "Svelte", "description": "Compile-time framework" } - ], - "multiSelect": false - } - ] - } -} -``` - -When askquestion is completed: -```json -{ - "title": "Asked 3 questions", - "metadata": { - "status": "completed", - "questions": ["UI Framework", "Styling", "Other Requirements"], - "answers": [ - { "questionId": "ui_framework", "values": ["react"] } - ] - } -} -``` diff --git a/CONTEXT/PLAN-5638-fix-worktree-project-id-collision-2025-12-22.md b/CONTEXT/PLAN-5638-fix-worktree-project-id-collision-2025-12-22.md deleted file mode 100644 index c01f9b8eb83..00000000000 --- a/CONTEXT/PLAN-5638-fix-worktree-project-id-collision-2025-12-22.md +++ /dev/null @@ -1,530 +0,0 @@ -# Plan: Fix Desktop App Worktree Project ID Collision - -**Issue:** [sst/opencode#5638](https://github.com/sst/opencode/issues/5638) -**Related PR:** [sst/opencode#5647](https://github.com/sst/opencode/pull/5647) -**Date:** 2025-12-22 -**Status:** Implemented -**Reviewed:** 2025-12-22 - -## Review Notes - -This plan has been reviewed against the current codebase. Key findings incorporated: - -- Cache write location must use absolute paths (fixed in Task 1.2) -- Race condition protection added to migration (Task 2.1) -- Path normalization for cross-platform hash stability (Task 1.5) -- Decision made on old project entry handling (Task 2.3 - Option B) -- Test cleanup for worktrees specified (Task 4.2) -- Existing bug fix noted: current code doesn't await cache writes (Task 1.6) - -## Problem Statement - -When opening multiple git worktrees from the same repository in the desktop app, the second worktree replaces the first one's project data. This happens because project IDs are derived solely from the root commit hash (`git rev-list --max-parents=0 --all`), which is identical across all worktrees of the same repository. - -### Root Cause - -The project ID generation in `packages/opencode/src/project/project.ts:55-73` uses only the git root commit hash as the unique identifier. Since all worktrees from the same repository share the same commit history (and thus the same root commit), they all receive the same project ID, causing data collision in storage and snapshots. - -### Impact - -- Users cannot have multiple worktrees from the same repository open simultaneously in the desktop app -- Opening a second worktree overwrites session data from the first -- This affects any workflow involving git worktrees (feature branches, parallel development, etc.) - -## Solution Overview - -Implement a differentiated project ID scheme: - -- **Main worktree:** Uses root commit hash only (backwards compatible) -- **Linked worktrees:** Uses `{rootCommit}-{worktreeHash}` format for unique IDs - -### Key Design Decisions - -1. **Backwards Compatibility:** Main worktrees retain the existing ID format to preserve existing session data for the common case -2. **Windows-safe ID format:** Use `-` as a separator instead of `|` because project IDs are used as filesystem paths in storage/snapshots and `|` is invalid on Windows -3. **Worktree Hash Caching:** Store the worktree hash in `.git/worktrees/{name}/opencode-worktree` to ensure ID stability if the worktree path changes -4. **Session Migration:** Migrate sessions from old format to new format when users upgrade, using session directory matching instead of project worktree metadata -5. **Fsmonitor Disable:** Disable git fsmonitor in snapshot repos to prevent hangs with linked worktrees - -## Technical Specifications - -### Project ID Format - -| Worktree Type | ID Format | Example | -| --------------- | ----------------------------- | -------------------------- | -| Main worktree | `{rootCommit}` | `a1b2c3d4e5f6...` | -| Linked worktree | `{rootCommit}-{worktreeHash}` | `a1b2c3d4e5f6...-7f8a9b2c` | - -### Cache File Locations - -| File | Location | Purpose | -| ------------------- | ------------------------------------------------------------------- | ------------------------------------- | -| Root commit cache | `.git/opencode` (main) or `.git/worktrees/{name}/opencode` (linked) | Cache expensive root commit lookup | -| Worktree hash cache | `.git/worktrees/{name}/opencode-worktree` | Ensure stable ID for linked worktrees | - -### Worktree Detection - -A linked worktree is detected by checking `git rev-parse --git-dir` and verifying that the normalized path includes `path.join(".git", "worktrees")`. This avoids path separator issues on Windows. - -**Important:** The `git rev-parse --git-dir` command returns a relative path (`.git`) for main worktrees but an absolute path for linked worktrees. Always resolve to absolute path using `path.resolve(worktree, gitDirRaw)` before further processing. - -### Path Normalization for Hashing - -To ensure cross-platform hash stability (e.g., WSL + Windows accessing same worktree), normalize path separators before hashing: - -```typescript -const normalizedPath = worktree.replace(/\\/g, "/") -const worktreeHash = Bun.hash(normalizedPath).toString(16) -``` - -### Project ID Format Documentation - -Add inline documentation in code: - -```typescript -// Project ID formats: -// - Main worktree: "{rootCommit}" (e.g., "a1b2c3d4...") -// - Linked worktree: "{rootCommit}-{pathHash}" (e.g., "a1b2c3d4...-7f8a9b2c") -// The separator is "-" (not "|") because project IDs are used in filesystem paths -``` - -### Upgrade Detection - -Do not rely on cached root commit for linked worktrees. Instead, after computing `rootCommit`, check for legacy storage under the old ID and migrate sessions whose `session.directory` matches the current `worktree`. - -## Files to Modify - -### Primary Changes - -| File | Changes | -| ------------------------------------------------ | ------------------------------------------------------- | -| `packages/opencode/src/project/project.ts` | Project ID generation, caching, and migration logic | -| `packages/opencode/src/snapshot/index.ts` | Disable fsmonitor for worktree compatibility | -| `packages/opencode/test/project/project.test.ts` | New tests for worktree ID differentiation and migration | - -### Reference Files (read-only) - -| File | Purpose | -| ------------------------------------------ | -------------------------------------------------- | -| `packages/opencode/src/storage/storage.ts` | Storage key structure, filesystem path constraints | -| `packages/opencode/src/session/index.ts` | Session data structure (directory fields) | - -## Implementation Tasks - -### Phase 1: Core Project ID Changes - -- [x] **1.1 Resolve worktree path early** - - Move `git rev-parse --show-toplevel` to execute before ID generation - - Normalize path for stable hashing (`path.resolve` already used) - - File: `packages/opencode/src/project/project.ts:54-87` - -- [x] **1.2 Implement gitDir resolution (with error handling)** - - Add `git rev-parse --git-dir` to get actual git directory - - Handles linked worktrees where `.git` is a file pointing elsewhere - - **Critical:** Resolve to absolute path: `path.resolve(worktree, gitDirRaw)` - - **Critical:** Use `.nothrow()` and fall back to existing `git` variable on error - - File: `packages/opencode/src/project/project.ts` - - ```typescript - // Get gitDir - may be relative for main worktrees, absolute for linked - const gitDirRaw = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(worktree) - .text() - .then((x) => x.trim()) - // Fall back to Filesystem.up result if git command fails - const gitDir = gitDirRaw ? path.resolve(worktree, gitDirRaw) : git - ``` - -- [x] **1.3 Add linked worktree detection (Windows-safe)** - - Use `const normalizedGitDir = path.normalize(gitDir)` and check for `path.join(".git", "worktrees")` - - For case-insensitive comparison on Windows: `.toLowerCase()` both sides - - Add logging for debugging: `log.info("worktree detection", { isLinkedWorktree, gitDir })` - - File: `packages/opencode/src/project/project.ts` - - ```typescript - const normalizedGitDir = path.normalize(gitDir).toLowerCase() - const worktreeMarker = path.join(".git", "worktrees").toLowerCase() - const isLinkedWorktree = normalizedGitDir.includes(worktreeMarker) - log.info("worktree detection", { isLinkedWorktree, gitDir: normalizedGitDir }) - ``` - -- [x] **1.4 Implement cache reading** - - Read cached root commit from `{gitDir}/opencode` - - For linked worktrees, also read cached worktree hash from `{gitDir}/opencode-worktree` - - Return early with cached ID if both are available - - File: `packages/opencode/src/project/project.ts` - -- [x] **1.5 Implement differentiated ID generation (cross-platform safe)** - - Main worktree: `id = rootCommit` - - Linked worktree: `id = ${rootCommit}-${Bun.hash(normalizedPath).toString(16)}` - - **Critical:** Normalize path separators before hashing for cross-platform stability - - File: `packages/opencode/src/project/project.ts` - - ```typescript - // Normalize path separators for consistent hashing across platforms (WSL + Windows) - const normalizedPath = worktree.replace(/\\/g, "/") - const worktreeHash = isLinkedWorktree ? Bun.hash(normalizedPath).toString(16) : undefined - const id = isLinkedWorktree ? `${rootCommit}-${worktreeHash}` : rootCommit - ``` - -- [x] **1.6 Implement cache writing (awaited) - BUG FIX** - - Write worktree hash to `{gitDir}/opencode-worktree` for linked worktrees - - Write root commit to `{gitDir}/opencode` if not cached - - Use `await` for both writes to avoid silent cache failures - - **Note:** This fixes an existing bug - current code at `project.ts:73` doesn't await the write - - File: `packages/opencode/src/project/project.ts` - - ```typescript - // Write caches (awaited to catch write failures) - if (isLinkedWorktree && worktreeHash) { - await Bun.file(path.join(gitDir, "opencode-worktree")).write(worktreeHash) - } - if (!cachedRootCommit) { - await Bun.file(path.join(gitDir, "opencode")).write(rootCommit) - } - ``` - -### Phase 2: Session Migration (Upgrade Safety) - -The upstream PR has dead code - `oldProjectID` is always `undefined`, so migration never runs. Fix by detecting legacy storage and migrating sessions based on directory matching. - -- [x] **2.1 Add migration detection logic (storage-based, with race protection)** - - After computing `rootCommit`, check for legacy storage under `rootCommit` when `isLinkedWorktree` is true - - Suggested check: `Storage.list(["session", rootCommit])` or `Storage.read(["project", rootCommit])` - - Only migrate if sessions exist and `session.directory` matches `worktree` - - **Critical:** Add idempotency check to prevent race conditions when multiple instances open same worktree - - File: `packages/opencode/src/project/project.ts` - - ```typescript - // Before migration, check if new project ID storage already exists (race protection) - const newProjectExists = await Storage.read(["project", newProjectID]).catch(() => undefined) - if (!newProjectExists) { - await migrateSessions(rootCommit, newProjectID, worktree) - } - ``` - -- [x] **2.2 Implement migrateSessions function (directory-based, idempotent)** - - Migrate sessions from old project ID to new project ID - - Filter sessions to migrate by `session.directory === worktree` - - Do not rely on `oldProject.worktree` due to historical collisions - - **Add idempotency:** Check if session already exists at new location before copying - - File: `packages/opencode/src/project/project.ts` - - ```typescript - async function migrateSessions(oldProjectID: string, newProjectID: string, worktree: string) { - const oldSessions = await Storage.list(["session", oldProjectID]).catch(() => []) - if (oldSessions.length === 0) return - - log.info("migrating sessions", { from: oldProjectID, to: newProjectID, worktree, count: oldSessions.length }) - - await work(10, oldSessions, async (key) => { - const sessionID = key[key.length - 1] - const session = await Storage.read(key).catch(() => undefined) - if (!session) return - if (session.directory !== worktree) return - - // Idempotency check: skip if already migrated - const existingSession = await Storage.read(["session", newProjectID, sessionID]).catch(() => undefined) - if (existingSession) { - log.info("session already migrated, skipping", { sessionID }) - return - } - - session.projectID = newProjectID - log.info("migrating session", { sessionID, from: oldProjectID, to: newProjectID }) - await Storage.write(["session", newProjectID, sessionID], session) - await Storage.remove(key) - }).catch((error) => { - log.error("failed to migrate sessions", { error, from: oldProjectID, to: newProjectID }) - }) - } - ``` - -- [x] **2.3 Handle old project entry (DECISION: Option B)** - - **Decision:** Remove legacy project entry if no sessions remain after migration - - This prevents orphaned project entries from accumulating - - File: `packages/opencode/src/project/project.ts` - - ```typescript - // After migration, clean up empty legacy project entry - async function cleanupLegacyProject(oldProjectID: string) { - const remainingSessions = await Storage.list(["session", oldProjectID]).catch(() => []) - if (remainingSessions.length === 0) { - log.info("removing empty legacy project entry", { projectID: oldProjectID }) - await Storage.remove(["project", oldProjectID]).catch(() => {}) - } - } - ``` - -### Phase 3: Snapshot Compatibility - -- [x] **3.1 Disable fsmonitor in snapshot repos** - - Add `git config core.fsmonitor false` after snapshot repo initialization - - Prevents hangs when worktree is a linked git worktree - - File: `packages/opencode/src/snapshot/index.ts:28` - - ```typescript - // After existing autocrlf config - await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow() - ``` - -- [x] **3.2 Document snapshot data behavior (no migration)** - - **Note:** Snapshot data is stored under `Global.Path.data/snapshot/{projectID}` (see `snapshot/index.ts:193-196`) - - When project ID changes for linked worktrees, old snapshot data is orphaned - - **Decision:** Accept this behavior - snapshots are temporary/disposable and not worth migrating - - Add comment in code documenting this intentional behavior - -### Phase 4: Testing - -- [x] **4.1 Update existing test assertions** - - Add assertion that main worktree ID does not contain separator - - Add assertion that `opencode-worktree` file does not exist for main worktree - - File: `packages/opencode/test/project/project.test.ts:32-41` - -- [x] **4.2 Add linked worktree test (with proper cleanup)** - - Create main repo with git worktree - - Verify main worktree uses root commit only - - Verify linked worktree uses `{rootCommit}-{hash}` format - - Verify IDs are different - - Verify `opencode-worktree` file exists for linked worktree - - **Critical:** Proper cleanup order - remove worktree before deleting directory - - File: `packages/opencode/test/project/project.test.ts` - - ```typescript - // Cleanup helper for worktrees - async function cleanupWorktree(mainRepoPath: string, worktreePath: string) { - // Must remove worktree reference first, otherwise main repo has stale references - await $`git worktree remove --force ${worktreePath}`.cwd(mainRepoPath).nothrow() - await fs.rm(worktreePath, { recursive: true, force: true }) - } - ``` - -- [x] **4.3 Add migration test (required)** - - Create legacy sessions under `rootCommit` with differing `session.directory` values - - Open linked worktree and verify only sessions with matching directory migrate - - Ensure unrelated sessions remain under legacy project ID - - File: `packages/opencode/test/project/project.test.ts` - -- [x] **4.4 Add integration test with real worktree scenario** - - Beyond unit tests, add end-to-end test that: - - Creates main repo with commits - - Creates linked worktree - - Opens both in opencode (simulated via Project.fromDirectory) - - Creates sessions in both - - Verifies complete isolation (no data leakage) - - File: `packages/opencode/test/project/project.test.ts` - -- [x] **4.5 Run full test suite** - ```bash - bun test - ``` - -### Phase 5: Validation - -- [x] **5.1 Manual testing - basic functionality** (verified via automated tests) - - Create a git repo with commits - - Open in opencode - - Verify project ID is root commit hash (no separator) - - Verify `.git/opencode` file is created - -- [x] **5.2 Manual testing - linked worktrees** (verified via automated tests) - - Create linked worktree: `git worktree add ../feature-branch HEAD` - - Open linked worktree in opencode - - Verify project ID contains separator - - Verify `.git/worktrees/{name}/opencode-worktree` file is created - - Verify both worktrees can be open simultaneously without collision - -- [x] **5.3 Manual testing - upgrade migration** (verified via automated tests) - - Create legacy sessions under old project ID - - Open linked worktree in new opencode - - Verify only sessions matching `session.directory === worktree` migrate - - Verify unrelated sessions remain under legacy project ID - -- [x] **5.4 Manual testing - Windows compatibility** (verified via cross-platform safe implementation using "-" separator) - - Verify new project IDs are valid Windows filenames - - Verify storage and snapshot directories are created successfully - -## Code Changes Summary - -### packages/opencode/src/project/project.ts - -```diff - export async function fromDirectory(directory: string) { - log.info("fromDirectory", { directory }) - -- const { id, worktree, vcs } = await iife(async () => { -+ const { id, worktree, vcs } = await iife(async () => { - const matches = Filesystem.up({ targets: [".git"], start: directory }) - const git = await matches.next().then((x) => x.value) - await matches.return() - if (git) { - let worktree = path.dirname(git) -+ // Resolve worktree path before ID generation -+ worktree = await $`git rev-parse --show-toplevel` -+ .quiet() -+ .nothrow() -+ .cwd(worktree) -+ .text() -+ .then((x) => path.resolve(worktree, x.trim())) -+ -+ // Resolve actual gitDir (handles worktrees) - may be relative for main worktrees -+ const gitDirRaw = await $`git rev-parse --git-dir` -+ .quiet() -+ .nothrow() -+ .cwd(worktree) -+ .text() -+ .then((x) => x.trim()) -+ // Fall back to Filesystem.up result if git command fails -+ const gitDir = gitDirRaw ? path.resolve(worktree, gitDirRaw) : git -+ -+ // Detect linked worktree (case-insensitive for Windows) -+ const normalizedGitDir = path.normalize(gitDir).toLowerCase() -+ const worktreeMarker = path.join(".git", "worktrees").toLowerCase() -+ const isLinkedWorktree = normalizedGitDir.includes(worktreeMarker) -+ log.info("worktree detection", { isLinkedWorktree, gitDir: normalizedGitDir }) -+ -+ // Read caches -+ const cachedRootCommit = await Bun.file(path.join(gitDir, "opencode")).text().catch(() => {}) -+ const cachedWorktreeHash = isLinkedWorktree -+ ? await Bun.file(path.join(gitDir, "opencode-worktree")).text().catch(() => {}) -+ : undefined -+ -+ if (cachedRootCommit && (!isLinkedWorktree || cachedWorktreeHash)) { -+ const id = isLinkedWorktree ? `${cachedRootCommit}-${cachedWorktreeHash}` : cachedRootCommit -+ return { id, worktree, vcs: "git" } -+ } -+ -+ // Compute root commit if needed -+ const roots = await $`git rev-list --max-parents=0 --all` -+ .quiet() -+ .nothrow() -+ .cwd(worktree) -+ .text() -+ .then((x) => -+ x -+ .split("\n") -+ .filter(Boolean) -+ .map((x) => x.trim()) -+ .toSorted(), -+ ) -+ const rootCommit = roots[0] -+ if (!rootCommit) return { id: "global", worktree, vcs: "git" } -+ -+ // Normalize path separators for cross-platform hash stability -+ const normalizedPath = worktree.replace(/\\/g, '/') -+ const worktreeHash = isLinkedWorktree ? Bun.hash(normalizedPath).toString(16) : undefined -+ const id = isLinkedWorktree ? `${rootCommit}-${worktreeHash}` : rootCommit -+ -+ // Write caches (awaited - fixes existing bug where writes weren't awaited) -+ if (isLinkedWorktree && worktreeHash) { -+ await Bun.file(path.join(gitDir, "opencode-worktree")).write(worktreeHash) -+ } -+ if (!cachedRootCommit) { -+ await Bun.file(path.join(gitDir, "opencode")).write(rootCommit) -+ } -+ -+ // Migration hook (linked worktrees only, with race protection) -+ if (isLinkedWorktree) { -+ const newProjectExists = await Storage.read(["project", id]).catch(() => undefined) -+ if (!newProjectExists) { -+ await migrateSessions(rootCommit, id, worktree) -+ await cleanupLegacyProject(rootCommit) -+ } -+ } - - return { id, worktree, vcs: "git" } - } - }) - } -+ -+ // Project ID formats: -+ // - Main worktree: "{rootCommit}" (e.g., "a1b2c3d4...") -+ // - Linked worktree: "{rootCommit}-{pathHash}" (e.g., "a1b2c3d4...-7f8a9b2c") -+ // The separator is "-" (not "|") because project IDs are used in filesystem paths -+ -+ async function migrateSessions(oldProjectID: string, newProjectID: string, worktree: string) { -+ const oldSessions = await Storage.list(["session", oldProjectID]).catch(() => []) -+ if (oldSessions.length === 0) return -+ -+ log.info("migrating sessions", { from: oldProjectID, to: newProjectID, worktree, count: oldSessions.length }) -+ -+ await work(10, oldSessions, async (key) => { -+ const sessionID = key[key.length - 1] -+ const session = await Storage.read(key).catch(() => undefined) -+ if (!session) return -+ if (session.directory !== worktree) return -+ -+ // Idempotency check: skip if already migrated -+ const existingSession = await Storage.read(["session", newProjectID, sessionID]).catch(() => undefined) -+ if (existingSession) { -+ log.info("session already migrated, skipping", { sessionID }) -+ return -+ } -+ -+ session.projectID = newProjectID -+ log.info("migrating session", { sessionID, from: oldProjectID, to: newProjectID }) -+ await Storage.write(["session", newProjectID, sessionID], session) -+ await Storage.remove(key) -+ }).catch((error) => { -+ log.error("failed to migrate sessions", { error, from: oldProjectID, to: newProjectID }) -+ }) -+ } -+ -+ async function cleanupLegacyProject(oldProjectID: string) { -+ const remainingSessions = await Storage.list(["session", oldProjectID]).catch(() => []) -+ if (remainingSessions.length === 0) { -+ log.info("removing empty legacy project entry", { projectID: oldProjectID }) -+ await Storage.remove(["project", oldProjectID]).catch(() => {}) -+ } -+ } -``` - -## Known Limitations - -1. **Worktree Path Changes:** If a user moves/renames their worktree directory and the cache file is deleted, they will get a new project ID and lose access to sessions. The cache file mitigates this for normal usage. -2. **Cache File Deletion:** If `.git/worktrees/{name}/opencode-worktree` is deleted, the worktree hash will be regenerated. Since it's based on the path, it should be stable unless the path changed. -3. **No Reverse Migration:** Sessions migrated from old format cannot be automatically migrated back if user downgrades opencode. -4. **Snapshot Data Orphaned:** When project ID changes for linked worktrees, old snapshot data under the previous ID is orphaned. This is intentional - snapshots are temporary and not worth migrating. -5. **Hash Algorithm Dependency:** The worktree hash uses `Bun.hash()` (xxHash). If Bun changes hash algorithms in future versions, cache files will need regeneration. Consider future-proofing with a version marker if this becomes an issue. -6. **Cross-Platform Path Differences:** While we normalize path separators for hashing, other platform-specific path differences (e.g., drive letters on Windows vs WSL paths) may still cause different hashes for the "same" worktree accessed from different environments. - -## External References - -- **Upstream PR:** https://github.com/sst/opencode/pull/5647 -- **Upstream Issue:** https://github.com/sst/opencode/issues/5638 -- **Git Worktrees Documentation:** https://git-scm.com/docs/git-worktree - -## Acceptance Criteria - -1. Main worktrees continue to use root commit hash as project ID (backwards compatible) -2. Linked worktrees use differentiated ID format `{rootCommit}-{hash}` and remain valid on Windows filesystems -3. Multiple worktrees from same repo can be open simultaneously without data collision -4. Existing sessions are preserved for main worktrees -5. Linked worktree sessions are migrated correctly using session directory matching -6. Unrelated sessions stored under legacy IDs remain untouched -7. Snapshot functionality works correctly with linked worktrees (no fsmonitor hangs) -8. All existing tests pass and new tests for worktree differentiation and migration pass -9. Cache writes are awaited (bug fix verified) -10. Race conditions in migration are handled via idempotency checks - -## Rollback Strategy - -If issues are discovered after deployment: - -1. **Emergency Disable (without code change):** - - Delete `.git/worktrees/{name}/opencode-worktree` cache files to force re-detection - - Sessions will be orphaned but not lost (still in storage under new project ID) - -2. **Manual Data Recovery:** - - Sessions can be manually moved in storage directory: - ```bash - # Storage location - ~/.local/share/opencode/storage/session/{projectID}/ - ``` - - Rename directory from `{rootCommit}-{hash}` back to `{rootCommit}` - -3. **Feature Flag Consideration:** - - If frequent issues expected, consider adding `Flag.OPENCODE_WORKTREE_COMPAT` to disable new behavior - - Not implemented by default as the change is low-risk for the common case (main worktrees unchanged) diff --git a/bun.lock b/bun.lock index e64e67f9e5e..6da0e950fec 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -78,7 +78,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -106,7 +106,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -133,7 +133,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -157,7 +157,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -181,7 +181,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -208,7 +208,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -237,7 +237,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -253,7 +253,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.201", + "version": "1.0.203", "bin": { "opencode": "./bin/opencode", }, @@ -348,7 +348,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -368,7 +368,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.201", + "version": "1.0.203", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -379,7 +379,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -392,7 +392,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -427,7 +427,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "zod": "catalog:", }, @@ -438,7 +438,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.201", + "version": "1.0.203", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 8ee2c4b71e7..f1da1a1d2a1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.201", + "version": "1.0.203", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8500b0645d4..b493313b701 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1009,18 +1009,21 @@ export const PromptInput: Component = (props) => { onInput={handleInput} onKeyDown={handleKeyDown} classList={{ - "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-icon-info-active": true, "font-mono!": store.mode === "shell", }} /> -
+
{store.mode === "shell" ? "Enter shell command..." : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
+
+ +
@@ -1077,7 +1080,6 @@ export const PromptInput: Component = (props) => { -
{(ctx) => ( -
- Tokens - {ctx().tokens} +
+
+ Tokens + {ctx().tokens}
-
- Usage - {ctx().percentage ?? 0}% +
+ Usage + {ctx().percentage ?? 0}%
-
- Cost - {cost()} +
+ Cost + {cost()}
} placement="top" > -
- {`${ctx().percentage ?? 0}%`} +
+ {/* {`${ctx().percentage ?? 0}%`} */}
)} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0140bcce71d..33b66969734 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, untrack, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" @@ -319,8 +319,11 @@ export default function Layout(props: ParentProps) { createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) - setStore("lastSession", directory, params.id) - notification.session.markViewed(params.id) + const id = params.id + setStore("lastSession", directory, id) + notification.session.markViewed(id) + untrack(() => layout.projects.expand(directory)) + requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { @@ -467,7 +470,12 @@ export default function Layout(props: ParentProps) { class="flex flex-col min-w-0 text-left w-full focus:outline-none" >
- + {props.session.title}
@@ -515,7 +523,15 @@ export default function Layout(props: ParentProps) { onClick={() => dialog.show(() => )} /> - + + Archive session + {command.keybind("session.archive")} +
+ } + > archiveSession(props.session)} />
@@ -600,7 +616,15 @@ export default function Layout(props: ParentProps) { - + + New session + {command.keybind("session.new")} +
+ } + >
diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 30bb4d7fd3f..a12dc87f24d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.201", + "version": "1.0.203", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e6eedfbfd9f..4f6d2717fb7 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.201", + "version": "1.0.203", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 42857251be8..572a86ddd5e 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.201", + "version": "1.0.203", "$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 3f8ec54edea..1b2869dd9ec 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.201", + "version": "1.0.203", "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 f994d660e78..4bdb5ce3886 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.0.201", + "version": "1.0.203", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index c291831453a..a89e5df7ef7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.201", + "version": "1.0.203", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 35555ba021f..e21818e4629 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.201" +version = "1.0.203" 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.201/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.203/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.201/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.201/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.203/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.201/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.203/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.201/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 9eafac4e39b..160e78b35fd 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.201", + "version": "1.0.203", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 184549f05b4..fda23d865b3 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.201", + "version": "1.0.203", "name": "opencode", "type": "module", "private": true, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 428d62bf6de..d191c4976bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1116,7 +1116,7 @@ export function Prompt(props: PromptProps) { syntaxStyle={syntax()} /> - + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} diff --git a/packages/opencode/src/lsp/language.ts b/packages/opencode/src/lsp/language.ts index 620944a8e07..d279f7d64e7 100644 --- a/packages/opencode/src/lsp/language.ts +++ b/packages/opencode/src/lsp/language.ts @@ -39,6 +39,7 @@ export const LANGUAGE_EXTENSIONS: Record = { ".hbs": "handlebars", ".handlebars": "handlebars", ".hs": "haskell", + ".lhs": "haskell", ".html": "html", ".htm": "html", ".ini": "ini", diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index b432e5a5d0a..ba51ba663a3 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1175,7 +1175,7 @@ export namespace LSPServer { case "linux": return "config_linux" case "win32": - return "config_windows" + return "config_win" default: return "config_linux" } @@ -1892,4 +1892,22 @@ export namespace LSPServer { } }, } + + export const HLS: Info = { + id: "haskell-language-server", + extensions: [".hs", ".lhs"], + root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), + async spawn(root) { + const bin = Bun.which("haskell-language-server-wrapper") + if (!bin) { + log.info("haskell-language-server-wrapper not found, please install haskell-language-server") + return + } + return { + process: spawn(bin, ["--lsp"], { + cwd: root, + }), + } + }, + } } diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index c14b6d75b30..a81deb62bf2 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -2,143 +2,103 @@ Executes a given bash command in a persistent shell session with optional timeou All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. + Before executing the command, please follow these steps: 1. Directory Verification: - - If the command will create new directories or files, first use the List tool to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use List to check that "foo" exists and is the intended parent directory + - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory 2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) + - cd "/Users/name/My Documents" (correct) + - cd /Users/name/My Documents (incorrect - will fail) - python "/path/with spaces/script.py" (correct) - python /path/with spaces/script.py (incorrect - will fail) - After ensuring proper quoting, execute the command. - Capture the output of the command. Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). - If not specified, commands will timeout after 120000ms (2 minutes). - - The description argument is required. You must write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds 30000 characters, output will be truncated before being - returned to you. - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or - `echo` commands, unless explicitly instructed or when these commands are truly necessary - for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < - pytest /foo/bar/tests - - - cd /foo/bar && pytest tests - - -# Working Directory - -The `workdir` parameter sets the working directory for command execution. Prefer using `workdir` over `cd &&` command chains when you simply need to run a command in a different directory. - - -workdir="/foo/bar", command="pytest tests" - - -command="pytest /foo/bar/tests" - - -command="cd /foo/bar && pytest tests" - + - The command argument is required. + - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds 30000 characters, output will be truncated before being returned to you. + - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. + + - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + # Committing changes with git -IMPORTANT: ONLY COMMIT IF THE USER ASKS YOU TO. - -If and only if the user asks you to create a new git commit, follow these steps carefully: - -1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool: - - Run a git status command to see all untracked files. - - Run a git diff command to see both staged and unstaged changes that will be committed. - - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. - -2. Analyze all staged changes (both previously staged and newly added) and draft a commit message. When analyzing: - -- List the files that have been changed or added -- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.) -- Brainstorm the purpose or motivation behind these changes -- Assess the impact of these changes on the overall project -- Check for any sensitive information that shouldn't be committed -- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" -- Ensure your language is clear, concise, and to the point -- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.) -- Ensure the message is not generic (avoid words like "Update" or "Fix" without context) -- Review the draft message to ensure it accurately reflects the changes and their purpose +Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully: -3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel: +Git Safety Protocol: +- NEVER update the git config +- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them +- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it +- NEVER run force push to main/master, warn the user if they request it +- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: + (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including + (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') + (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") +- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit +- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) +- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. + +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: + - Run a git status command to see all untracked files. + - Run a git diff command to see both staged and unstaged changes that will be committed. + - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. +2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: + - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). + - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files + - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" + - Ensure it accurately reflects the changes and their purpose +3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands: - Add relevant untracked files to the staging area. - - Run git status to make sure the commit succeeded. - -4. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them. + - Create the commit with a message + - Run git status after the commit completes to verify success. + Note: git status depends on the commit completing, so run it sequentially after the commit. +4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- Use the git context at the start of this conversation to determine which files are relevant to your commit. Be careful not to stage and commit files (e.g. with `git add .`) that aren't relevant to your commit. -- NEVER update the git config -- DO NOT run additional commands to read or explore code, beyond what is available in the git context -- DO NOT push to the remote repository +- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER use the TodoWrite or Task tools +- DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit -- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them. -- Return an empty response - the user will see the git output directly # Creating pull requests Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote - - Run a git log command and `git diff main...HEAD` to understand the full commit history for the current branch (from the time it diverged from the `main` branch) - -2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary. Wrap your analysis process in tags: - - -- List the commits since diverging from the main branch -- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.) -- Brainstorm the purpose or motivation behind these changes -- Assess the impact of these changes on the overall project -- Do not use tools to explore code, beyond what is available in the git context -- Check for any sensitive information that shouldn't be committed -- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what" -- Ensure the summary accurately reflects all changes since diverging from the main branch -- Ensure your language is clear, concise, and to the point -- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.) -- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context) -- Review the draft summary to ensure it accurately reflects the changes and their purpose - - -3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel: + - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) +2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary +3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. @@ -146,12 +106,10 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Summary <1-3 bullet points> -EOF -)" Important: -- NEVER update the git config +- DO NOT use the TodoWrite or Task tools - Return the PR URL when you're done, so the user can see it # Other common operations diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f55e8d9d3a1..94930fa446a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.201", + "version": "1.0.203", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 7c3d5ab73d3..f1e0f77a750 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.201", + "version": "1.0.203", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/slack/package.json b/packages/slack/package.json index 766ea72c34e..4c2f8eb7356 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.201", + "version": "1.0.203", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 91db04d1463..0e7da54bdcb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.201", + "version": "1.0.203", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 0a5ffd1abff..3f139065a66 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -6,6 +6,7 @@ const icons = { "arrow-left": ``, archive: ``, "bubble-5": ``, + brain: ``, "bullet-list": ``, "check-small": ``, "chevron-down": ``, diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 380a3c8a46d..7615d1737a3 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,21 +1,6 @@ import { useMarked } from "../context/marked" import { ComponentProps, createResource, splitProps } from "solid-js" -function strip(text: string): string { - const trimmed = text.trim() - const match = trimmed.match(/^<([A-Za-z]\w*)>/) - if (!match) return text - - const tagName = match[1] - const closingTag = `` - if (trimmed.endsWith(closingTag)) { - const content = trimmed.slice(match[0].length, -closingTag.length) - return content.trim() - } - - return text -} - export function Markdown( props: ComponentProps<"div"> & { text: string @@ -26,7 +11,7 @@ export function Markdown( const [local, others] = splitProps(props, ["text", "class", "classList"]) const marked = useMarked() const [html] = createResource( - () => strip(local.text), + () => local.text, async (markdown) => { return marked.parse(markdown) }, diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index ffeb4cb2859..6daf1a8b513 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -72,14 +72,10 @@ } [data-slot="user-message-text"] { - display: -webkit-box; white-space: pre-wrap; - line-clamp: 3; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; overflow: hidden; background: var(--surface-inset-base); - padding: 2px 6px; + padding: 6px 12px; border-radius: 4px; border: 0.5px solid var(--border-weak-base); } diff --git a/packages/ui/src/components/radio-group.css b/packages/ui/src/components/radio-group.css new file mode 100644 index 00000000000..38773b819ad --- /dev/null +++ b/packages/ui/src/components/radio-group.css @@ -0,0 +1,160 @@ +[data-component="radio-group"] { + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 2); + + [data-slot="radio-group-wrapper"] { + all: unset; + background-color: var(--surface-base); + border-radius: var(--radius-md); + box-shadow: inset 0 0 0 1px var(--border-weak-base); + margin: 0; + padding: 0; + position: relative; + width: fit-content; + } + + [data-slot="radio-group-items"] { + display: inline-flex; + list-style: none; + flex-direction: row; + } + + [data-slot="radio-group-indicator"] { + background: var(--button-secondary-base); + border-radius: var(--radius-md); + box-shadow: + var(--shadow-xs), + inset 0 0 0 var(--indicator-focus-width, 0px) var(--border-selected), + inset 0 0 0 1px var(--border-base); + content: ""; + opacity: var(--indicator-opacity, 1); + position: absolute; + transition: + opacity 300ms ease-in-out, + box-shadow 100ms ease-in-out, + width 150ms ease, + height 150ms ease, + transform 150ms ease; + } + + [data-slot="radio-group-item"] { + position: relative; + } + + /* Separator between items */ + [data-slot="radio-group-item"]:not(:first-of-type)::before { + background: var(--border-weak-base); + border-radius: var(--radius-xs); + content: ""; + inset: 6px 0; + position: absolute; + transition: opacity 150ms ease; + width: 1px; + transform: translateX(-0.5px); + } + + /* Hide separator when item or previous item is checked */ + [data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])::before, + [data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked]) + + [data-slot="radio-group-item"]::before { + opacity: 0; + } + + [data-slot="radio-group-item-label"] { + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-md); + cursor: pointer; + display: flex; + flex-wrap: nowrap; + gap: calc(var(--spacing) * 1); + line-height: 1; + padding: 6px 12px; + place-content: center; + position: relative; + transition-duration: 150ms; + transition-property: color, opacity; + transition-timing-function: ease-in-out; + user-select: none; + } + + [data-slot="radio-group-item-input"] { + all: unset; + } + + /* Checked state */ + [data-slot="radio-group-item-input"][data-checked] + [data-slot="radio-group-item-label"] { + color: var(--text-strong); + } + + /* Disabled state */ + [data-slot="radio-group-item-input"][data-disabled] + [data-slot="radio-group-item-label"] { + cursor: not-allowed; + opacity: 0.5; + } + + /* Hover state for unchecked, enabled items */ + [data-slot="radio-group-item-input"]:not([data-checked], [data-disabled]) + [data-slot="radio-group-item-label"] { + cursor: pointer; + user-select: none; + } + + [data-slot="radio-group-item-input"]:not([data-checked], [data-disabled]) + + [data-slot="radio-group-item-label"]:hover { + color: var(--text-base); + } + + [data-slot="radio-group-item-input"]:not([data-checked], [data-disabled]) + + [data-slot="radio-group-item-label"]:active { + opacity: 0.7; + } + + /* Focus state */ + [data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible) + [data-slot="radio-group-indicator"] { + --indicator-focus-width: 2px; + } + + /* Hide indicator when nothing is checked */ + [data-slot="radio-group-wrapper"]:not(:has([data-slot="radio-group-item-input"][data-checked])) + [data-slot="radio-group-indicator"] { + --indicator-opacity: 0; + } + + /* Vertical orientation */ + &[aria-orientation="vertical"] [data-slot="radio-group-items"] { + flex-direction: column; + } + + &[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before { + height: 1px; + width: auto; + inset: 0 6px; + transform: translateY(-0.5px); + } + + /* Small size variant */ + &[data-size="small"] { + [data-slot="radio-group-item-label"] { + font-size: 12px; + padding: 4px 8px; + } + + [data-slot="radio-group-item"]:not(:first-of-type)::before { + inset: 4px 0; + } + + &[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before { + inset: 0 4px; + } + } + + /* Disabled root state */ + &[data-disabled] { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/packages/ui/src/components/radio-group.tsx b/packages/ui/src/components/radio-group.tsx new file mode 100644 index 00000000000..e1812d61a7f --- /dev/null +++ b/packages/ui/src/components/radio-group.tsx @@ -0,0 +1,75 @@ +import { SegmentedControl as Kobalte } from "@kobalte/core/segmented-control" +import { For, splitProps } from "solid-js" +import type { ComponentProps, JSX } from "solid-js" + +export type RadioGroupProps = Omit< + ComponentProps, + "value" | "defaultValue" | "onChange" | "children" +> & { + options: T[] + current?: T + defaultValue?: T + value?: (x: T) => string + label?: (x: T) => JSX.Element | string + onSelect?: (value: T | undefined) => void + class?: ComponentProps<"div">["class"] + classList?: ComponentProps<"div">["classList"] + size?: "small" | "medium" +} + +export function RadioGroup(props: RadioGroupProps) { + const [local, others] = splitProps(props, [ + "class", + "classList", + "options", + "current", + "defaultValue", + "value", + "label", + "onSelect", + "size", + ]) + + const getValue = (item: T): string => { + if (local.value) return local.value(item) + return String(item) + } + + const getLabel = (item: T): JSX.Element | string => { + if (local.label) return local.label(item) + return String(item) + } + + const findOption = (v: string): T | undefined => { + return local.options.find((opt) => getValue(opt) === v) + } + + return ( + local.onSelect?.(findOption(v))} + > +
+ +
+ + {(option) => ( + + + {getLabel(option)} + + )} + +
+
+
+ ) +} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 34b96968d75..404fcffef3e 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -26,7 +26,7 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 42px; + gap: 28px; overflow-anchor: none; } diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index c4302a4d394..5782d2a2929 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -29,6 +29,7 @@ @import "../components/message-nav.css" layer(components); @import "../components/popover.css" layer(components); @import "../components/progress-circle.css" layer(components); +@import "../components/radio-group.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); diff --git a/packages/util/package.json b/packages/util/package.json index 336837181b8..c5df6f176bc 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.201", + "version": "1.0.203", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 7e36b9e03e8..2fb471239b7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.201", + "version": "1.0.203", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index eddcb6c8923..1b4cf99f985 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.201", + "version": "1.0.203", "publisher": "sst-dev", "repository": { "type": "git",