feat: vscode extension (#59)
* feat: add initial VS Code extension plan and implementation tasks * feat(vscode): added initial version of an Openchamber VSCode extension * feat(vscode): enhance VS Code extension with theme integration and session management * feat(vscode): implement connection status handling and overlay in VSCode layout * feat: move extension to secondary sidebar * chore: upgrade @opencode-ai/sdk to 1.0.150 * vscode: editor bridge, file picker, click-to-open in tool parts * vscode: layout session lifecycle, theme sync, typography overrides * ui: compact mode for vscode, model search, autocomplete width fixes * perf: scroll force flag, raf placeholder, git polling backoff * ui: tool output styling, markdown code block fix, gitignore * refactor: update typography handling for VSCode runtime, remove unused styles * docs: update README with VS Code extension details and add extension image * docs: update changelog with new features and performance improvements
This commit is contained in:
committed by
GitHub
parent
610ccf4c62
commit
bb72c0fb0c
@@ -0,0 +1,61 @@
|
||||
name: Publish VS Code Extension
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build VS Code extension
|
||||
run: pnpm -C packages/vscode run build
|
||||
|
||||
- name: Package extension
|
||||
run: pnpm -C packages/vscode exec vsce package --no-dependencies
|
||||
|
||||
- name: Publish to VS Code Marketplace
|
||||
if: ${{ env.VSCE_PAT != '' }}
|
||||
run: pnpm -C packages/vscode exec vsce publish -p "$VSCE_PAT" --no-dependencies
|
||||
|
||||
- name: Publish to Open VSX
|
||||
if: ${{ env.OVSX_PAT != '' }}
|
||||
run: pnpm -C packages/vscode dlx ovsx publish packages/vscode/*.vsix -p "$OVSX_PAT"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openchamber-vscode-vsix
|
||||
path: packages/vscode/*.vsix
|
||||
|
||||
- name: Attach VSIX to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: packages/vscode/*.vsix
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -11,6 +11,7 @@ dist-ssr
|
||||
release
|
||||
*.local
|
||||
*.tgz
|
||||
*.vsix
|
||||
/npm
|
||||
/tsc
|
||||
/openchamber@*
|
||||
|
||||
@@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file.
|
||||
## [Unreleased]
|
||||
|
||||
- Added assistant answer fork flow so users can start a new session from an assistant plan/response with inherited context.
|
||||
- Added OpenChamber VS Code extension with editor integration: file picker, click-to-open in tool parts
|
||||
- Improved scroll performance with force flag and RAF placeholder
|
||||
- Added git polling backoff optimization
|
||||
|
||||
|
||||
## [1.0.9] - 2025-12-08
|
||||
|
||||
@@ -16,6 +16,7 @@ The whole project was built entirely with AI coding agents under my supervision.
|
||||

|
||||

|
||||

|
||||

|
||||
<p>
|
||||
<img src="docs/references/pwa_chat_example.png" width="45%" alt="PWA Chat">
|
||||
<img src="docs/references/pwa_terminal_example.png" width="45%" alt="PWA Terminal">
|
||||
@@ -45,6 +46,10 @@ The whole project was built entirely with AI coding agents under my supervision.
|
||||
|
||||
## Installation
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
Install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions.
|
||||
|
||||
### CLI (Web Server)
|
||||
|
||||
```bash
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 976 KiB |
@@ -0,0 +1,165 @@
|
||||
# VS Code Extension Plan
|
||||
|
||||
Chat UI sidebar panel for VS Code that connects to OpenCode backend.
|
||||
|
||||
## Goals
|
||||
|
||||
- Sidebar webview panel with OpenChamber chat UI
|
||||
- Reuse `@openchamber/ui` components (ChatView, stores, hooks)
|
||||
- Auto-detect VS Code theme (light/dark) and adapt
|
||||
- Use workspace folder as OpenCode directory
|
||||
- Support `@file` mentions via VS Code workspace API
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
packages/vscode/
|
||||
├── src/
|
||||
│ ├── extension.ts # Extension entry, registers webview
|
||||
│ ├── ChatViewProvider.ts # WebviewViewProvider for sidebar
|
||||
│ ├── bridge.ts # Webview <-> Extension host messaging
|
||||
│ └── theme.ts # VS Code theme detection
|
||||
├── webview/
|
||||
│ ├── main.tsx # Webview React entry
|
||||
│ ├── App.tsx # Minimal App (chat-only, no layout)
|
||||
│ └── api/
|
||||
│ ├── index.ts # createVSCodeAPIs()
|
||||
│ ├── files.ts # File listing via extension host
|
||||
│ └── settings.ts # Theme/config from VS Code
|
||||
├── package.json # Extension manifest
|
||||
├── tsconfig.json
|
||||
├── tsconfig.webview.json
|
||||
└── vite.config.ts # Webview bundler
|
||||
```
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Phase 1: Extension Scaffold
|
||||
|
||||
1. Create `packages/vscode/` directory structure
|
||||
2. Create `package.json` with extension manifest
|
||||
- Sidebar view contribution
|
||||
- Commands (new session)
|
||||
- Configuration (API URL)
|
||||
3. Create `tsconfig.json` for extension host (Node.js)
|
||||
4. Create `tsconfig.webview.json` for webview (browser)
|
||||
5. Create `vite.config.ts` for webview bundling
|
||||
6. Add workspace reference in root `pnpm-workspace.yaml`
|
||||
|
||||
### Phase 2: Extension Host
|
||||
|
||||
1. `src/extension.ts` - activate/deactivate, register provider
|
||||
2. `src/ChatViewProvider.ts` - WebviewViewProvider implementation
|
||||
- Generate HTML with CSP
|
||||
- Handle webview lifecycle
|
||||
- Pass workspace folder to webview
|
||||
3. `src/bridge.ts` - Message handler for webview requests
|
||||
- `files:list` - list directory contents
|
||||
- `files:search` - fuzzy file search
|
||||
- `workspace:folder` - get workspace root
|
||||
4. `src/theme.ts` - Detect VS Code color theme kind
|
||||
|
||||
### Phase 3: Webview Runtime
|
||||
|
||||
1. `webview/main.tsx` - React entry point
|
||||
2. `webview/App.tsx` - Simplified app shell
|
||||
- No MainLayout/Header/Sidebar
|
||||
- Just ChatView + essential providers
|
||||
- Theme sync with VS Code
|
||||
3. `webview/api/index.ts` - createVSCodeAPIs()
|
||||
- RuntimeAPIs implementation
|
||||
- IPC bridge to extension host
|
||||
4. `webview/api/files.ts` - FilesAPI via postMessage
|
||||
5. `webview/api/settings.ts` - SettingsAPI (theme from VS Code)
|
||||
|
||||
### Phase 4: Theme Integration
|
||||
|
||||
1. Listen to `vscode.window.onDidChangeActiveColorTheme`
|
||||
2. Map VS Code theme kind to OpenChamber light/dark
|
||||
3. Post theme changes to webview
|
||||
4. Apply theme CSS variables dynamically
|
||||
|
||||
### Phase 5: File Context Support
|
||||
|
||||
1. Implement `files:list` in extension host using `vscode.workspace.fs`
|
||||
2. Implement `files:search` using `vscode.workspace.findFiles`
|
||||
3. Wire up to ChatInput `@file` autocomplete
|
||||
|
||||
### Phase 6: Build & Package
|
||||
|
||||
1. Add build scripts to `packages/vscode/package.json`
|
||||
2. Add root-level scripts (`vscode:dev`, `vscode:build`, `vscode:package`)
|
||||
3. Configure `.vscodeignore` for clean packaging
|
||||
4. Test extension in VS Code Extension Host
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Extension Host (Node.js)
|
||||
- `@types/vscode` - VS Code API types
|
||||
- `esbuild` - Bundle extension.ts
|
||||
|
||||
### Webview (Browser)
|
||||
- `@openchamber/ui` (workspace) - Shared UI components
|
||||
- `vite` + `@vitejs/plugin-react` - Bundle React app
|
||||
- `@opencode-ai/sdk` - OpenCode API client
|
||||
|
||||
## Extension Manifest Highlights
|
||||
|
||||
```json
|
||||
{
|
||||
"contributes": {
|
||||
"views": {
|
||||
"explorer": [{
|
||||
"type": "webview",
|
||||
"id": "openchamber.chatView",
|
||||
"name": "OpenChamber"
|
||||
}]
|
||||
},
|
||||
"commands": [{
|
||||
"command": "openchamber.newSession",
|
||||
"title": "OpenChamber: New Chat Session"
|
||||
}],
|
||||
"configuration": {
|
||||
"properties": {
|
||||
"openchamber.apiUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:47339"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reused from @openchamber/ui
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| `components/chat/*` | Reuse all |
|
||||
| `components/views/ChatView` | Reuse |
|
||||
| `stores/useSessionStore` | Reuse |
|
||||
| `stores/useConfigStore` | Reuse |
|
||||
| `stores/useUIStore` | Partial (no sidebar state) |
|
||||
| `hooks/useEventStream` | Reuse |
|
||||
| `hooks/useMessageSync` | Reuse |
|
||||
| `lib/opencode/client` | Reuse |
|
||||
| `lib/theme/*` | Reuse (CSS generation) |
|
||||
| `components/layout/*` | Skip |
|
||||
| `components/views/GitView` | Skip |
|
||||
| `components/views/DiffView` | Skip |
|
||||
| `components/views/TerminalView` | Skip |
|
||||
|
||||
## Not Needed (handled by VS Code)
|
||||
|
||||
- Terminal API (VS Code integrated terminal)
|
||||
- Git API (VS Code SCM)
|
||||
- Notifications API (VS Code notifications)
|
||||
- Permissions API (VS Code handles sandbox)
|
||||
- Directory picker (workspace folder used)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **View location**: Explorer sidebar
|
||||
- **Sidebar icon**: Reuse from `packages/web/public/` (tab icon)
|
||||
- **Marketplace icon**: Reuse from `packages/desktop/src-tauri/icons/app-icon-checkpoint.svg`
|
||||
- **Publisher**: `fedaykindev` (dev.azure.com/fedaykindev, artmore@protonmail.com)
|
||||
+5
-1
@@ -45,6 +45,10 @@
|
||||
"desktop:build": "pnpm -C packages/desktop build && pnpm -C packages/desktop tauri build",
|
||||
"desktop:lint": "pnpm -C packages/desktop run lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||
"desktop:type-check": "pnpm -C packages/desktop run type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||
"vscode:dev": "pnpm -C packages/vscode run dev",
|
||||
"vscode:build": "pnpm -C packages/vscode run build",
|
||||
"vscode:package": "pnpm -C packages/vscode run package",
|
||||
"vscode:type-check": "pnpm -C packages/vscode run type-check",
|
||||
"version:bump": "node scripts/bump-version.mjs",
|
||||
"release:prepare": "pnpm run build && pnpm run type-check && pnpm run lint"
|
||||
},
|
||||
@@ -55,7 +59,7 @@
|
||||
"@heroui/system": "^2.4.23",
|
||||
"@heroui/theme": "^2.4.23",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@opencode-ai/sdk": "^1.0.133",
|
||||
"@opencode-ai/sdk": "^1.0.150",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -32,7 +32,7 @@ export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
|
||||
};
|
||||
|
||||
return {
|
||||
runtime: { platform: 'desktop', isDesktop: true, label: 'tauri-bootstrap' },
|
||||
runtime: { platform: 'desktop', isDesktop: true, isVSCode: false, label: 'tauri-bootstrap' },
|
||||
terminal: wrappedTerminalAPI,
|
||||
git: createDesktopGitAPI(),
|
||||
files: createDesktopFilesAPI(),
|
||||
|
||||
+20
-1
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||
@@ -34,6 +35,7 @@ function App({ apis }: AppProps) {
|
||||
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||
const { uiFont, monoFont } = useFontPreferences();
|
||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => apis.runtime.isDesktop);
|
||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||
const [cliAvailable, setCliAvailable] = React.useState<boolean>(() => {
|
||||
if (!apis.runtime.isDesktop) return true;
|
||||
return isCliAvailable();
|
||||
@@ -41,7 +43,8 @@ function App({ apis }: AppProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsDesktopRuntime(apis.runtime.isDesktop);
|
||||
}, [apis.runtime.isDesktop]);
|
||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||
}, [apis.runtime.isDesktop, apis.runtime.isVSCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
@@ -165,6 +168,22 @@ function App({ apis }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// VS Code runtime - simplified layout without git/terminal views
|
||||
if (isVSCodeRuntime) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<FireworksProvider>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<VSCodeLayout />
|
||||
<Toaster />
|
||||
</div>
|
||||
</FireworksProvider>
|
||||
</RuntimeAPIProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { OpenCodeIcon } from '@/components/ui/OpenCodeIcon';
|
||||
import { isDesktopRuntime } from '@/lib/desktop';
|
||||
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
|
||||
@@ -89,15 +89,17 @@ type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
|
||||
|
||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
||||
const [state, setState] = React.useState<GateState>(() => (desktopRuntime ? 'authenticated' : 'pending'));
|
||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const skipAuth = desktopRuntime || vscodeRuntime;
|
||||
const [state, setState] = React.useState<GateState>(() => (skipAuth ? 'authenticated' : 'pending'));
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState('');
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(desktopRuntime);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
|
||||
const checkStatus = React.useCallback(async () => {
|
||||
if (desktopRuntime) {
|
||||
if (skipAuth) {
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -119,20 +121,20 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
console.warn('Failed to check session status:', error);
|
||||
setState('error');
|
||||
}
|
||||
}, [desktopRuntime]);
|
||||
}, [skipAuth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (desktopRuntime) {
|
||||
if (skipAuth) {
|
||||
return;
|
||||
}
|
||||
void checkStatus();
|
||||
}, [checkStatus, desktopRuntime]);
|
||||
}, [checkStatus, skipAuth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!desktopRuntime && state === 'locked') {
|
||||
if (!skipAuth && state === 'locked') {
|
||||
hasResyncedRef.current = false;
|
||||
}
|
||||
}, [desktopRuntime, state]);
|
||||
}, [skipAuth, state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state === 'locked' && passwordInputRef.current) {
|
||||
@@ -142,7 +144,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
}, [state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (desktopRuntime) {
|
||||
if (skipAuth) {
|
||||
return;
|
||||
}
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
@@ -153,7 +155,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
||||
await applyPersistedDirectoryPreferences();
|
||||
})();
|
||||
}
|
||||
}, [desktopRuntime, state]);
|
||||
}, [skipAuth, state]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -129,7 +129,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-[240px] max-w-[360px] max-h-60 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{agents.length ? (
|
||||
|
||||
@@ -48,9 +48,9 @@ export const ChatContainer: React.FC = () => {
|
||||
const {
|
||||
scrollRef,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
getAnimationHandlers,
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
spacerHeight,
|
||||
pendingAnchorId,
|
||||
hasActiveAnchor,
|
||||
@@ -179,7 +179,7 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
if (sessionMessages.length === 0 && !streamingMessageId) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full bg-background transform-gpu">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
@@ -239,7 +239,7 @@ export const ChatContainer: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => scrollToBottom()}
|
||||
onClick={() => scrollToBottom({ force: true })}
|
||||
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-accent"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
}
|
||||
|
||||
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||
@@ -145,7 +145,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||
|
||||
scrollToBottom?.({ instant: true });
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
|
||||
const normalizedCommand = messageToSend.trimStart();
|
||||
if (normalizedCommand.startsWith('/')) {
|
||||
@@ -155,7 +155,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
.split(/\s+/)[0]
|
||||
?.toLowerCase();
|
||||
if (commandName === 'summarize') {
|
||||
scrollToBottom?.({ instant: true });
|
||||
scrollToBottom?.({ instant: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ interface ChatMessageProps {
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
isPendingAnchor?: boolean;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-[250px] max-w-[450px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{loading ? (
|
||||
|
||||
@@ -5,24 +5,24 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
|
||||
export const FileAttachmentButton = memo(() => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { addAttachedFile } = useSessionStore();
|
||||
const { isMobile } = useUIStore();
|
||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const attachFiles = async (files: FileList | File[]) => {
|
||||
let attachedCount = 0;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||
try {
|
||||
await addAttachedFile(files[i]);
|
||||
await addAttachedFile(file);
|
||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||
if (sizeAfter > sizeBefore) {
|
||||
attachedCount++;
|
||||
@@ -32,16 +32,63 @@ export const FileAttachmentButton = memo(() => {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
await attachFiles(files);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleVSCodePick = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/vscode/pick-files');
|
||||
const data = await response.json();
|
||||
const picked = Array.isArray(data?.files) ? data.files : [];
|
||||
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
|
||||
|
||||
if (skipped.length > 0) {
|
||||
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
|
||||
toast.error(`Some files were skipped:\n${summary}`);
|
||||
}
|
||||
|
||||
const asFiles = picked
|
||||
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
|
||||
if (!file?.dataUrl) return null;
|
||||
try {
|
||||
const [meta, base64] = file.dataUrl.split(',');
|
||||
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
|
||||
if (!base64) return null;
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: mime });
|
||||
return new File([blob], file.name || 'file', { type: mime });
|
||||
} catch (err) {
|
||||
console.error('Failed to decode VS Code picked file', err);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as File[];
|
||||
|
||||
if (asFiles.length > 0) {
|
||||
await attachFiles(asFiles);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('VS Code file pick failed', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@@ -54,7 +101,13 @@ export const FileAttachmentButton = memo(() => {
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => {
|
||||
if (isVSCodeRuntime) {
|
||||
void handleVSCodePick();
|
||||
} else {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
buttonSizeClass,
|
||||
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
||||
|
||||
@@ -164,7 +164,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-[240px] max-w-[520px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{loading ? (
|
||||
|
||||
@@ -235,7 +235,11 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -259,8 +263,18 @@ const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: strin
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('group relative', className)} ref={codeRef}>
|
||||
{children}
|
||||
<div className="group relative" ref={codeRef}>
|
||||
<pre
|
||||
{...props}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
...style,
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
|
||||
@@ -16,7 +16,7 @@ interface MessageListProps {
|
||||
hasMoreAbove: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
pendingAnchorId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiCheckboxCircleLine, RiCloseCircleLine, RiFileImageLine, RiFileMusicLine, RiFilePdfLine, RiFileVideoLine, RiPencilAiLine, RiQuestionLine, RiText, RiToolsLine } from '@remixicon/react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiCheckboxCircleLine, RiCloseCircleLine, RiFileImageLine, RiFileMusicLine, RiFilePdfLine, RiFileVideoLine, RiPencilAiLine, RiQuestionLine, RiSearchLine, RiText, RiToolsLine } from '@remixicon/react';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||
@@ -25,6 +26,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
@@ -214,8 +216,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||
const isCompact = isMobile || isVSCodeRuntime;
|
||||
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | null>(null);
|
||||
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
|
||||
const [mobileModelQuery, setMobileModelQuery] = React.useState('');
|
||||
const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []);
|
||||
const closeMobileTooltip = React.useCallback(() => setMobileTooltipOpen(null), []);
|
||||
const longPressTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
@@ -247,6 +252,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
if (activeMobilePanel !== 'agent') {
|
||||
setMobileEditOptionsOpen(false);
|
||||
}
|
||||
if (activeMobilePanel !== 'model') {
|
||||
setMobileModelQuery('');
|
||||
}
|
||||
}, [activeMobilePanel]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -321,11 +329,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
}
|
||||
}, [editToggleDisabled]);
|
||||
|
||||
const buttonHeight = isMobile ? 'h-9' : 'h-8';
|
||||
const editToggleIconClass = isMobile ? 'h-5 w-5' : 'h-4 w-4';
|
||||
const controlIconSize = isMobile ? 'h-5 w-5' : 'h-4 w-4';
|
||||
const controlTextSize = isMobile ? 'typography-micro' : 'typography-meta';
|
||||
const inlineGapClass = isMobile ? 'gap-x-2' : 'gap-x-3';
|
||||
const buttonHeight = isCompact ? 'h-9' : 'h-8';
|
||||
const editToggleIconClass = isCompact ? 'h-5 w-5' : 'h-4 w-4';
|
||||
const controlIconSize = isCompact ? 'h-5 w-5' : 'h-4 w-4';
|
||||
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
|
||||
const inlineGapClass = isCompact ? 'gap-x-2' : 'gap-x-3';
|
||||
const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode];
|
||||
|
||||
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
|
||||
@@ -725,7 +733,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
if (currentSessionId) {
|
||||
saveSessionAgentSelection(currentSessionId, agentName);
|
||||
}
|
||||
if (isMobile) {
|
||||
if (isCompact) {
|
||||
closeMobilePanel();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -744,7 +752,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isMobile) {
|
||||
if (isCompact) {
|
||||
closeMobilePanel();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -835,7 +843,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
}, []);
|
||||
|
||||
const renderMobileModelTooltip = () => {
|
||||
if (!isMobile || mobileTooltipOpen !== 'model') return null;
|
||||
if (!isCompact || mobileTooltipOpen !== 'model') return null;
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
@@ -925,7 +933,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
const renderMobileAgentTooltip = () => {
|
||||
if (!isMobile || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
|
||||
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
|
||||
|
||||
const enabledTools = Object.entries(currentAgent.tools || {})
|
||||
.filter(([, enabled]) => enabled)
|
||||
@@ -1067,7 +1075,25 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
const renderMobileModelPanel = () => {
|
||||
if (!isMobile) return null;
|
||||
if (!isCompact) return null;
|
||||
|
||||
const normalizedQuery = mobileModelQuery.trim().toLowerCase();
|
||||
const filteredProviders = providers
|
||||
.map((provider) => {
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
const matchesProvider = normalizedQuery.length === 0
|
||||
? true
|
||||
: provider.name.toLowerCase().includes(normalizedQuery) || provider.id.toLowerCase().includes(normalizedQuery);
|
||||
const matchingModels = normalizedQuery.length === 0
|
||||
? providerModels
|
||||
: providerModels.filter((model: ProviderModel) => {
|
||||
const name = getModelDisplayName(model).toLowerCase();
|
||||
const id = typeof model.id === 'string' ? model.id.toLowerCase() : '';
|
||||
return name.includes(normalizedQuery) || id.includes(normalizedQuery);
|
||||
});
|
||||
return { provider, providerModels: matchingModels, matchesProvider };
|
||||
})
|
||||
.filter(({ matchesProvider, providerModels }) => matchesProvider || providerModels.length > 0);
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
@@ -1075,15 +1101,42 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
onClose={closeMobilePanel}
|
||||
title="Select model"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => {
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
if (providerModels.length === 0) {
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-2">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={mobileModelQuery}
|
||||
onChange={(event) => setMobileModelQuery(event.target.value)}
|
||||
placeholder="Search providers or models"
|
||||
className="pl-7 h-8 typography-meta"
|
||||
/>
|
||||
{mobileModelQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileModelQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<RiCloseCircleLine className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredProviders.length === 0 && (
|
||||
<div className="px-3 py-8 text-center typography-meta text-muted-foreground">
|
||||
No providers or models match your search.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProviders.map(({ provider, providerModels }) => {
|
||||
if (providerModels.length === 0 && !normalizedQuery.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActiveProvider = provider.id === currentProviderId;
|
||||
const isExpanded = expandedMobileProviders.has(provider.id);
|
||||
const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0;
|
||||
|
||||
return (
|
||||
<div key={provider.id} className="rounded-xl border border-border/40 bg-background/95">
|
||||
@@ -1112,7 +1165,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
{isExpanded && providerModels.length > 0 && (
|
||||
<div className="flex flex-col border-t border-border/30">
|
||||
{providerModels.map((model: ProviderModel) => {
|
||||
const isSelected = isActiveProvider && model.id === currentModelId;
|
||||
@@ -1137,29 +1190,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{capabilityIcons.map(({ key, icon: IconComponent, label }) => (
|
||||
<span
|
||||
key={`cap-${provider.id}-${model.id}-${key}`}
|
||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
{inputIcons.map(({ key, icon: IconComponent, label }) => (
|
||||
<span
|
||||
key={`input-${provider.id}-${model.id}-${key}`}
|
||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
||||
title={`${label} input`}
|
||||
aria-label={`${label} input`}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-end gap-1 text-right">
|
||||
{(metadata?.limit?.context || metadata?.limit?.output) && (
|
||||
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
|
||||
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
|
||||
{metadata?.limit?.context && metadata?.limit?.output ? <span>•</span> : null}
|
||||
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{(capabilityIcons.length > 0 || inputIcons.length > 0) && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
|
||||
<span
|
||||
key={`meta-${provider.id}-${model.id}-${key}`}
|
||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -1175,7 +1228,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
const renderMobileAgentPanel = () => {
|
||||
if (!isMobile) return null;
|
||||
if (!isCompact) return null;
|
||||
|
||||
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
|
||||
|
||||
@@ -1397,13 +1450,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
|
||||
const renderModelSelector = () => (
|
||||
<Tooltip delayDuration={1000}>
|
||||
{!isMobile ? (
|
||||
{!isCompact ? (
|
||||
<DropdownMenu open={agentMenuOpen} onOpenChange={setAgentMenuOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 cursor-pointer hover:opacity-70 w-fit',
|
||||
'model-controls__model-trigger flex items-center gap-1.5 cursor-pointer hover:opacity-70 min-w-0 flex-1',
|
||||
buttonHeight
|
||||
)}
|
||||
>
|
||||
@@ -1420,7 +1473,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
)}
|
||||
<span
|
||||
key={`${currentProviderId}-${currentModelId}`}
|
||||
className={cn(controlTextSize, 'font-medium whitespace-nowrap text-foreground', 'max-w-[32vw]', 'md:max-w-[20vw]', 'truncate')}
|
||||
className={cn(
|
||||
'model-controls__model-label',
|
||||
controlTextSize,
|
||||
'font-medium whitespace-nowrap text-foreground truncate min-w-0 flex-1'
|
||||
)}
|
||||
>
|
||||
{getCurrentModelDisplayName()}
|
||||
</span>
|
||||
@@ -1535,8 +1592,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
onTouchCancel={handleLongPressEnd}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 min-w-0 focus:outline-none',
|
||||
'cursor-pointer hover:opacity-70 max-w-full justify-end',
|
||||
'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none flex-1',
|
||||
'cursor-pointer hover:opacity-70',
|
||||
buttonHeight
|
||||
)}
|
||||
>
|
||||
@@ -1548,7 +1605,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
) : (
|
||||
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
|
||||
)}
|
||||
<span className="typography-micro font-medium truncate min-w-0 max-w-[36vw] text-right">
|
||||
<span className="model-controls__model-label typography-micro font-medium truncate min-w-0 flex-1">
|
||||
{getCurrentModelDisplayName()}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1697,7 +1754,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
const renderAgentSelector = () => {
|
||||
if (!isMobile) {
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Tooltip delayDuration={1000}>
|
||||
@@ -1860,10 +1917,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
onTouchEnd={handleLongPressEnd}
|
||||
onTouchCancel={handleLongPressEnd}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
|
||||
'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
|
||||
buttonHeight,
|
||||
'cursor-pointer hover:opacity-70',
|
||||
isMobile && 'ml-1'
|
||||
isCompact && 'ml-1'
|
||||
)}
|
||||
>
|
||||
<RiAiAgentLine
|
||||
@@ -1875,7 +1932,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
|
||||
/>
|
||||
<span
|
||||
className={cn(controlTextSize, 'font-medium truncate', 'max-w-[36vw]', 'md:max-w-[20vw]')}
|
||||
className={cn('model-controls__agent-label', controlTextSize, 'font-medium truncate min-w-0')}
|
||||
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
|
||||
>
|
||||
{getAgentDisplayName()}
|
||||
@@ -1884,12 +1941,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const inlineClassName = cn('flex items-center min-w-0', inlineGapClass, className);
|
||||
const inlineClassName = cn('@container/model-controls flex items-center min-w-0', inlineGapClass, className);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={inlineClassName}>
|
||||
<div className={cn('flex items-center min-w-0', isMobile ? 'flex-1 min-w-0' : undefined)}>
|
||||
<div className={cn('flex items-center min-w-0', !isCompact ? 'flex-1 min-w-0' : undefined)}>
|
||||
{renderModelSelector()}
|
||||
</div>
|
||||
<div className={cn('flex items-center min-w-0', inlineGapClass)}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
@@ -39,6 +40,8 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||
const isCompact = isMobile || isVSCodeRuntime;
|
||||
const { currentDirectory } = useDirectoryStore();
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -331,7 +334,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||
: file.name;
|
||||
const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45;
|
||||
const displayLabel = shouldCompact
|
||||
? truncatePathMiddle(rawLabel, { maxLength: isMobile ? 42 : 48 })
|
||||
? truncatePathMiddle(rawLabel, { maxLength: isCompact ? 42 : 48 })
|
||||
: rawLabel;
|
||||
|
||||
const row = (
|
||||
@@ -437,7 +440,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const scrollAreaClass = isMobile ? 'flex-1 min-h-[240px]' : 'h-[300px]';
|
||||
const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]';
|
||||
|
||||
const pickerBody = (
|
||||
<>
|
||||
@@ -524,7 +527,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
if (isCompact) {
|
||||
return (
|
||||
<>
|
||||
{mobileTrigger}
|
||||
|
||||
@@ -105,7 +105,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border/30 bg-muted/10 overflow-hidden">
|
||||
<div className="h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
|
||||
<div className="tool-output-surface h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
|
||||
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
|
||||
Object.keys(popup.metadata.input).length > 0 &&
|
||||
popup.metadata?.tool !== 'todowrite' &&
|
||||
@@ -127,45 +127,47 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
: 'Input:'}
|
||||
</div>
|
||||
{meta.tool === 'bash' && getInputValue('command') ? (
|
||||
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
|
||||
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('command')!}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : meta.tool === 'task' && getInputValue('prompt') ? (
|
||||
<pre
|
||||
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||
<div
|
||||
className="tool-input-surface bg-transparent rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||
style={toolDisplayStyles.getPopupStyles()}
|
||||
>
|
||||
{getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
|
||||
{getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
|
||||
{`Instructions:\n${getInputValue('prompt')}`}
|
||||
</pre>
|
||||
</div>
|
||||
) : meta.tool === 'write' && getInputValue('content') ? (
|
||||
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
|
||||
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || 'text'}
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('content')!}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||
<div
|
||||
className="tool-input-surface bg-transparent rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||
style={toolDisplayStyles.getPopupStyles()}
|
||||
>
|
||||
{formatInputForDisplay(input, meta.tool as string)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -173,7 +175,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
|
||||
{popup.isDiff ? (
|
||||
diffViewMode === 'unified' ? (
|
||||
<div className="typography-markdown">
|
||||
<div className="typography-code">
|
||||
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||
<div
|
||||
@@ -186,7 +188,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-markdown font-mono px-3 py-0.5 flex',
|
||||
'typography-code font-mono px-3 py-0.5 flex',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
@@ -223,7 +225,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -232,7 +235,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -248,7 +252,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
))}
|
||||
</div>
|
||||
) : popup.diffHunks ? (
|
||||
<div className="typography-markdown">
|
||||
<div className="typography-code">
|
||||
{popup.diffHunks.map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||
<div
|
||||
@@ -261,7 +265,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
<div key={lineIdx} className="grid grid-cols-2 divide-x divide-border/20">
|
||||
<div
|
||||
className={cn(
|
||||
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
|
||||
'typography-code font-mono px-3 py-0.5 overflow-hidden',
|
||||
line.leftLine.type === 'context' && 'bg-transparent',
|
||||
line.leftLine.type === 'empty' && 'bg-transparent'
|
||||
)}
|
||||
@@ -292,7 +296,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -301,7 +306,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -313,7 +319,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
|
||||
'typography-code font-mono px-3 py-0.5 overflow-hidden',
|
||||
line.rightLine.type === 'context' && 'bg-transparent',
|
||||
line.rightLine.type === 'empty' && 'bg-transparent'
|
||||
)}
|
||||
@@ -344,7 +350,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -353,7 +360,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -402,7 +410,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
@@ -417,13 +425,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
{popup.content}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'grep') {
|
||||
return (
|
||||
renderGrepOutput(popup.content, isMobile) || (
|
||||
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
<pre className="typography-code bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
{popup.content}
|
||||
</pre>
|
||||
)
|
||||
@@ -433,7 +441,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
if (tool === 'glob') {
|
||||
return (
|
||||
renderGlobOutput(popup.content, isMobile) || (
|
||||
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
<pre className="typography-code bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
{popup.content}
|
||||
</pre>
|
||||
)
|
||||
@@ -444,7 +452,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
return (
|
||||
<div
|
||||
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
|
||||
style={{ fontSize: 'var(--text-meta)' }}
|
||||
style={{ fontSize: tool === 'task' ? 'var(--text-code)' : 'var(--text-meta)' }}
|
||||
>
|
||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||
{popup.content}
|
||||
@@ -462,7 +470,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
@@ -491,7 +499,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||
|
||||
return (
|
||||
<div key={idx} className={`typography-markdown font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<div key={idx} className={`typography-code font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-4 self-start select-none">
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
</span>
|
||||
@@ -509,7 +517,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -518,7 +527,9 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 'inherit',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -540,7 +551,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
@@ -550,7 +561,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
) : (
|
||||
<div className="p-8 text-muted-foreground typography-ui-header">
|
||||
<div className="mb-2">Command completed successfully</div>
|
||||
<div className="typography-markdown">No output was produced</div>
|
||||
<div className="typography-meta">No output was produced</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -175,7 +176,7 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
|
||||
}) => (
|
||||
<ScrollableOverlay
|
||||
outerClassName={cn('w-full min-w-0 flex-none overflow-hidden', maxHeightClass, outerClassName)}
|
||||
className={cn('p-2 rounded-xl w-full min-w-0 border border-border/20 bg-muted/30', className)}
|
||||
className={cn('tool-output-surface p-2 rounded-xl w-full min-w-0 border border-border/20 bg-transparent', className)}
|
||||
disableHorizontal={disableHorizontal}
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
@@ -191,7 +192,7 @@ interface DiffPreviewProps {
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) => (
|
||||
<div className="typography-meta px-1 pb-1 pt-0 space-y-0">
|
||||
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
|
||||
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
|
||||
@@ -203,7 +204,7 @@ const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) =
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-meta font-mono px-2 py-0.5 flex -mx-2',
|
||||
'typography-code font-mono px-2 py-0.5 flex -mx-2',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
@@ -226,23 +227,24 @@ const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) =
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent !important' },
|
||||
}}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||
}}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -273,7 +275,7 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{lines.map((line, lineIdx) => (
|
||||
<div key={lineIdx} className="typography-meta font-mono px-2 py-0.5 flex -mx-1">
|
||||
<div key={lineIdx} className="typography-code font-mono px-2 py-0.5 flex -mx-1">
|
||||
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-2 self-start select-none">
|
||||
{lineIdx + 1}
|
||||
</span>
|
||||
@@ -288,7 +290,8 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -296,7 +299,7 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent !important' },
|
||||
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
|
||||
}}
|
||||
>
|
||||
{line || ' '}
|
||||
@@ -421,7 +424,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
const listOutput = renderListOutput(outputString, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
listOutput ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
@@ -432,7 +435,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
grepOutput ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
@@ -443,7 +446,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
globOutput ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
@@ -464,7 +467,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
webSearchContent ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
@@ -497,14 +500,14 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
const isInfoMessage = (line: string) => line.trim().startsWith('(');
|
||||
|
||||
return renderScrollableBlock(
|
||||
<div className="typography-meta w-full min-w-0 space-y-1">
|
||||
<div className="typography-code w-full min-w-0 space-y-1">
|
||||
{lines.map((line: string, idx: number) => {
|
||||
const isInfo = isInfoMessage(line);
|
||||
const lineNumber = offset + idx + 1;
|
||||
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||
|
||||
return (
|
||||
<div key={idx} className={cn('typography-meta font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
</span>
|
||||
@@ -522,7 +525,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
@@ -531,7 +535,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -559,7 +564,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
wrapLongLines
|
||||
@@ -603,10 +609,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
) : hasInputText ? (
|
||||
<div className="my-1">
|
||||
{renderScrollableBlock(
|
||||
<blockquote className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
|
||||
<blockquote className="tool-input-text whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
|
||||
{inputTextContent}
|
||||
</blockquote>,
|
||||
{ maxHeightClass: 'max-h-60' }
|
||||
{ maxHeightClass: 'max-h-60', className: 'tool-input-surface' }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -674,10 +680,38 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const input = stateWithData.input;
|
||||
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null;
|
||||
const description = getToolDescription(part, state, isMobile, currentDirectory);
|
||||
const displayName = getToolMetadata(part.tool).displayName;
|
||||
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
const handleMainClick = (e: React.MouseEvent) => {
|
||||
if (!runtime?.editor) {
|
||||
onToggle(part.id);
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath: unknown;
|
||||
if (part.tool === 'edit' || part.tool === 'multiedit') {
|
||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
} else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) {
|
||||
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
}
|
||||
|
||||
if (typeof filePath === 'string') {
|
||||
e.stopPropagation();
|
||||
let absolutePath = filePath;
|
||||
if (!filePath.startsWith('/')) {
|
||||
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
|
||||
}
|
||||
runtime.editor.openFile(absolutePath);
|
||||
} else {
|
||||
onToggle(part.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isFinalized) {
|
||||
return null;
|
||||
}
|
||||
@@ -689,11 +723,11 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
className={cn(
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
|
||||
)}
|
||||
onClick={() => onToggle(part.id)}
|
||||
onClick={handleMainClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{}
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0" onClick={(e) => { e.stopPropagation(); onToggle(part.id); }}>
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -34,6 +34,8 @@ export function WorkingPlaceholder({
|
||||
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resultTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const lastCheckTimeRef = useRef<number>(0);
|
||||
const lastActiveStatusRef = useRef<string | null>(null);
|
||||
const hasShownActivityRef = useRef<boolean>(false);
|
||||
const wasAbortedRef = useRef<boolean>(false);
|
||||
@@ -207,7 +209,16 @@ export function WorkingPlaceholder({
|
||||
wasAbortedRef.current = false;
|
||||
};
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
const CHECK_THROTTLE_MS = 150; // Throttle checks to ~6-7 times per second
|
||||
|
||||
const checkLoop = (timestamp: number) => {
|
||||
// Throttle: skip if less than CHECK_THROTTLE_MS since last check
|
||||
if (timestamp - lastCheckTimeRef.current < CHECK_THROTTLE_MS) {
|
||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
||||
return;
|
||||
}
|
||||
lastCheckTimeRef.current = timestamp;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - displayStartTimeRef.current;
|
||||
|
||||
@@ -216,6 +227,7 @@ export function WorkingPlaceholder({
|
||||
const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0;
|
||||
|
||||
if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) {
|
||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -257,14 +269,24 @@ export function WorkingPlaceholder({
|
||||
lastActiveStatusRef.current = null;
|
||||
removalPendingRef.current = false;
|
||||
wasAbortedRef.current = false;
|
||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
||||
return;
|
||||
}
|
||||
|
||||
startFadeOut(result);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
||||
};
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
}, [isFadingOut]);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const renderListOutput = (output: string, options?: { unstyled?: boolean
|
||||
'w-full min-w-0 font-mono space-y-0.5',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
style={typography.micro}
|
||||
style={typography.tool.popup}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="min-w-0" style={{ paddingLeft: `${item.depth * 20}px` }}>
|
||||
@@ -115,13 +115,14 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
|
||||
'space-y-2 w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
style={typography.tool.popup}
|
||||
>
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
|
||||
</div>
|
||||
{Object.entries(fileGroups).map(([filepath, matches]) => (
|
||||
<div key={filepath} className="space-y-1">
|
||||
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||
{filepath}
|
||||
</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
@@ -130,7 +131,7 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
{match.lineNum && (
|
||||
@@ -181,18 +182,19 @@ export const renderGlobOutput = (output: string, isMobile: boolean, options?: {
|
||||
'space-y-2 w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
style={typography.tool.popup}
|
||||
>
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
Found {paths.length} file{paths.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{sortedDirs.map((dir) => (
|
||||
<div key={dir} className="space-y-1">
|
||||
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||
{dir}/
|
||||
</div>
|
||||
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||
{groups[dir].sort().map((filename) => (
|
||||
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
||||
<span className="text-foreground font-mono truncate">{filename}</span>
|
||||
</div>
|
||||
@@ -248,6 +250,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
|
||||
'space-y-3 w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
style={typography.tool.popup}
|
||||
>
|
||||
<div className="flex gap-4 typography-meta pb-2 border-b border-border/20">
|
||||
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>Total: {todos.length}</span>
|
||||
@@ -275,7 +278,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
|
||||
{todosByStatus.in_progress.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
{getPriorityDot(todo.priority)}
|
||||
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -292,7 +295,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
|
||||
{todosByStatus.pending.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
{getPriorityDot(todo.priority)}
|
||||
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -309,7 +312,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
|
||||
{todosByStatus.completed.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
<RiCheckLine className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--status-success)', opacity: 0.7 }} />
|
||||
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -326,7 +329,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
|
||||
{todosByStatus.cancelled.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
<span className="w-3 h-3 text-muted-foreground/50 mt-0.5 flex-shrink-0">×</span>
|
||||
<span className="typography-meta text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -344,9 +347,10 @@ export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: stri
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'typography-meta max-w-none w-full min-w-0',
|
||||
'typography-code max-w-none w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/20'
|
||||
)}
|
||||
style={typography.tool.popup}
|
||||
>
|
||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||
{output}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||
import { ChatView } from '@/components/views';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||
import { RiAddLine, RiArrowLeftLine } from '@remixicon/react';
|
||||
import { RiLoader4Line } from '@remixicon/react';
|
||||
|
||||
type VSCodeView = 'sessions' | 'chat';
|
||||
|
||||
export const VSCodeLayout: React.FC = () => {
|
||||
const [currentView, setCurrentView] = React.useState<VSCodeView>('sessions');
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const createSession = useSessionStore((state) => state.createSession);
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
|
||||
() => (typeof window !== 'undefined'
|
||||
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
|
||||
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
|
||||
: 'connecting') || 'connecting'
|
||||
);
|
||||
const [connectionError, setConnectionError] = React.useState<string | undefined>(
|
||||
() => (typeof window !== 'undefined'
|
||||
? (window as { __OPENCHAMBER_CONNECTION__?: { error?: string } }).__OPENCHAMBER_CONNECTION__?.error
|
||||
: undefined),
|
||||
);
|
||||
const [hasEverConnected, setHasEverConnected] = React.useState<boolean>(() => connectionStatus === 'connected');
|
||||
const [overlayVisible, setOverlayVisible] = React.useState<boolean>(() => connectionStatus !== 'connected');
|
||||
const overlayTimer = React.useRef<number | null>(null);
|
||||
const configInitialized = useConfigStore((state) => state.isInitialized);
|
||||
const initializeConfig = useConfigStore((state) => state.initializeApp);
|
||||
const loadSessions = useSessionStore((state) => state.loadSessions);
|
||||
const loadMessages = useSessionStore((state) => state.loadMessages);
|
||||
const messages = useSessionStore((state) => state.messages);
|
||||
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
|
||||
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
|
||||
const autoSelectedRef = React.useRef<boolean>(false);
|
||||
const startedFreshSessionRef = React.useRef<boolean>(false);
|
||||
|
||||
// Navigate to chat when a session is selected
|
||||
React.useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
setCurrentView('chat');
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
// If the active session disappears (e.g., deleted), stay on the sessions list
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId && currentView === 'chat') {
|
||||
setCurrentView('sessions');
|
||||
}
|
||||
}, [currentSessionId, currentView]);
|
||||
|
||||
const handleBackToSessions = React.useCallback(() => {
|
||||
setCurrentView('sessions');
|
||||
}, []);
|
||||
|
||||
const handleNewSession = React.useCallback(async () => {
|
||||
const result = await createSession();
|
||||
if (result?.id) {
|
||||
setCurrentView('chat');
|
||||
}
|
||||
}, [createSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
|
||||
const status = detail?.status;
|
||||
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
|
||||
setConnectionStatus(status);
|
||||
setConnectionError(detail?.error);
|
||||
if (status === 'connected') {
|
||||
setHasEverConnected(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('openchamber:connection-status', handler as EventListener);
|
||||
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
const showConnectionOverlay = React.useMemo(() => {
|
||||
if (hasInitializedOnce && connectionStatus === 'connected' && !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (!hasInitializedOnce) {
|
||||
return connectionStatus !== 'connected' || isInitializing;
|
||||
}
|
||||
return connectionStatus === 'error';
|
||||
}, [connectionStatus, hasInitializedOnce, isInitializing]);
|
||||
|
||||
const overlayDelay = hasEverConnected ? 800 : 250;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (overlayTimer.current) {
|
||||
window.clearTimeout(overlayTimer.current);
|
||||
overlayTimer.current = null;
|
||||
}
|
||||
|
||||
if (showConnectionOverlay) {
|
||||
overlayTimer.current = window.setTimeout(() => setOverlayVisible(true), overlayDelay);
|
||||
} else {
|
||||
setOverlayVisible(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (overlayTimer.current) {
|
||||
window.clearTimeout(overlayTimer.current);
|
||||
overlayTimer.current = null;
|
||||
}
|
||||
};
|
||||
}, [overlayDelay, showConnectionOverlay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const runBootstrap = async () => {
|
||||
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
|
||||
return;
|
||||
}
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
if (!configInitialized) {
|
||||
await initializeConfig();
|
||||
}
|
||||
await loadSessions();
|
||||
setHasInitializedOnce(true);
|
||||
} catch {
|
||||
// Ignore bootstrap failures; overlay will remain until next attempt
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
void runBootstrap();
|
||||
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const hydrateMessages = async () => {
|
||||
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat') {
|
||||
return;
|
||||
}
|
||||
const targetSessionId = currentSessionId || sessions[0]?.id;
|
||||
if (!targetSessionId) return;
|
||||
|
||||
const hasMessages = messages.has(targetSessionId) && (messages.get(targetSessionId)?.length || 0) > 0;
|
||||
if (!hasMessages) {
|
||||
if (!currentSessionId) {
|
||||
setCurrentSession(targetSessionId);
|
||||
}
|
||||
try {
|
||||
await loadMessages(targetSessionId);
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
};
|
||||
|
||||
void hydrateMessages();
|
||||
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, sessions, setCurrentSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasInitializedOnce || autoSelectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!currentSessionId && sessions.length > 0) {
|
||||
setCurrentSession(sessions[0].id);
|
||||
autoSelectedRef.current = true;
|
||||
}
|
||||
}, [currentSessionId, hasInitializedOnce, sessions, setCurrentSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const ensureFreshSession = async () => {
|
||||
if (connectionStatus !== 'connected' || !hasInitializedOnce || startedFreshSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = sessions.find((s) => s.id === currentSessionId);
|
||||
const isCurrentPlaceholder = current?.title?.toLowerCase()?.startsWith('new session');
|
||||
|
||||
if (current && isCurrentPlaceholder) {
|
||||
setCurrentSession(current.id);
|
||||
} else {
|
||||
// Look for an existing empty session to reuse before creating a new one
|
||||
const reusableSession = sessions.find((s) => s.title?.toLowerCase()?.startsWith('new session'));
|
||||
|
||||
if (reusableSession) {
|
||||
setCurrentSession(reusableSession.id);
|
||||
} else {
|
||||
const newSession = await createSession();
|
||||
if (newSession?.id) {
|
||||
setCurrentSession(newSession.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
startedFreshSessionRef.current = true;
|
||||
};
|
||||
|
||||
void ensureFreshSession();
|
||||
}, [connectionStatus, createSession, currentSessionId, hasInitializedOnce, sessions, setCurrentSession]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-background text-foreground flex flex-col">
|
||||
{currentView === 'sessions' ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader title="Sessions" onNewSession={handleNewSession} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SessionSidebar
|
||||
mobileVariant
|
||||
allowReselect
|
||||
onSessionSelected={() => setCurrentView('chat')}
|
||||
hideDirectoryControls
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader
|
||||
title={sessions.find(s => s.id === currentSessionId)?.title || 'Chat'}
|
||||
showBack
|
||||
onBack={handleBackToSessions}
|
||||
showContextUsage
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{overlayVisible && (
|
||||
<div className="absolute inset-0 z-50 bg-background/90 backdrop-blur-sm flex flex-col items-center justify-center gap-3 text-center px-4">
|
||||
<RiLoader4Line className="h-7 w-7 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-medium">
|
||||
{connectionStatus === 'connecting'
|
||||
? (hasEverConnected ? 'Reconnecting to OpenCode…' : 'Starting OpenCode API…')
|
||||
: 'Lost connection to OpenCode'}
|
||||
</div>
|
||||
{connectionError && (
|
||||
<div className="text-xs text-muted-foreground max-w-md">
|
||||
{connectionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface VSCodeHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
onNewSession?: () => void;
|
||||
showContextUsage?: boolean;
|
||||
}
|
||||
|
||||
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, showContextUsage }) => {
|
||||
const { getCurrentModel } = useConfigStore();
|
||||
const getContextUsage = useSessionStore((state) => state.getContextUsage);
|
||||
|
||||
const currentModel = getCurrentModel();
|
||||
const limits = (currentModel?.limit && typeof currentModel.limit === 'object'
|
||||
? currentModel.limit
|
||||
: null) as { context?: number; output?: number } | null;
|
||||
const contextLimit = typeof limits?.context === 'number' ? limits.context : 0;
|
||||
const outputLimit = typeof limits?.output === 'number' ? limits.output : 0;
|
||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-background shrink-0">
|
||||
{showBack && onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Back to sessions"
|
||||
>
|
||||
<RiArrowLeftLine className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
|
||||
{onNewSession && (
|
||||
<button
|
||||
onClick={onNewSession}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="New session"
|
||||
>
|
||||
<RiAddLine className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
|
||||
<ContextUsageDisplay
|
||||
totalTokens={contextUsage.totalTokens}
|
||||
percentage={contextUsage.percentage}
|
||||
contextLimit={contextUsage.contextLimit}
|
||||
outputLimit={contextUsage.outputLimit ?? 0}
|
||||
size="compact"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -107,9 +107,17 @@ type SessionGroup = {
|
||||
|
||||
interface SessionSidebarProps {
|
||||
mobileVariant?: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
allowReselect?: boolean;
|
||||
hideDirectoryControls?: boolean;
|
||||
}
|
||||
|
||||
export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant = false }) => {
|
||||
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
mobileVariant = false,
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = false,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
@@ -336,9 +344,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!allowReselect && sessionId === currentSessionId) {
|
||||
onSessionSelected?.(sessionId);
|
||||
return;
|
||||
}
|
||||
setCurrentSession(sessionId);
|
||||
onSessionSelected?.(sessionId);
|
||||
},
|
||||
[setCurrentSession],
|
||||
[allowReselect, currentSessionId, onSessionSelected, setCurrentSession],
|
||||
);
|
||||
|
||||
const handleSaveEdit = React.useCallback(async () => {
|
||||
@@ -922,48 +935,50 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
|
||||
mobileVariant ? '' : isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar',
|
||||
)}
|
||||
>
|
||||
<div className="h-14 select-none px-2 flex-shrink-0">
|
||||
<div className="flex h-full items-center gap-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDirectoryDialog}
|
||||
className={cn(
|
||||
'group flex min-w-0 flex-1 items-center gap-2 rounded-md px-0 py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
!isDesktopRuntime && 'hover:bg-sidebar/20',
|
||||
)}
|
||||
aria-label="Change project directory"
|
||||
title={directoryTooltip || '/'}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground group-hover:text-foreground',
|
||||
!isDesktopRuntime && 'bg-sidebar/60',
|
||||
)}
|
||||
>
|
||||
<RiFolder6Line className="h-[1.125rem] w-[1.125rem] translate-y-px" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<p className="truncate whitespace-nowrap typography-ui font-semibold text-muted-foreground group-hover:text-foreground">
|
||||
{displayDirectory || '/'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isGitRepo ? (
|
||||
{!hideDirectoryControls && (
|
||||
<div className="h-14 select-none px-2 flex-shrink-0">
|
||||
<div className="flex h-full items-center gap-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorktreeManager}
|
||||
onClick={handleOpenDirectoryDialog}
|
||||
className={cn(
|
||||
'inline-flex h-10 w-7 flex-shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
!isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
||||
'group flex min-w-0 flex-1 items-center gap-2 rounded-md px-0 py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
!isDesktopRuntime && 'hover:bg-sidebar/20',
|
||||
)}
|
||||
aria-label="Manage worktrees"
|
||||
aria-label="Change project directory"
|
||||
title={directoryTooltip || '/'}
|
||||
>
|
||||
<RiGitRepositoryLine className="h-[1.125rem] w-[1.125rem] translate-y-px" />
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground group-hover:text-foreground',
|
||||
!isDesktopRuntime && 'bg-sidebar/60',
|
||||
)}
|
||||
>
|
||||
<RiFolder6Line className="h-[1.125rem] w-[1.125rem] translate-y-px" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<p className="truncate whitespace-nowrap typography-ui font-semibold text-muted-foreground group-hover:text-foreground">
|
||||
{displayDirectory || '/'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{isGitRepo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorktreeManager}
|
||||
className={cn(
|
||||
'inline-flex h-10 w-7 flex-shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
!isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
||||
)}
|
||||
aria-label="Manage worktrees"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-[1.125rem] w-[1.125rem] translate-y-px" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollableOverlay
|
||||
outerClassName="flex-1 min-h-0"
|
||||
@@ -971,6 +986,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
|
||||
>
|
||||
{groupedSessions.length === 0 ? (
|
||||
emptyState
|
||||
) : hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{groupedSessions[0].sessions.length === 0 ? (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
No sessions yet.
|
||||
</div>
|
||||
) : (
|
||||
groupedSessions[0].sessions.map((node) => renderSessionNode(node, 0, groupedSessions[0].directory))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
groupedSessions.map((group) => (
|
||||
<div key={group.id} className="relative">
|
||||
@@ -997,30 +1022,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
|
||||
<span className="typography-micro font-medium text-muted-foreground truncate group-hover/header:text-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={isCreatingSession}
|
||||
className={cn(
|
||||
'inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
isCreatingSession && 'opacity-40 cursor-default',
|
||||
)}
|
||||
aria-label="Create session in this group"
|
||||
onClick={(e) => {
|
||||
if (isCreatingSession) return;
|
||||
e.stopPropagation();
|
||||
handleCreateSessionInGroup(group.directory);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isCreatingSession) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
{!hideDirectoryControls && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={isCreatingSession}
|
||||
className={cn(
|
||||
'inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
isCreatingSession && 'opacity-40 cursor-default',
|
||||
)}
|
||||
aria-label="Create session in this group"
|
||||
onClick={(e) => {
|
||||
if (isCreatingSession) return;
|
||||
e.stopPropagation();
|
||||
handleCreateSessionInGroup(group.directory);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RiAddLine className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isCreatingSession) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
handleCreateSessionInGroup(group.directory);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RiAddLine className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
} from 'react';
|
||||
import type { Theme, ThemeMode } from '@/types/theme';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { isDesktopRuntime } from '@/lib/desktop';
|
||||
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import {
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
flexokiDarkTheme,
|
||||
} from '@/lib/theme/themes';
|
||||
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
|
||||
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type ThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
@@ -50,6 +52,8 @@ const ensureThemeById = (themeId: string, variant: 'light' | 'dark'): Theme => {
|
||||
return fallback ?? fallbackThemeForVariant(variant);
|
||||
};
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
||||
|
||||
const validateThemeId = (themeId: string | null, variant: 'light' | 'dark'): string => {
|
||||
if (!themeId) {
|
||||
return variant === 'light' ? DEFAULT_LIGHT_ID : DEFAULT_DARK_ID;
|
||||
@@ -126,8 +130,19 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
|
||||
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getSystemPreference());
|
||||
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
|
||||
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
|
||||
return null;
|
||||
}
|
||||
const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__;
|
||||
return existing || null;
|
||||
});
|
||||
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
const currentTheme = useMemo(() => {
|
||||
if (isVSCode && vscodeTheme) {
|
||||
return vscodeTheme;
|
||||
}
|
||||
if (preferences.themeMode === 'light') {
|
||||
return ensureThemeById(preferences.lightThemeId, 'light');
|
||||
}
|
||||
@@ -137,9 +152,42 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return systemPrefersDark
|
||||
? ensureThemeById(preferences.darkThemeId, 'dark')
|
||||
: ensureThemeById(preferences.lightThemeId, 'light');
|
||||
}, [preferences, systemPrefersDark]);
|
||||
}, [isVSCode, preferences, systemPrefersDark, vscodeTheme]);
|
||||
|
||||
const availableThemes = themes;
|
||||
const availableThemes = useMemo(
|
||||
() => (isVSCode && vscodeTheme ? [vscodeTheme, ...themes] : themes),
|
||||
[isVSCode, vscodeTheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVSCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyVSCodeTheme = (theme: Theme) => {
|
||||
setVSCodeTheme(theme);
|
||||
const variant: ThemeMode = theme.metadata.variant === 'dark' ? 'dark' : 'light';
|
||||
const uiStore = useUIStore.getState();
|
||||
if (uiStore.theme !== variant) {
|
||||
uiStore.setTheme(variant);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<VSCodeThemePayload>).detail;
|
||||
if (detail?.theme) {
|
||||
applyVSCodeTheme(detail.theme);
|
||||
}
|
||||
};
|
||||
|
||||
const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__;
|
||||
if (existing) {
|
||||
applyVSCodeTheme(existing);
|
||||
}
|
||||
|
||||
window.addEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener);
|
||||
return () => window.removeEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener);
|
||||
}, [isVSCode]);
|
||||
|
||||
const updateBrowserChrome = useCallback((theme: Theme) => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -177,17 +225,25 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
metaThemeColorMedia.setAttribute('content', chromeColor);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const applyVSCodeRuntimeClass = useCallback((enabled: boolean) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.toggle('vscode-runtime', enabled);
|
||||
}, []);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
cssGenerator.apply(currentTheme);
|
||||
applyVSCodeRuntimeClass(isVSCode);
|
||||
updateBrowserChrome(currentTheme);
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(currentTheme.metadata.variant);
|
||||
}, [cssGenerator, currentTheme, updateBrowserChrome]);
|
||||
}, [applyVSCodeRuntimeClass, cssGenerator, currentTheme, isVSCode, updateBrowserChrome]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preferences.themeMode !== 'system' || typeof window === 'undefined') {
|
||||
|
||||
@@ -56,7 +56,7 @@ interface UseChatScrollManagerResult {
|
||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
showScrollButton: boolean;
|
||||
scrollToBottom: (options?: { instant?: boolean }) => void;
|
||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
spacerHeight: number;
|
||||
pendingAnchorId: string | null;
|
||||
hasActiveAnchor: boolean;
|
||||
@@ -116,6 +116,7 @@ export const useChatScrollManager = ({
|
||||
const anchorIdRef = React.useRef<string | null>(null);
|
||||
|
||||
const hasAnchoredOnceRef = React.useRef<boolean>(false);
|
||||
const userScrollOverrideRef = React.useRef<boolean>(false);
|
||||
|
||||
const currentPhase = currentSessionId
|
||||
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
|
||||
@@ -238,13 +239,30 @@ export const useChatScrollManager = ({
|
||||
}
|
||||
}, [pendingAnchorId]);
|
||||
|
||||
const scrollToBottom = React.useCallback((options?: { instant?: boolean }) => {
|
||||
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
|
||||
const shouldRespectUserScroll =
|
||||
userScrollOverrideRef.current &&
|
||||
currentPhase === 'idle' &&
|
||||
!isSyncing &&
|
||||
!options?.force &&
|
||||
distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD;
|
||||
|
||||
if (shouldRespectUserScroll) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
userScrollOverrideRef.current = false;
|
||||
}
|
||||
|
||||
const bottom = container.scrollHeight - container.clientHeight;
|
||||
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
|
||||
}, [scrollEngine]);
|
||||
}, [currentPhase, isSyncing, scrollEngine]);
|
||||
|
||||
const scrollToNewAnchor = React.useCallback((messageId: string) => {
|
||||
if (lastScrolledAnchorIdRef.current === messageId) {
|
||||
@@ -294,12 +312,16 @@ export const useChatScrollManager = ({
|
||||
});
|
||||
}, [scrollEngine, updateSpacerHeight]);
|
||||
|
||||
const handleScrollEvent = React.useCallback(() => {
|
||||
const handleScrollEvent = React.useCallback((event?: Event) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.isTrusted) {
|
||||
userScrollOverrideRef.current = true;
|
||||
}
|
||||
|
||||
scrollEngine.handleScroll();
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
@@ -328,10 +350,10 @@ export const useChatScrollManager = ({
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('scroll', handleScrollEvent, { passive: true });
|
||||
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScrollEvent);
|
||||
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
||||
};
|
||||
}, [handleScrollEvent]);
|
||||
|
||||
@@ -376,6 +398,7 @@ export const useChatScrollManager = ({
|
||||
|
||||
spacerHeightRef.current = 0;
|
||||
setSpacerHeight(0);
|
||||
userScrollOverrideRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run on session change, not message changes
|
||||
}, [currentSessionId, sessionMessages.length]);
|
||||
|
||||
@@ -16,3 +16,5 @@ export const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TV
|
||||
};
|
||||
|
||||
export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop);
|
||||
|
||||
export const useIsVSCodeRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isVSCode);
|
||||
|
||||
@@ -75,25 +75,15 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [mockMode, setMockMode] = useState(shouldMockUpdate);
|
||||
const [mockState, setMockState] = useState<UpdateState | null>(null);
|
||||
|
||||
// Check for mock mode changes (for console toggling)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const shouldMock = shouldMockUpdate();
|
||||
if (shouldMock !== mockMode) {
|
||||
setMockMode(shouldMock);
|
||||
if (shouldMock) {
|
||||
const config = getMockConfig();
|
||||
if (config) {
|
||||
setMockState(createMockUpdate(config));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [mockMode]);
|
||||
// Only check mock mode once at startup - no polling
|
||||
const [mockMode] = useState(shouldMockUpdate);
|
||||
const [mockState, setMockState] = useState<UpdateState | null>(() => {
|
||||
if (shouldMockUpdate()) {
|
||||
const config = getMockConfig();
|
||||
return config ? createMockUpdate(config) : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
if (mockMode) {
|
||||
|
||||
@@ -1259,9 +1259,55 @@ html:not(.dark) .chat-scroll {
|
||||
Minimal overrides - fonts only, using Streamdown defaults
|
||||
============================================ */
|
||||
|
||||
/* VS Code webviews can inject default pre/code/blockquote backgrounds; tool input/output should inherit the card surface. */
|
||||
.tool-input-surface pre,
|
||||
.tool-input-surface code,
|
||||
.tool-input-surface blockquote,
|
||||
.tool-input-text {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* VSCode: tool cards should honor semantic code sizing, even when syntax themes inject sizes. */
|
||||
:root.vscode-runtime .tool-input-surface,
|
||||
:root.vscode-runtime .tool-output-surface {
|
||||
font-size: var(--text-code) !important;
|
||||
}
|
||||
|
||||
:root.vscode-runtime .tool-input-surface code,
|
||||
:root.vscode-runtime .tool-input-surface pre,
|
||||
:root.vscode-runtime .tool-output-surface code,
|
||||
:root.vscode-runtime .tool-output-surface pre {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
/* Model/agent controls: collapse labels in narrow containers (mobile + VSCode side panel). */
|
||||
@container model-controls (max-width: 15rem) {
|
||||
.model-controls__agent-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container model-controls (max-width: 12rem) {
|
||||
.model-controls__model-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text font: IBM Plex Sans */
|
||||
.streamdown-content {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-markdown);
|
||||
}
|
||||
|
||||
/* Tool markdown should render at code size to match tool card typography. */
|
||||
.streamdown-content.streamdown-tool {
|
||||
font-size: var(--text-code) !important;
|
||||
}
|
||||
|
||||
.streamdown-content.streamdown-tool code,
|
||||
.streamdown-content.streamdown-tool pre {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
/* Code font: IBM Plex Mono */
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export type RuntimePlatform = 'web' | 'desktop';
|
||||
export type RuntimePlatform = 'web' | 'desktop' | 'vscode';
|
||||
|
||||
export interface RuntimeDescriptor {
|
||||
platform: RuntimePlatform;
|
||||
|
||||
isDesktop: boolean;
|
||||
|
||||
isVSCode: boolean;
|
||||
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -386,6 +388,11 @@ export interface ToolsAPI {
|
||||
getAvailableTools(): Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface EditorAPI {
|
||||
openFile(path: string, line?: number, column?: number): Promise<void>;
|
||||
openDiff(original: string, modified: string, label?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimeAPIs {
|
||||
runtime: RuntimeDescriptor;
|
||||
terminal: TerminalAPI;
|
||||
@@ -396,6 +403,7 @@ export interface RuntimeAPIs {
|
||||
notifications: NotificationsAPI;
|
||||
diagnostics?: DiagnosticsAPI;
|
||||
tools: ToolsAPI;
|
||||
editor?: EditorAPI;
|
||||
|
||||
worktrees?: WorktreeMetadata[];
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ export type DesktopApi = {
|
||||
export const isDesktopRuntime = (): boolean =>
|
||||
typeof window !== "undefined" && typeof window.opencodeDesktop !== "undefined";
|
||||
|
||||
export const isVSCodeRuntime = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
return apis?.runtime?.isVSCode === true;
|
||||
};
|
||||
|
||||
export const getDesktopApi = (): DesktopApi | null => {
|
||||
if (!isDesktopRuntime()) {
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography';
|
||||
import { SEMANTIC_TYPOGRAPHY, VSCODE_TYPOGRAPHY } from '@/lib/typography';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
const hexToRgb = (value: string | undefined | null): string | null => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
@@ -525,22 +526,23 @@ export class CSSVariableGenerator {
|
||||
|
||||
private generateTypographyVariables(): string[] {
|
||||
const vars: string[] = [];
|
||||
const typography = isVSCodeRuntime() ? VSCODE_TYPOGRAPHY : SEMANTIC_TYPOGRAPHY;
|
||||
|
||||
vars.push(' /* Semantic Typography Variables */');
|
||||
vars.push(' --ui-regular-font-weight: 400;');
|
||||
|
||||
vars.push(' /* Markdown content - all markdown elements use same size */');
|
||||
vars.push(` --text-markdown: ${SEMANTIC_TYPOGRAPHY.markdown};`);
|
||||
vars.push(` --text-markdown: ${typography.markdown};`);
|
||||
vars.push(' /* Code content - all code elements use same size */');
|
||||
vars.push(` --text-code: ${SEMANTIC_TYPOGRAPHY.code};`);
|
||||
vars.push(` --text-code: ${typography.code};`);
|
||||
vars.push(' /* UI headers - dialog titles, panel headers */');
|
||||
vars.push(` --text-ui-header: ${SEMANTIC_TYPOGRAPHY.uiHeader};`);
|
||||
vars.push(` --text-ui-header: ${typography.uiHeader};`);
|
||||
vars.push(' /* UI labels - buttons, menus, navigation */');
|
||||
vars.push(` --text-ui-label: ${SEMANTIC_TYPOGRAPHY.uiLabel};`);
|
||||
vars.push(` --text-ui-label: ${typography.uiLabel};`);
|
||||
vars.push(' /* Metadata - timestamps, status, helper text */');
|
||||
vars.push(` --text-meta: ${SEMANTIC_TYPOGRAPHY.meta};`);
|
||||
vars.push(` --text-meta: ${typography.meta};`);
|
||||
vars.push(' /* Micro text - badges, shortcuts, indicators */');
|
||||
vars.push(` --text-micro: ${SEMANTIC_TYPOGRAPHY.micro};`);
|
||||
vars.push(` --text-micro: ${typography.micro};`);
|
||||
|
||||
vars.push(' /* Heading line height and letter spacing */');
|
||||
vars.push(' --h1-line-height: 1.25rem;');
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Theme } from '@/types/theme';
|
||||
import type { ThemeMode } from '@/types/theme';
|
||||
import { flexokiDarkTheme, flexokiLightTheme } from '@/lib/theme/themes';
|
||||
|
||||
export type VSCodeThemeKind = 'light' | 'dark' | 'high-contrast';
|
||||
|
||||
export type VSCodeThemeColorToken =
|
||||
| 'editor.background'
|
||||
| 'editor.foreground'
|
||||
| 'editor.selectionBackground'
|
||||
| 'editor.selectionForeground'
|
||||
| 'editor.lineHighlightBackground'
|
||||
| 'editorCursor.foreground'
|
||||
| 'focusBorder'
|
||||
| 'sideBar.background'
|
||||
| 'sideBar.foreground'
|
||||
| 'panel.background'
|
||||
| 'panel.foreground'
|
||||
| 'panel.border'
|
||||
| 'input.background'
|
||||
| 'input.foreground'
|
||||
| 'input.border'
|
||||
| 'button.background'
|
||||
| 'button.foreground'
|
||||
| 'button.hoverBackground'
|
||||
| 'textLink.foreground'
|
||||
| 'descriptionForeground'
|
||||
| 'terminal.ansiRed'
|
||||
| 'terminal.ansiGreen'
|
||||
| 'terminal.ansiBlue'
|
||||
| 'terminal.ansiYellow'
|
||||
| 'terminal.ansiCyan'
|
||||
| 'editorError.foreground'
|
||||
| 'editorError.background'
|
||||
| 'editorWarning.foreground'
|
||||
| 'editorWarning.background'
|
||||
| 'editorInfo.foreground'
|
||||
| 'editorInfo.background'
|
||||
| 'testing.iconPassed'
|
||||
| 'badge.background'
|
||||
| 'badge.foreground'
|
||||
| 'statusBar.background'
|
||||
| 'statusBar.foreground'
|
||||
| 'list.hoverBackground'
|
||||
| 'list.activeSelectionBackground'
|
||||
| 'textPreformat.foreground'
|
||||
| 'textPreformat.background';
|
||||
|
||||
export type VSCodeThemePalette = {
|
||||
kind: VSCodeThemeKind;
|
||||
colors: Partial<Record<VSCodeThemeColorToken, string>>;
|
||||
mode?: ThemeMode;
|
||||
};
|
||||
|
||||
export type VSCodeThemePayload = {
|
||||
theme: Theme;
|
||||
palette: VSCodeThemePalette;
|
||||
};
|
||||
|
||||
const VARIABLE_MAP: Record<VSCodeThemeColorToken, string> = {
|
||||
'editor.background': '--vscode-editor-background',
|
||||
'editor.foreground': '--vscode-editor-foreground',
|
||||
'editor.selectionBackground': '--vscode-editor-selectionBackground',
|
||||
'editor.selectionForeground': '--vscode-editor-selectionForeground',
|
||||
'editor.lineHighlightBackground': '--vscode-editor-lineHighlightBackground',
|
||||
'editorCursor.foreground': '--vscode-editorCursor-foreground',
|
||||
focusBorder: '--vscode-focusBorder',
|
||||
'sideBar.background': '--vscode-sideBar-background',
|
||||
'sideBar.foreground': '--vscode-sideBar-foreground',
|
||||
'panel.background': '--vscode-panel-background',
|
||||
'panel.foreground': '--vscode-panel-foreground',
|
||||
'panel.border': '--vscode-panel-border',
|
||||
'input.background': '--vscode-input-background',
|
||||
'input.foreground': '--vscode-input-foreground',
|
||||
'input.border': '--vscode-input-border',
|
||||
'button.background': '--vscode-button-background',
|
||||
'button.foreground': '--vscode-button-foreground',
|
||||
'button.hoverBackground': '--vscode-button-hoverBackground',
|
||||
'textLink.foreground': '--vscode-textLink-foreground',
|
||||
descriptionForeground: '--vscode-descriptionForeground',
|
||||
'terminal.ansiRed': '--vscode-terminal-ansiRed',
|
||||
'terminal.ansiGreen': '--vscode-terminal-ansiGreen',
|
||||
'terminal.ansiBlue': '--vscode-terminal-ansiBlue',
|
||||
'terminal.ansiYellow': '--vscode-terminal-ansiYellow',
|
||||
'terminal.ansiCyan': '--vscode-terminal-ansiCyan',
|
||||
'editorError.foreground': '--vscode-editorError-foreground',
|
||||
'editorError.background': '--vscode-editorError-background',
|
||||
'editorWarning.foreground': '--vscode-editorWarning-foreground',
|
||||
'editorWarning.background': '--vscode-editorWarning-background',
|
||||
'editorInfo.foreground': '--vscode-editorInfo-foreground',
|
||||
'editorInfo.background': '--vscode-editorInfo-background',
|
||||
'testing.iconPassed': '--vscode-testing-iconPassed',
|
||||
'badge.background': '--vscode-badge-background',
|
||||
'badge.foreground': '--vscode-badge-foreground',
|
||||
'statusBar.background': '--vscode-statusBar-background',
|
||||
'statusBar.foreground': '--vscode-statusBar-foreground',
|
||||
'list.hoverBackground': '--vscode-list-hoverBackground',
|
||||
'list.activeSelectionBackground': '--vscode-list-activeSelectionBackground',
|
||||
'textPreformat.foreground': '--vscode-textPreformat-foreground',
|
||||
'textPreformat.background': '--vscode-textPreformat-background',
|
||||
};
|
||||
|
||||
const normalizeColor = (value?: string | null): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const readKind = (preferred?: VSCodeThemeKind): VSCodeThemeKind => {
|
||||
if (preferred === 'light' || preferred === 'dark' || preferred === 'high-contrast') {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const prefersLight = typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
return prefersLight ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
export const readVSCodeThemePalette = (
|
||||
preferredKind?: VSCodeThemeKind,
|
||||
preferredMode?: ThemeMode,
|
||||
): VSCodeThemePalette | null => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const colors: Partial<Record<VSCodeThemeColorToken, string>> = {};
|
||||
|
||||
(Object.keys(VARIABLE_MAP) as VSCodeThemeColorToken[]).forEach((token) => {
|
||||
const cssVar = VARIABLE_MAP[token];
|
||||
const value = normalizeColor(styles.getPropertyValue(cssVar));
|
||||
if (value) {
|
||||
colors[token] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
kind: readKind(preferredKind),
|
||||
colors,
|
||||
mode: preferredMode,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme => {
|
||||
const base = palette.kind === 'light' ? flexokiLightTheme : flexokiDarkTheme;
|
||||
const read = (token: VSCodeThemeColorToken, fallback: string): string =>
|
||||
palette.colors[token] ?? fallback;
|
||||
|
||||
const sidebarBg = read('sideBar.background', base.colors.surface.background);
|
||||
const sidebarFg = read('sideBar.foreground', read('descriptionForeground', base.colors.surface.mutedForeground));
|
||||
const panelBg = read('panel.background', read('editor.background', base.colors.surface.elevated));
|
||||
const panelFg = read('panel.foreground', read('editor.foreground', base.colors.surface.foreground));
|
||||
const background = sidebarBg;
|
||||
const foreground = read('editor.foreground', base.colors.surface.foreground);
|
||||
const accent = read('textLink.foreground', read('button.background', base.colors.primary.base));
|
||||
const accentFg = read('button.foreground', base.colors.primary.foreground || base.colors.surface.background);
|
||||
const hoverBg = read('list.hoverBackground', read('editor.selectionBackground', base.colors.interactive.hover));
|
||||
const activeBg = read('list.activeSelectionBackground', hoverBg);
|
||||
const selection = read('editor.selectionBackground', activeBg);
|
||||
const selectionFg = read('editor.selectionForeground', foreground);
|
||||
const focus = read('focusBorder', selection);
|
||||
const border = read('input.border', read('panel.border', base.colors.interactive.border));
|
||||
const cursor = read('editorCursor.foreground', base.colors.interactive.cursor);
|
||||
const badgeBg = read('badge.background', accent);
|
||||
const badgeFg = read('badge.foreground', foreground);
|
||||
|
||||
const inlineCode = read('textPreformat.foreground', read('terminal.ansiGreen', base.colors.syntax.base.string));
|
||||
// Tailwind's `--accent` drives hovered/selected menu items in Radix/shadcn; prefer VS Code list hover/selection.
|
||||
const subtle = hoverBg;
|
||||
|
||||
return {
|
||||
...base,
|
||||
metadata: {
|
||||
...base.metadata,
|
||||
id: 'vscode-auto',
|
||||
name: 'VS Code Theme',
|
||||
description: 'Mirrors your current VS Code color theme',
|
||||
author: 'VS Code',
|
||||
version: '1.0.0',
|
||||
variant: palette.kind === 'light' ? 'light' : 'dark',
|
||||
tags: ['vscode', 'auto'],
|
||||
},
|
||||
colors: {
|
||||
...base.colors,
|
||||
primary: {
|
||||
base: accent,
|
||||
hover: read('button.hoverBackground', accent),
|
||||
active: read('button.hoverBackground', accent),
|
||||
foreground: accentFg,
|
||||
muted: read('textLink.foreground', accent),
|
||||
emphasis: accent,
|
||||
},
|
||||
surface: {
|
||||
...base.colors.surface,
|
||||
background,
|
||||
foreground,
|
||||
muted: panelBg,
|
||||
mutedForeground: sidebarFg,
|
||||
elevated: panelBg,
|
||||
elevatedForeground: panelFg,
|
||||
overlay: read('statusBar.background', base.colors.surface.overlay),
|
||||
subtle,
|
||||
},
|
||||
interactive: {
|
||||
...base.colors.interactive,
|
||||
border,
|
||||
borderHover: border,
|
||||
borderFocus: focus,
|
||||
selection,
|
||||
selectionForeground: selectionFg,
|
||||
focus,
|
||||
focusRing: focus,
|
||||
cursor,
|
||||
hover: hoverBg,
|
||||
active: activeBg,
|
||||
},
|
||||
status: {
|
||||
...base.colors.status,
|
||||
error: read('editorError.foreground', base.colors.status.error),
|
||||
errorForeground: read('editorError.foreground', base.colors.status.errorForeground),
|
||||
errorBackground: read('editorError.background', base.colors.status.errorBackground),
|
||||
errorBorder: read('editorError.foreground', base.colors.status.errorBorder),
|
||||
warning: read('editorWarning.foreground', base.colors.status.warning),
|
||||
warningForeground: read('editorWarning.foreground', base.colors.status.warningForeground),
|
||||
warningBackground: read('editorWarning.background', base.colors.status.warningBackground),
|
||||
warningBorder: read('editorWarning.foreground', base.colors.status.warningBorder),
|
||||
success: read('testing.iconPassed', base.colors.status.success),
|
||||
successForeground: read('testing.iconPassed', base.colors.status.successForeground),
|
||||
successBackground: read('testing.iconPassed', base.colors.status.successBackground),
|
||||
successBorder: read('testing.iconPassed', base.colors.status.successBorder),
|
||||
info: read('editorInfo.foreground', base.colors.status.info),
|
||||
infoForeground: read('editorInfo.foreground', base.colors.status.infoForeground),
|
||||
infoBackground: read('editorInfo.background', base.colors.status.infoBackground),
|
||||
infoBorder: read('editorInfo.foreground', base.colors.status.infoBorder),
|
||||
},
|
||||
syntax: {
|
||||
...base.colors.syntax,
|
||||
base: {
|
||||
...base.colors.syntax.base,
|
||||
background,
|
||||
foreground,
|
||||
comment: read('editor.lineHighlightBackground', base.colors.syntax.base.comment),
|
||||
keyword: accent,
|
||||
string: inlineCode,
|
||||
number: read('terminal.ansiYellow', base.colors.syntax.base.number),
|
||||
function: read('terminal.ansiBlue', base.colors.syntax.base.function),
|
||||
variable: read('terminal.ansiCyan', base.colors.syntax.base.variable),
|
||||
type: read('terminal.ansiCyan', base.colors.syntax.base.type),
|
||||
operator: accent,
|
||||
},
|
||||
},
|
||||
badges: {
|
||||
...(base.colors.badges || {}),
|
||||
default: {
|
||||
bg: badgeBg,
|
||||
fg: badgeFg,
|
||||
border: border,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,15 @@ export const SEMANTIC_TYPOGRAPHY = {
|
||||
micro: '0.875rem',
|
||||
} as const;
|
||||
|
||||
export const VSCODE_TYPOGRAPHY = {
|
||||
markdown: '0.9375rem',
|
||||
code: '0.9375rem',
|
||||
uiHeader: '1rem',
|
||||
uiLabel: '0.9375rem',
|
||||
meta: '0.9375rem',
|
||||
micro: '0.875rem',
|
||||
} as const;
|
||||
|
||||
export const SEMANTIC_TYPOGRAPHY_CSS = {
|
||||
'--text-markdown': SEMANTIC_TYPOGRAPHY.markdown,
|
||||
'--text-code': SEMANTIC_TYPOGRAPHY.code,
|
||||
@@ -247,7 +256,8 @@ export const toolDisplayStyles = {
|
||||
|
||||
getCollapsedStyles: () => ({
|
||||
...typography.tool.collapsed,
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
margin: 0,
|
||||
padding: toolDisplayStyles.padding.collapsed,
|
||||
borderRadius: 0,
|
||||
@@ -255,7 +265,8 @@ export const toolDisplayStyles = {
|
||||
|
||||
getPopupStyles: () => ({
|
||||
...typography.tool.popup,
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
margin: 0,
|
||||
padding: toolDisplayStyles.padding.popup,
|
||||
borderRadius: '0.75rem',
|
||||
@@ -263,7 +274,8 @@ export const toolDisplayStyles = {
|
||||
|
||||
getPopupContainerStyles: () => ({
|
||||
...typography.tool.popup,
|
||||
background: 'transparent !important',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
margin: 0,
|
||||
padding: toolDisplayStyles.padding.popupContainer,
|
||||
borderRadius: '0.5rem',
|
||||
|
||||
@@ -2,15 +2,29 @@ import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography';
|
||||
|
||||
let started = false;
|
||||
|
||||
const TYPOGRAPHY_STYLE_ID = 'openchamber-typography-base';
|
||||
|
||||
const applySemanticTypography = (): void => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
Object.entries(SEMANTIC_TYPOGRAPHY).forEach(([key, value]) => {
|
||||
const cssVarName = `--text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(cssVarName, value);
|
||||
});
|
||||
|
||||
const cssVars = Object.entries(SEMANTIC_TYPOGRAPHY)
|
||||
.map(([key, value]) => ` --text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value};`)
|
||||
.join('\n');
|
||||
|
||||
const styleContent = `:root {\n${cssVars}\n}\n`;
|
||||
|
||||
const existing = document.getElementById(TYPOGRAPHY_STYLE_ID);
|
||||
if (existing) {
|
||||
existing.textContent = styleContent;
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = TYPOGRAPHY_STYLE_ID;
|
||||
style.textContent = styleContent;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
export const startTypographyWatcher = (): void => {
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
GitIdentitySummary,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
const GIT_POLL_INTERVAL = 3000;
|
||||
const GIT_POLL_BASE_INTERVAL = 10000;
|
||||
const GIT_POLL_MAX_INTERVAL = 20000;
|
||||
const GIT_POLL_BACKOFF_STEP = 5000;
|
||||
const LOG_STALE_THRESHOLD = 30000;
|
||||
|
||||
interface DirectoryGitState {
|
||||
@@ -34,7 +36,8 @@ interface GitStore {
|
||||
isLoadingBranches: boolean;
|
||||
isLoadingIdentity: boolean;
|
||||
|
||||
pollIntervalId: ReturnType<typeof setInterval> | null;
|
||||
pollIntervalId: ReturnType<typeof setTimeout> | null;
|
||||
currentPollInterval: number;
|
||||
|
||||
setActiveDirectory: (directory: string | null) => void;
|
||||
getDirectoryState: (directory: string) => DirectoryGitState | null;
|
||||
@@ -139,6 +142,7 @@ export const useGitStore = create<GitStore>()(
|
||||
isLoadingBranches: false,
|
||||
isLoadingIdentity: false,
|
||||
pollIntervalId: null,
|
||||
currentPollInterval: GIT_POLL_BASE_INTERVAL,
|
||||
|
||||
setActiveDirectory: (directory) => {
|
||||
const { activeDirectory, directories } = get();
|
||||
@@ -346,24 +350,53 @@ export const useGitStore = create<GitStore>()(
|
||||
const { pollIntervalId } = get();
|
||||
if (pollIntervalId) return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
const { activeDirectory } = get();
|
||||
if (!activeDirectory) return;
|
||||
const schedulePoll = () => {
|
||||
const { currentPollInterval } = get();
|
||||
const timeoutId = setTimeout(async () => {
|
||||
// Skip if tab not visible
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
set({ pollIntervalId: schedulePoll() });
|
||||
return;
|
||||
}
|
||||
|
||||
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
|
||||
if (statusChanged) {
|
||||
await get().fetchLog(activeDirectory, git);
|
||||
}
|
||||
}, GIT_POLL_INTERVAL);
|
||||
const { activeDirectory } = get();
|
||||
if (!activeDirectory) {
|
||||
set({ pollIntervalId: schedulePoll() });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ pollIntervalId: intervalId });
|
||||
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
|
||||
if (statusChanged) {
|
||||
await get().fetchLog(activeDirectory, git);
|
||||
// Reset to base interval on changes
|
||||
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
} else {
|
||||
// Backoff when no changes
|
||||
const newInterval = Math.min(
|
||||
currentPollInterval + GIT_POLL_BACKOFF_STEP,
|
||||
GIT_POLL_MAX_INTERVAL
|
||||
);
|
||||
set({ currentPollInterval: newInterval });
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
const { pollIntervalId: currentId } = get();
|
||||
if (currentId !== null) {
|
||||
set({ pollIntervalId: schedulePoll() });
|
||||
}
|
||||
}, currentPollInterval);
|
||||
|
||||
return timeoutId;
|
||||
};
|
||||
|
||||
set({ pollIntervalId: schedulePoll(), currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
const { pollIntervalId } = get();
|
||||
if (pollIntervalId) {
|
||||
clearInterval(pollIntervalId);
|
||||
set({ pollIntervalId: null });
|
||||
clearTimeout(pollIntervalId);
|
||||
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.vscode/**
|
||||
node_modules/**
|
||||
src/**
|
||||
webview/**
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
tsconfig.webview.json
|
||||
vite.config.ts
|
||||
*.map
|
||||
**/*.ts
|
||||
!dist/**
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 OpenChamber contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,47 @@
|
||||
# OpenChamber VS Code Extension
|
||||
|
||||
AI coding assistant for VS Code powered by the OpenCode API. Embeds the OpenChamber chat interface in VS Code's secondary sidebar.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Chat UI in secondary sidebar
|
||||
- Session management with history
|
||||
- File attachments via native VS Code file picker (10MB limit)
|
||||
- Auto-start `opencode serve` if not running
|
||||
- Workspace-isolated opencode instances (different workspaces get unique opencode instances)
|
||||
- Adapts to VS Code's light/dark/high-contrast themes
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `OpenChamber: New Chat Session` | Create new chat session |
|
||||
| `OpenChamber: Focus Chat` | Focus chat panel in secondary sidebar |
|
||||
| `OpenChamber: Restart API Connection` | Restart OpenCode API process |
|
||||
| `OpenChamber: Show in Secondary Side Bar` | Toggle chat panel visibility |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL |
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenCode CLI installed and available in PATH (or set via `OPENCODE_BINARY` env var)
|
||||
- VS Code 1.85.0+
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm -C packages/vscode run build # build extension + webview
|
||||
pnpm -C packages/vscode exec vsce package --no-dependencies
|
||||
```
|
||||
|
||||
## Local Install
|
||||
|
||||
- After packaging: `code --install-extension packages/vscode/openchamber-*.vsix`
|
||||
- Or in VS Code: Extensions panel → "Install from VSIX…" and select the file
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Dark rounded background -->
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="150" ry="150" fill="#1a1616"/>
|
||||
|
||||
<!-- Glyph centered -->
|
||||
<g transform="translate(512, 512) scale(10) translate(-35, -36.5)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="#F8F7F3"/>
|
||||
<rect x="8.75" y="30" width="17.5" height="18.5" fill="#4B4646"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="#F8F7F3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 644 B |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="24" height="24" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(35, 35) scale(0.95) translate(-35, -35)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
|
||||
fill="#808080"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
|
||||
fill="#808080"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "openchamber",
|
||||
"displayName": "OpenChamber",
|
||||
"description": "AI coding assistant powered by OpenCode",
|
||||
"version": "1.0.9",
|
||||
"publisher": "fedaykindev",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/btriapitsyn/openchamber.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Programming Languages",
|
||||
"Machine Learning",
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"ai",
|
||||
"claude",
|
||||
"gpt",
|
||||
"agent",
|
||||
"coding",
|
||||
"chatgpt",
|
||||
"groq",
|
||||
"assistant",
|
||||
"opencode",
|
||||
"openchamber"
|
||||
],
|
||||
"icon": "assets/app-icon.png",
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [],
|
||||
"contributes": {
|
||||
"viewsContainers": {
|
||||
"secondarySidebar": [
|
||||
{
|
||||
"id": "openchamber",
|
||||
"title": "OpenChamber",
|
||||
"icon": "assets/icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"openchamber": [
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "openchamber.chatView",
|
||||
"name": "Chat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "openchamber.newSession",
|
||||
"title": "OpenChamber: New Chat Session"
|
||||
},
|
||||
{
|
||||
"command": "openchamber.focusChat",
|
||||
"title": "OpenChamber: Focus Chat"
|
||||
},
|
||||
{
|
||||
"command": "openchamber.restartApi",
|
||||
"title": "OpenChamber: Restart API Connection"
|
||||
},
|
||||
{
|
||||
"command": "openchamber.showInSecondarySidebar",
|
||||
"title": "OpenChamber: Show in Secondary Side Bar",
|
||||
"icon": "assets/icon.svg"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "openchamber.showInSecondarySidebar",
|
||||
"when": "editorIsOpen",
|
||||
"group": "navigation@100"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "OpenChamber",
|
||||
"properties": {
|
||||
"openchamber.apiUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:47339",
|
||||
"description": "URL of the OpenCode API server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "pnpm run build",
|
||||
"build": "pnpm run build:extension && pnpm run build:webview",
|
||||
"build:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify",
|
||||
"build:webview": "VITE_OPENCODE_URL=/api vite build",
|
||||
"dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"pnpm run watch:extension\" \"pnpm run watch:webview\"",
|
||||
"watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap",
|
||||
"watch:webview": "vite build --watch",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
|
||||
"lint": "pnpm dlx eslint --ext .ts,.tsx src webview",
|
||||
"package": "vsce package --no-dependencies"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"esbuild": "^0.24.2",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2",
|
||||
"@vscode/vsce": "^3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.0.133",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { handleBridgeMessage, type BridgeRequest } from './bridge';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
|
||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'openchamber.chatView';
|
||||
|
||||
private _view?: vscode.WebviewView;
|
||||
private _isVisible = false;
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _openCodeManager?: OpenCodeManager
|
||||
) {}
|
||||
|
||||
public resolveWebviewView(
|
||||
webviewView: vscode.WebviewView
|
||||
) {
|
||||
this._view = webviewView;
|
||||
this._isVisible = webviewView.visible;
|
||||
|
||||
webviewView.onDidChangeVisibility(() => {
|
||||
this._isVisible = webviewView.visible;
|
||||
});
|
||||
|
||||
const distUri = vscode.Uri.joinPath(this._extensionUri, 'dist');
|
||||
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [this._extensionUri, distUri],
|
||||
};
|
||||
|
||||
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
|
||||
|
||||
webviewView.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
||||
if (message.type === 'restartApi') {
|
||||
await this._openCodeManager?.restart();
|
||||
return;
|
||||
}
|
||||
const response = await handleBridgeMessage(message, {
|
||||
manager: this._openCodeManager,
|
||||
context: this._context,
|
||||
});
|
||||
webviewView.webview.postMessage(response);
|
||||
});
|
||||
}
|
||||
|
||||
public newSession() {
|
||||
if (this._view) {
|
||||
this._view.webview.postMessage({ type: 'command', command: 'newSession' });
|
||||
}
|
||||
}
|
||||
|
||||
public updateTheme(kind: vscode.ColorThemeKind) {
|
||||
if (this._view) {
|
||||
const themeKind = getThemeKindName(kind);
|
||||
this._view.webview.postMessage({
|
||||
type: 'themeChange',
|
||||
theme: { kind: themeKind },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public updateConnectionStatus(status: ConnectionStatus, error?: string) {
|
||||
if (this._view) {
|
||||
this._view.webview.postMessage({
|
||||
type: 'connectionStatus',
|
||||
status,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public isVisible(): boolean {
|
||||
return this._isVisible;
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(webview: vscode.Webview) {
|
||||
const scriptPath = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||
const scriptUri = webview.asWebviewUri(scriptPath);
|
||||
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const apiUrl = this._openCodeManager?.getApiUrl() || config.get<string>('apiUrl') || 'http://localhost:47339';
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||
const initialStatus = this._openCodeManager?.getStatus() || 'disconnected';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
|
||||
<style>
|
||||
html, body, #root { height: 100%; width: 100%; }
|
||||
body { margin: 0; padding: 0; overflow: hidden; background: transparent; }
|
||||
</style>
|
||||
<title>OpenChamber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Polyfill process for Node.js modules running in browser
|
||||
window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
|
||||
|
||||
window.__VSCODE_CONFIG__ = {
|
||||
apiUrl: "${apiUrl}",
|
||||
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
||||
theme: "${themeKind}",
|
||||
connectionStatus: "${initialStatus}"
|
||||
};
|
||||
window.__OPENCHAMBER_HOME__ = "${workspaceFolder.replace(/\\/g, '\\\\')}";
|
||||
</script>
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface BridgeResponse {
|
||||
id: string;
|
||||
type: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
interface FileSearchResult {
|
||||
path: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface BridgeContext {
|
||||
manager?: OpenCodeManager;
|
||||
context?: vscode.ExtensionContext;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'openchamber.settings';
|
||||
|
||||
const readSettings = (ctx?: BridgeContext) => {
|
||||
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
||||
const restStored = { ...stored };
|
||||
delete (restStored as Record<string, unknown>).lastDirectory;
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
const themeVariant =
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight
|
||||
? 'light'
|
||||
: 'dark';
|
||||
|
||||
return {
|
||||
themeVariant,
|
||||
lastDirectory: workspaceFolder,
|
||||
...restStored,
|
||||
};
|
||||
};
|
||||
|
||||
const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext) => {
|
||||
const current = readSettings(ctx);
|
||||
const restChanges = { ...(changes || {}) };
|
||||
delete restChanges.lastDirectory;
|
||||
const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory };
|
||||
await ctx?.context?.globalState.update(SETTINGS_KEY, merged);
|
||||
return merged;
|
||||
};
|
||||
|
||||
const normalizeFsPath = (value: string) => value.replace(/\\/g, '/');
|
||||
|
||||
const listDirectoryEntries = async (dirPath: string) => {
|
||||
const uri = vscode.Uri.file(dirPath);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
return entries.map(([name, fileType]) => ({
|
||||
name,
|
||||
path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath),
|
||||
isDirectory: fileType === vscode.FileType.Directory,
|
||||
}));
|
||||
};
|
||||
|
||||
const searchDirectory = async (directory: string, query: string, limit = 60) => {
|
||||
const rootPath = directory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
if (!rootPath) return [];
|
||||
|
||||
const sanitizedQuery = query?.trim() || '';
|
||||
const pattern = sanitizedQuery ? `**/*${sanitizedQuery}*` : '**/*';
|
||||
const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**';
|
||||
const results = await vscode.workspace.findFiles(
|
||||
new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern),
|
||||
exclude,
|
||||
limit,
|
||||
);
|
||||
|
||||
return results.map((file) => {
|
||||
const absolute = normalizeFsPath(file.fsPath);
|
||||
const relative = normalizeFsPath(path.relative(rootPath, absolute));
|
||||
const name = path.basename(absolute);
|
||||
return {
|
||||
name,
|
||||
path: absolute,
|
||||
relativePath: relative || name,
|
||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchModelsMetadata = async () => {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
|
||||
try {
|
||||
const response = await fetch('https://models.dev/api.json', {
|
||||
signal: controller?.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`models.dev responded with ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'files:list': {
|
||||
const { path: dirPath } = payload as { path: string };
|
||||
const uri = vscode.Uri.file(dirPath);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
const result: FileEntry[] = entries.map(([name, fileType]) => ({
|
||||
name,
|
||||
path: vscode.Uri.joinPath(uri, name).fsPath,
|
||||
isDirectory: fileType === vscode.FileType.Directory,
|
||||
}));
|
||||
return { id, type, success: true, data: { directory: dirPath, entries: result } };
|
||||
}
|
||||
|
||||
case 'files:search': {
|
||||
const { query, maxResults = 50 } = payload as { query: string; maxResults?: number };
|
||||
const pattern = `**/*${query}*`;
|
||||
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults);
|
||||
const results: FileSearchResult[] = files.map((file) => ({
|
||||
path: file.fsPath,
|
||||
}));
|
||||
return { id, type, success: true, data: results };
|
||||
}
|
||||
|
||||
case 'workspace:folder': {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
return { id, type, success: true, data: { folder } };
|
||||
}
|
||||
|
||||
case 'config:get': {
|
||||
const { key } = payload as { key: string };
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const value = config.get(key);
|
||||
return { id, type, success: true, data: { value } };
|
||||
}
|
||||
|
||||
case 'api:fs:list': {
|
||||
const target = (payload as { path?: string })?.path || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const entries = await listDirectoryEntries(target);
|
||||
return { id, type, success: true, data: { entries, directory: target } };
|
||||
}
|
||||
|
||||
case 'api:fs:search': {
|
||||
const { directory = '', query = '', limit } = (payload || {}) as { directory?: string; query?: string; limit?: number };
|
||||
const files = await searchDirectory(directory, query, limit);
|
||||
return { id, type, success: true, data: { files } };
|
||||
}
|
||||
|
||||
case 'api:fs:mkdir': {
|
||||
const target = (payload as { path: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
await vscode.workspace.fs.createDirectory(vscode.Uri.file(target));
|
||||
return { id, type, success: true, data: { success: true, path: normalizeFsPath(target) } };
|
||||
}
|
||||
|
||||
case 'api:fs/home': {
|
||||
const workspaceHome = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const home = workspaceHome || os.homedir();
|
||||
return { id, type, success: true, data: { home: normalizeFsPath(home) } };
|
||||
}
|
||||
|
||||
case 'api:files/pick': {
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
||||
const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri;
|
||||
|
||||
const picks = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
canSelectMany: allowMany,
|
||||
defaultUri,
|
||||
openLabel: 'Attach',
|
||||
});
|
||||
|
||||
if (!picks || picks.length === 0) {
|
||||
return { id, type, success: true, data: { files: [], skipped: [] } };
|
||||
}
|
||||
|
||||
const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = [];
|
||||
const skipped: Array<{ name: string; reason: string }> = [];
|
||||
|
||||
const guessMime = (ext: string) => {
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
case '.gif':
|
||||
case '.bmp':
|
||||
case '.webp':
|
||||
return `image/${ext.replace('.', '')}`;
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
case '.txt':
|
||||
case '.log':
|
||||
return 'text/plain';
|
||||
case '.json':
|
||||
return 'application/json';
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'text/markdown';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
};
|
||||
|
||||
for (const uri of picks) {
|
||||
try {
|
||||
const stat = await vscode.workspace.fs.stat(uri);
|
||||
const size = stat.size ?? 0;
|
||||
const name = path.basename(uri.fsPath);
|
||||
|
||||
if (size > MAX_SIZE) {
|
||||
skipped.push({ name, reason: 'File exceeds 10MB limit' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const mimeType = guessMime(ext);
|
||||
const base64 = Buffer.from(bytes).toString('base64');
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
files.push({ name, mimeType, size, dataUrl });
|
||||
} catch (error) {
|
||||
const name = path.basename(uri.fsPath);
|
||||
skipped.push({ name, reason: error instanceof Error ? error.message : 'Failed to read file' });
|
||||
}
|
||||
}
|
||||
|
||||
return { id, type, success: true, data: { files, skipped } };
|
||||
}
|
||||
|
||||
case 'api:config/settings:get': {
|
||||
const settings = readSettings(ctx);
|
||||
return { id, type, success: true, data: settings };
|
||||
}
|
||||
|
||||
case 'api:config/settings:save': {
|
||||
const changes = (payload as Record<string, unknown>) || {};
|
||||
const updated = await persistSettings(changes, ctx);
|
||||
return { id, type, success: true, data: updated };
|
||||
}
|
||||
|
||||
case 'api:config/reload': {
|
||||
await ctx?.manager?.restart();
|
||||
return { id, type, success: true, data: { restarted: true } };
|
||||
}
|
||||
|
||||
case 'api:opencode/directory': {
|
||||
const target = (payload as { path?: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
const result = await ctx?.manager?.setWorkingDirectory(target);
|
||||
if (!result) {
|
||||
return { id, type, success: false, error: 'OpenCode manager unavailable' };
|
||||
}
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
case 'api:models/metadata': {
|
||||
try {
|
||||
const data = await fetchModelsMetadata();
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'editor:openFile': {
|
||||
const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number };
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
||||
const options: vscode.TextDocumentShowOptions = {};
|
||||
if (typeof line === 'number') {
|
||||
const pos = new vscode.Position(Math.max(0, line - 1), column || 0);
|
||||
options.selection = new vscode.Range(pos, pos);
|
||||
}
|
||||
await vscode.window.showTextDocument(doc, options);
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'editor:openDiff': {
|
||||
const { original, modified, label } = payload as { original: string; modified: string; label?: string };
|
||||
try {
|
||||
// If the paths are just content, we need to create virtual documents or temp files.
|
||||
// However, 'editor:openDiff' usually implies comparing two URIs.
|
||||
// If the payload contains file paths:
|
||||
const originalUri = vscode.Uri.file(original);
|
||||
const modifiedUri = vscode.Uri.file(modified);
|
||||
const title = label || `${path.basename(original)} ↔ ${path.basename(modified)}`;
|
||||
|
||||
await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
|
||||
return { id, type, success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { id, type, success: false, error: `Unknown message type: ${type}` };
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ChatViewProvider } from './ChatViewProvider';
|
||||
import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
||||
|
||||
let chatViewProvider: ChatViewProvider | undefined;
|
||||
let openCodeManager: OpenCodeManager | undefined;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
// Create OpenCode manager first
|
||||
openCodeManager = createOpenCodeManager(context);
|
||||
|
||||
// Create chat view provider with manager reference
|
||||
chatViewProvider = new ChatViewProvider(context, context.extensionUri, openCodeManager);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(
|
||||
ChatViewProvider.viewType,
|
||||
chatViewProvider,
|
||||
{ webviewOptions: { retainContextWhenHidden: true } }
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.newSession', () => {
|
||||
chatViewProvider?.newSession();
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.focusChat', () => {
|
||||
vscode.commands.executeCommand('openchamber.chatView.focus');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.restartApi', async () => {
|
||||
await openCodeManager?.restart();
|
||||
})
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.showInSecondarySidebar', async () => {
|
||||
const viewId = ChatViewProvider.viewType;
|
||||
const isVisible = chatViewProvider?.isVisible() === true;
|
||||
|
||||
if (isVisible) {
|
||||
await vscode.commands.executeCommand('workbench.action.toggleAuxiliaryBar');
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.commands.executeCommand('workbench.action.focusAuxiliaryBar');
|
||||
await vscode.commands.executeCommand(`${viewId}.focus`);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
||||
chatViewProvider?.updateTheme(theme.kind);
|
||||
})
|
||||
);
|
||||
|
||||
// Subscribe to status changes
|
||||
context.subscriptions.push(
|
||||
openCodeManager.onStatusChange((status, error) => {
|
||||
chatViewProvider?.updateConnectionStatus(status, error);
|
||||
})
|
||||
);
|
||||
|
||||
// Auto-start OpenCode API
|
||||
openCodeManager.start();
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
openCodeManager?.stop();
|
||||
openCodeManager = undefined;
|
||||
chatViewProvider = undefined;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as net from 'net';
|
||||
|
||||
const DEFAULT_PORT = 47339;
|
||||
const HEALTH_CHECK_INTERVAL = 5000;
|
||||
const STARTUP_TIMEOUT = 10000;
|
||||
const SHUTDOWN_TIMEOUT = 3000;
|
||||
|
||||
const BIN_CANDIDATES = [
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
process.env.OPENCODE_PATH,
|
||||
process.env.OPENCODE_BINARY,
|
||||
'/opt/homebrew/bin/opencode',
|
||||
'/usr/local/bin/opencode',
|
||||
'/usr/bin/opencode',
|
||||
path.join(os.homedir(), '.local/bin/opencode'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export interface OpenCodeManager {
|
||||
start(workdir?: string): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
||||
getStatus(): ConnectionStatus;
|
||||
getApiUrl(): string;
|
||||
getWorkingDirectory(): string;
|
||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
||||
}
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCliPath(): string | null {
|
||||
for (const candidate of BIN_CANDIDATES) {
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = process.env.PATH || '';
|
||||
for (const segment of envPath.split(path.delimiter)) {
|
||||
const candidate = path.join(segment, 'opencode');
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const shellCandidates = [
|
||||
process.env.SHELL,
|
||||
'/bin/bash',
|
||||
'/bin/zsh',
|
||||
'/bin/sh',
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const shellPath of shellCandidates) {
|
||||
if (!isExecutable(shellPath)) continue;
|
||||
try {
|
||||
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const candidate = result.stdout.trim().split(/\s+/).pop();
|
||||
if (candidate && isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function checkHealth(apiUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const candidates = [`${apiUrl}/health`, `${apiUrl}/api/health`];
|
||||
|
||||
for (const target of candidates) {
|
||||
try {
|
||||
const response = await fetch(target, { signal: controller.signal });
|
||||
if (response.ok) {
|
||||
clearTimeout(timeout);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hashWorkspaceIdentifier(identifier: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < identifier.length; i++) {
|
||||
hash = (hash * 31 + identifier.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number, maxAttempts = 20): Promise<number> {
|
||||
let port = startPort;
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const available = await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
server.close();
|
||||
resolve(false);
|
||||
});
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
if (available) {
|
||||
return port;
|
||||
}
|
||||
port += 1;
|
||||
}
|
||||
return startPort;
|
||||
}
|
||||
|
||||
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
|
||||
let childProcess: ChildProcess | null = null;
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
let healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
let lastError: string | undefined;
|
||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const workspaceKey = `openchamber.api.port.${hashWorkspaceIdentifier(workspaceFolder)}`;
|
||||
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
||||
|
||||
const storedPort = context.workspaceState.get<number>(workspaceKey);
|
||||
let apiUrl: string = storedPort && Number.isFinite(storedPort)
|
||||
? `http://localhost:${storedPort}`
|
||||
: `http://localhost:${DEFAULT_PORT}`;
|
||||
let desiredPort: number = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT;
|
||||
|
||||
const parseApiUrl = (candidate: string): { url: string; port: number } | null => {
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
const origin = parsed.origin;
|
||||
const pathname = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/+$/, '') : '';
|
||||
const normalized = `${origin}${pathname}`;
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : DEFAULT_PORT;
|
||||
return {
|
||||
url: normalized,
|
||||
port: Number.isFinite(port) && port > 0 ? port : DEFAULT_PORT,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveApi = async () => {
|
||||
// If user explicitly set a non-default URL, honor it (shared across workspaces).
|
||||
const parsed = configuredApiUrl ? parseApiUrl(configuredApiUrl) : null;
|
||||
const isDefault = !parsed || parsed.port === DEFAULT_PORT;
|
||||
|
||||
if (!isDefault && parsed) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Workspace-isolated port selection
|
||||
const storedPort = context.workspaceState.get<number>(workspaceKey);
|
||||
const basePort = storedPort && Number.isFinite(storedPort) ? storedPort : DEFAULT_PORT + (hashWorkspaceIdentifier(workspaceFolder) % 1000);
|
||||
const port = await findAvailablePort(basePort);
|
||||
void context.workspaceState.update(workspaceKey, port);
|
||||
return { url: `http://localhost:${port}`, port };
|
||||
};
|
||||
|
||||
const apiConfigPromise = resolveApi().then((result) => {
|
||||
apiUrl = result.url;
|
||||
desiredPort = result.port;
|
||||
return result;
|
||||
}).catch(() => null);
|
||||
|
||||
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
||||
if (status !== newStatus || lastError !== error) {
|
||||
status = newStatus;
|
||||
lastError = error;
|
||||
listeners.forEach(cb => cb(status, error));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealthy(timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await checkHealth(apiUrl)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startHealthCheck() {
|
||||
stopHealthCheck();
|
||||
healthCheckInterval = setInterval(async () => {
|
||||
const healthy = await checkHealth(apiUrl);
|
||||
if (healthy && status !== 'connected') {
|
||||
setStatus('connected');
|
||||
} else if (!healthy && status === 'connected') {
|
||||
setStatus('disconnected');
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
function stopHealthCheck() {
|
||||
if (healthCheckInterval) {
|
||||
clearInterval(healthCheckInterval);
|
||||
healthCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(workdir?: string) {
|
||||
await apiConfigPromise;
|
||||
|
||||
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
||||
workingDirectory = workdir.trim();
|
||||
}
|
||||
|
||||
// First check if API is already running
|
||||
if (await checkHealth(apiUrl)) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('connecting');
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
if (!cliPath) {
|
||||
setStatus('error', 'OpenCode CLI not found. Install it or set OPENCODE_BINARY env var.');
|
||||
vscode.window.showErrorMessage(
|
||||
'OpenCode CLI not found. Please install it or set the OPENCODE_BINARY environment variable.',
|
||||
'More Info'
|
||||
).then(selection => {
|
||||
if (selection === 'More Info') {
|
||||
vscode.env.openExternal(vscode.Uri.parse('https://github.com/opencode-ai/opencode'));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
try {
|
||||
childProcess = spawn(cliPath, ['serve', '--port', desiredPort.toString()], {
|
||||
cwd: spawnCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_PORT: desiredPort.toString(),
|
||||
},
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
console.log('[OpenCode]', data.toString());
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
console.error('[OpenCode]', data.toString());
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
setStatus('error', `Failed to start OpenCode: ${err.message}`);
|
||||
childProcess = null;
|
||||
});
|
||||
|
||||
childProcess.on('exit', () => {
|
||||
if (status !== 'disconnected') {
|
||||
setStatus('disconnected');
|
||||
}
|
||||
childProcess = null;
|
||||
});
|
||||
|
||||
// Wait for API to become healthy
|
||||
const healthy = await waitForHealthy(STARTUP_TIMEOUT);
|
||||
if (healthy) {
|
||||
setStatus('connected');
|
||||
startHealthCheck();
|
||||
} else {
|
||||
setStatus('error', 'OpenCode API did not start in time');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setStatus('error', `Failed to start OpenCode: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
stopHealthCheck();
|
||||
|
||||
if (childProcess) {
|
||||
try {
|
||||
childProcess.kill('SIGTERM');
|
||||
// Wait a bit for graceful shutdown
|
||||
await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT));
|
||||
if (childProcess && !childProcess.killed && childProcess.exitCode === null) {
|
||||
childProcess.kill('SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
childProcess = null;
|
||||
}
|
||||
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
await stop();
|
||||
await start();
|
||||
}
|
||||
|
||||
async function setWorkingDirectory(path: string) {
|
||||
const target = typeof path === 'string' && path.trim().length > 0 ? path.trim() : workingDirectory;
|
||||
workingDirectory = target;
|
||||
await restart();
|
||||
return { success: true, restarted: true, path: target };
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setWorkingDirectory,
|
||||
getStatus: () => status,
|
||||
getApiUrl: () => apiUrl,
|
||||
getWorkingDirectory: () => workingDirectory,
|
||||
onStatusChange(callback) {
|
||||
listeners.add(callback);
|
||||
// Immediately call with current status
|
||||
callback(status, lastError);
|
||||
return new vscode.Disposable(() => listeners.delete(callback));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export type ThemeKindName = 'light' | 'dark';
|
||||
|
||||
export function getThemeKindName(kind: vscode.ColorThemeKind): ThemeKindName {
|
||||
switch (kind) {
|
||||
case vscode.ColorThemeKind.Light:
|
||||
case vscode.ColorThemeKind.HighContrastLight:
|
||||
return 'light';
|
||||
case vscode.ColorThemeKind.Dark:
|
||||
case vscode.ColorThemeKind.HighContrast:
|
||||
default:
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "webview"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["../ui/src/*"],
|
||||
"@vscode/*": ["./webview/*"],
|
||||
"@openchamber/ui/*": ["../ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["webview/**/*", "../ui/src/**/*"],
|
||||
"exclude": [
|
||||
"webview/components/**/*",
|
||||
"webview/stores/**/*",
|
||||
"webview/hooks/**/*",
|
||||
"webview/App.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: path.resolve(__dirname, 'webview'),
|
||||
base: './', // Use relative paths for VS Code webview
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '../ui/src'),
|
||||
'@vscode': path.resolve(__dirname, './webview'),
|
||||
'@openchamber/ui': path.resolve(__dirname, '../ui/src'),
|
||||
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
'global': 'globalThis',
|
||||
},
|
||||
envPrefix: ['VITE_'],
|
||||
optimizeDeps: {
|
||||
include: ['@opencode-ai/sdk'],
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'dist/webview'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, 'webview/index.html'),
|
||||
external: ['node:child_process', 'node:fs', 'node:path', 'node:url'],
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
chunkFileNames: 'assets/[name].js',
|
||||
assetFileNames: 'assets/[name].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import React from 'react';
|
||||
import { useChatStore } from './stores/chatStore';
|
||||
import { useNavigation } from './hooks/useNavigation';
|
||||
|
||||
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
function ConnectionStatusBanner({ status, error, onRetry }: {
|
||||
status: ConnectionStatus;
|
||||
error?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (status === 'connected') return null;
|
||||
|
||||
const messages: Record<ConnectionStatus, string> = {
|
||||
disconnected: 'Not connected to OpenCode API',
|
||||
connecting: 'Connecting...',
|
||||
connected: '',
|
||||
error: error || 'Connection error',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 px-4 py-2 text-sm border-b ${
|
||||
status === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{status === 'connecting' && (
|
||||
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{messages[status]}</span>
|
||||
{(status === 'disconnected' || status === 'error') && (
|
||||
<button onClick={onRetry} className="px-2 py-0.5 text-xs rounded bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsList() {
|
||||
const { sessions, currentSessionId, selectSession, createSession, isLoadingSessions } = useChatStore();
|
||||
const { goToChat } = useNavigation();
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
|
||||
const handleSelectSession = async (sessionId: string) => {
|
||||
await selectSession(sessionId);
|
||||
goToChat();
|
||||
};
|
||||
|
||||
const handleNewSession = async () => {
|
||||
setIsCreating(true);
|
||||
const sessionId = await createSession();
|
||||
setIsCreating(false);
|
||||
if (sessionId) {
|
||||
goToChat();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp?: number) => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000);
|
||||
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<h1 className="text-sm font-medium">Sessions</h1>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="p-1.5 rounded hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? (
|
||||
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoadingSessions ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">Loading...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm mb-4">No sessions yet</div>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Start New Chat'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={`w-full text-left px-3 py-2.5 hover:bg-muted/50 transition-colors ${
|
||||
session.id === currentSessionId ? 'bg-primary/10 border-l-2 border-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{session.title || 'New Session'}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatTime(session.time?.created)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPanel() {
|
||||
const { currentSessionId, sessions, messages, sendMessage, abortMessage, isSending, streamingSessionId } = useChatStore();
|
||||
const { goToSessions } = useNavigation();
|
||||
const [input, setInput] = React.useState('');
|
||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : [];
|
||||
const isStreaming = streamingSessionId === currentSessionId;
|
||||
|
||||
React.useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [sessionMessages.length]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isSending) return;
|
||||
const text = input.trim();
|
||||
setInput('');
|
||||
await sendMessage(text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<button onClick={goToSessions} className="p-1 rounded hover:bg-muted">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M11 2L5 8l6 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<h1 className="text-sm font-medium truncate flex-1">{currentSession?.title || 'New Chat'}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
{sessionMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Start a conversation
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessionMessages.map((msg, idx) => (
|
||||
<MessageBubble key={msg.info.id || idx} message={msg} />
|
||||
))}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-muted rounded-lg px-3 py-2 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
disabled={isSending}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
style={{ minHeight: 40, maxHeight: 120 }}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button onClick={abortMessage} className="px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="currentColor"/></svg>
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSend} disabled={!input.trim() || isSending} className="px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l12-6-3.5 6 3.5 6L2 8z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: { info: { role: string }; parts: Array<{ type: string; text?: string }> } }) {
|
||||
const isUser = message.info.role === 'user';
|
||||
const text = message.parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
isUser ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||
}`}>
|
||||
<div className="whitespace-pre-wrap break-words">{text || '...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VSCodeApp() {
|
||||
const { initialize, isConnected } = useChatStore();
|
||||
const { currentView } = useNavigation();
|
||||
const [status, setStatus] = React.useState<ConnectionStatus>('connecting');
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
const connect = React.useCallback(async () => {
|
||||
setStatus('connecting');
|
||||
setError(undefined);
|
||||
try {
|
||||
await initialize();
|
||||
setStatus('connected');
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [initialize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isConnected) setStatus('connected');
|
||||
}, [isConnected]);
|
||||
|
||||
// Listen for extension messages
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'connectionStatus') {
|
||||
if (msg.status === 'connected') setStatus('connected');
|
||||
else if (msg.status === 'error') {
|
||||
setStatus('error');
|
||||
setError(msg.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background text-foreground">
|
||||
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
|
||||
<div className="flex-1 min-h-0">
|
||||
{currentView === 'sessions' ? <SessionsList /> : <ChatPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VSCodeApp;
|
||||
@@ -0,0 +1,114 @@
|
||||
declare const acquireVsCodeApi: () => {
|
||||
postMessage: (message: unknown) => void;
|
||||
getState: () => unknown;
|
||||
setState: (state: unknown) => void;
|
||||
};
|
||||
|
||||
interface VSCodeAPI {
|
||||
postMessage: (message: unknown) => void;
|
||||
}
|
||||
|
||||
let vscodeApi: VSCodeAPI | null = null;
|
||||
|
||||
function getVSCodeAPI(): VSCodeAPI {
|
||||
if (!vscodeApi) {
|
||||
vscodeApi = acquireVsCodeApi();
|
||||
}
|
||||
return vscodeApi;
|
||||
}
|
||||
|
||||
// Export vscode API for direct use
|
||||
export const vscode = {
|
||||
postMessage: (message: unknown) => getVSCodeAPI().postMessage(message),
|
||||
};
|
||||
|
||||
interface BridgeRequest {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface BridgeResponse {
|
||||
id: string;
|
||||
type: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}>();
|
||||
|
||||
let requestIdCounter = 0;
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
|
||||
const response = event.data;
|
||||
if (!response || typeof response.id !== 'string') return;
|
||||
|
||||
const pending = pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
pendingRequests.delete(response.id);
|
||||
if (response.success) {
|
||||
pending.resolve(response.data);
|
||||
} else {
|
||||
pending.reject(new Error(response.error || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++requestIdCounter}_${Date.now()}`;
|
||||
const request: BridgeRequest = { id, type, payload };
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${type} timed out`));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
getVSCodeAPI().postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
type CommandHandler = (payload: unknown) => void;
|
||||
const commandHandlers = new Map<string, CommandHandler>();
|
||||
|
||||
export function onCommand(command: string, handler: CommandHandler): () => void {
|
||||
commandHandlers.set(command, handler);
|
||||
return () => commandHandlers.delete(command);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'command' && message.command) {
|
||||
const handler = commandHandlers.get(message.command);
|
||||
if (handler) {
|
||||
handler(message.payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type ThemeChangePayload = 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast' };
|
||||
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
|
||||
let themeChangeHandler: ThemeChangeHandler | null = null;
|
||||
|
||||
export function onThemeChange(handler: ThemeChangeHandler): () => void {
|
||||
themeChangeHandler = handler;
|
||||
return () => { themeChangeHandler = null; };
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'themeChange' && themeChangeHandler) {
|
||||
themeChangeHandler(message.theme);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
import { sendBridgeMessage } from './bridge';
|
||||
import type { EditorAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodeEditorAPI = (): EditorAPI => ({
|
||||
openFile: async (path: string, line?: number, column?: number) => {
|
||||
await sendBridgeMessage('editor:openFile', { path, line, column });
|
||||
},
|
||||
openDiff: async (original: string, modified: string, label?: string) => {
|
||||
await sendBridgeMessage('editor:openDiff', { original, modified, label });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
export const createVSCodeFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const response = await fetch('/api/fs/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
maxResults: payload.maxResults,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const results = (await response.json()) as unknown;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
|
||||
.map((item) => ({
|
||||
path: normalizePath((item as FileSearchResult).path),
|
||||
score: (item as FileSearchResult).score,
|
||||
preview: (item as FileSearchResult).preview,
|
||||
}));
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RuntimeAPIs, TerminalAPI, GitAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
||||
import { createVSCodeFilesAPI } from './files';
|
||||
import { createVSCodeSettingsAPI } from './settings';
|
||||
import { createVSCodePermissionsAPI } from './permissions';
|
||||
import { createVSCodeToolsAPI } from './tools';
|
||||
import { createVSCodeEditorAPI } from './editor';
|
||||
|
||||
// Stub APIs return sensible defaults instead of throwing
|
||||
const createStubTerminalAPI = (): TerminalAPI => ({
|
||||
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
});
|
||||
|
||||
const createStubGitAPI = (): GitAPI => ({
|
||||
checkIsGitRepository: async () => false,
|
||||
getGitStatus: async () => ({ current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true }),
|
||||
getGitDiff: async () => ({ diff: '' }),
|
||||
getGitFileDiff: async () => ({ original: '', modified: '', path: '' }),
|
||||
revertGitFile: async () => {},
|
||||
isLinkedWorktree: async () => false,
|
||||
getGitBranches: async () => ({ all: [], current: '', branches: {} }),
|
||||
deleteGitBranch: async () => ({ success: false }),
|
||||
deleteRemoteBranch: async () => ({ success: false }),
|
||||
generateCommitMessage: async () => ({ message: { subject: '', highlights: [] } }),
|
||||
listGitWorktrees: async () => [],
|
||||
addGitWorktree: async () => ({ success: false, path: '', branch: '' }),
|
||||
removeGitWorktree: async () => ({ success: false }),
|
||||
ensureOpenChamberIgnored: async () => {},
|
||||
createGitCommit: async () => ({ success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 } }),
|
||||
gitPush: async () => ({ success: false, pushed: [], repo: '', ref: null }),
|
||||
gitPull: async () => ({ success: false, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0 }),
|
||||
gitFetch: async () => ({ success: false }),
|
||||
checkoutBranch: async () => ({ success: false, branch: '' }),
|
||||
createBranch: async () => ({ success: false, branch: '' }),
|
||||
getGitLog: async () => ({ all: [], latest: null, total: 0 }),
|
||||
getCommitFiles: async () => ({ files: [] }),
|
||||
getCurrentGitIdentity: async () => null,
|
||||
setGitIdentity: async () => ({ success: false, profile: { id: '', name: '', userName: '', userEmail: '' } }),
|
||||
getGitIdentities: async () => [],
|
||||
createGitIdentity: async (p) => p,
|
||||
updateGitIdentity: async (_, p) => p,
|
||||
deleteGitIdentity: async () => {},
|
||||
});
|
||||
|
||||
const createStubNotificationsAPI = (): NotificationsAPI => ({
|
||||
notifyAgentCompletion: async () => true,
|
||||
canNotify: () => true,
|
||||
});
|
||||
|
||||
export const createVSCodeAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' },
|
||||
terminal: createStubTerminalAPI(),
|
||||
git: createStubGitAPI(),
|
||||
files: createVSCodeFilesAPI(),
|
||||
settings: createVSCodeSettingsAPI(),
|
||||
permissions: createVSCodePermissionsAPI(),
|
||||
notifications: createStubNotificationsAPI(),
|
||||
tools: createVSCodeToolsAPI(),
|
||||
editor: createVSCodeEditorAPI(),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodePermissionsAPI = (): PermissionsAPI => ({
|
||||
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
|
||||
// VS Code handles permissions via workspace
|
||||
return { success: true, path: request.path };
|
||||
},
|
||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const SETTINGS_ENDPOINT = '/api/config/settings';
|
||||
const RELOAD_ENDPOINT = '/api/config/reload';
|
||||
|
||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return data as SettingsPayload;
|
||||
};
|
||||
|
||||
export const createVSCodeSettingsAPI = (): SettingsAPI => ({
|
||||
async load(): Promise<SettingsLoadResult> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to VS Code config
|
||||
return {
|
||||
settings: {
|
||||
themeVariant: window.__VSCODE_CONFIG__?.theme === 'light' ? 'light' : 'dark',
|
||||
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || '',
|
||||
},
|
||||
source: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return {
|
||||
settings: {
|
||||
...payload,
|
||||
// Override with VS Code settings
|
||||
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
|
||||
},
|
||||
source: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return payload;
|
||||
},
|
||||
|
||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to restart OpenCode');
|
||||
}
|
||||
return { restarted: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoint as web - fetch interceptor handles URL rewriting
|
||||
export const createVSCodeToolsAPI = (): ToolsAPI => ({
|
||||
async getAvailableTools(): Promise<string[]> {
|
||||
const response = await fetch('/api/experimental/tool/ids');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Tools API returned invalid data format');
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { VSCodeHeader } from './VSCodeHeader';
|
||||
import { SimpleMessageRenderer } from './SimpleMessageRenderer';
|
||||
|
||||
export function ChatPanel() {
|
||||
const { goToSessions } = useNavigation();
|
||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const messages = useSessionStore((s) => s.messages);
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
const sendMessage = useSessionStore((s) => s.sendMessage);
|
||||
const abortCurrentOperation = useSessionStore((s) => s.abortCurrentOperation);
|
||||
const streamingMessageIds = useSessionStore((s) => s.streamingMessageIds);
|
||||
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const [isSending, setIsSending] = React.useState(false);
|
||||
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
const sessionTitle = currentSession?.title || 'New Chat';
|
||||
const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : [];
|
||||
const isStreaming = currentSessionId ? streamingMessageIds.has(currentSessionId) : false;
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
React.useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [sessionMessages.length]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || !currentSessionId || isSending) return;
|
||||
|
||||
const messageText = inputValue.trim();
|
||||
setInputValue('');
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
await sendMessage(messageText);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
setInputValue(messageText); // Restore input on error
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
if (currentSessionId) {
|
||||
abortCurrentOperation(currentSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<VSCodeHeader
|
||||
title={sessionTitle}
|
||||
showBack
|
||||
onBack={goToSessions}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-2"
|
||||
>
|
||||
{sessionMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Start a conversation
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessionMessages.map((msg) => (
|
||||
<SimpleMessageRenderer key={msg.info.id} message={msg} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-3 bg-background">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={isSending}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
style={{ minHeight: '40px', maxHeight: '120px' }}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleAbort}
|
||||
className="px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="3" y="3" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isSending}
|
||||
className="px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 8l14-7-4 7 4 7L1 8z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk';
|
||||
|
||||
interface SessionItemProps {
|
||||
session: Session;
|
||||
isActive: boolean;
|
||||
isStreaming?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const formatRelativeTime = (timestamp: number | undefined): string => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
export function SessionItem({ session, isActive, isStreaming, onClick }: SessionItemProps) {
|
||||
const title = session.title || 'New Session';
|
||||
const time = formatRelativeTime(session.time?.created);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 flex items-start gap-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 border-l-2 border-primary'
|
||||
: 'hover:bg-muted/50 border-l-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Activity indicator */}
|
||||
<div className="mt-1.5 flex-shrink-0">
|
||||
{isStreaming ? (
|
||||
<span className="block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
) : (
|
||||
<span className={`block w-2 h-2 rounded-full ${isActive ? 'bg-primary' : 'bg-muted-foreground/30'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{title}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span>{time}</span>
|
||||
{session.summary && (
|
||||
<span className="text-[10px]">
|
||||
{session.summary.additions !== undefined && (
|
||||
<span className="text-green-600">+{session.summary.additions}</span>
|
||||
)}
|
||||
{session.summary.deletions !== undefined && (
|
||||
<span className="text-red-500 ml-1">-{session.summary.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { VSCodeHeader } from './VSCodeHeader';
|
||||
import { SessionItem } from './SessionItem';
|
||||
|
||||
export function SessionsListView() {
|
||||
const { sessions, currentSessionId, setCurrentSession, createSession, streamingMessageIds } = useSessionStore();
|
||||
const { goToChat } = useNavigation();
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0));
|
||||
}, [sessions]);
|
||||
|
||||
const handleSelectSession = async (sessionId: string) => {
|
||||
await setCurrentSession(sessionId);
|
||||
goToChat();
|
||||
};
|
||||
|
||||
const handleNewSession = async () => {
|
||||
if (isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createSession();
|
||||
goToChat();
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const newButton = (
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="New session"
|
||||
>
|
||||
{isCreating ? (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader title="Sessions" actions={newButton} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sortedSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm mb-4">No sessions yet</div>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Start New Chat'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sortedSessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
isActive={session.id === currentSessionId}
|
||||
isStreaming={streamingMessageIds.has(session.id)}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk';
|
||||
|
||||
interface SimpleMessageRendererProps {
|
||||
message: { info: Message; parts: Part[] };
|
||||
}
|
||||
|
||||
export function SimpleMessageRenderer({ message }: SimpleMessageRendererProps) {
|
||||
const { info, parts } = message;
|
||||
const isUser = info.role === 'user';
|
||||
|
||||
// Extract text content from parts
|
||||
const textContent = parts
|
||||
.filter((part): part is Part & { type: 'text' } => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n');
|
||||
|
||||
// Check for tool calls
|
||||
const toolParts = parts.filter((part) => part.type === 'tool-invocation' || part.type === 'tool-result');
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
{/* Role indicator for assistant */}
|
||||
{!isUser && (
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Assistant
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text content */}
|
||||
{textContent && (
|
||||
<div className="whitespace-pre-wrap break-words">{textContent}</div>
|
||||
)}
|
||||
|
||||
{/* Tool activity indicator */}
|
||||
{toolParts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
{toolParts.map((part, idx) => (
|
||||
<ToolPartRenderer key={idx} part={part} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty message placeholder */}
|
||||
{!textContent && toolParts.length === 0 && (
|
||||
<div className="text-muted-foreground italic">...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolPartRenderer({ part }: { part: Part }) {
|
||||
if (part.type === 'tool-invocation') {
|
||||
const toolName = part.toolInvocation?.toolName || 'tool';
|
||||
const state = part.toolInvocation?.state || 'pending';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{state === 'pending' || state === 'streaming' ? (
|
||||
<span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : state === 'result' ? (
|
||||
<span className="text-green-500">✓</span>
|
||||
) : (
|
||||
<span className="text-red-500">✗</span>
|
||||
)}
|
||||
<span className="font-mono">{toolName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === 'tool-result') {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
Result: {typeof part.result === 'string' ? part.result.slice(0, 50) : '...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface VSCodeHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function VSCodeHeader({ title, showBack, onBack, actions }: VSCodeHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-background/80 backdrop-blur-sm sticky top-0 z-10">
|
||||
{showBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 -ml-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11 2L5 8l6 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<h1 className="flex-1 text-sm font-medium truncate">{title}</h1>
|
||||
{actions && <div className="flex items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { SessionsListView } from './SessionsListView';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
|
||||
export function VSCodeLayout() {
|
||||
const { currentView } = useNavigation();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-background text-foreground">
|
||||
{currentView === 'sessions' ? <SessionsListView /> : <ChatPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ViewType = 'sessions' | 'chat';
|
||||
|
||||
interface NavigationState {
|
||||
currentView: ViewType;
|
||||
navigateTo: (view: ViewType) => void;
|
||||
goToChat: () => void;
|
||||
goToSessions: () => void;
|
||||
}
|
||||
|
||||
export const useNavigation = create<NavigationState>((set) => ({
|
||||
currentView: 'sessions',
|
||||
navigateTo: (view) => set({ currentView: view }),
|
||||
goToChat: () => set({ currentView: 'chat' }),
|
||||
goToSessions: () => set({ currentView: 'sessions' }),
|
||||
}));
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenChamber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,281 @@
|
||||
import { createVSCodeAPIs } from './api';
|
||||
import { onThemeChange, sendBridgeMessage } from './api/bridge';
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import {
|
||||
buildVSCodeThemeFromPalette,
|
||||
readVSCodeThemePalette,
|
||||
type VSCodeThemeKind,
|
||||
type VSCodeThemePayload,
|
||||
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
||||
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
__VSCODE_CONFIG__?: {
|
||||
apiUrl: string;
|
||||
workspaceFolder: string;
|
||||
theme: string;
|
||||
connectionStatus: string;
|
||||
};
|
||||
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
||||
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[OpenChamber] VS Code webview starting...');
|
||||
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
|
||||
|
||||
const bootstrapConnectionStatus = () => {
|
||||
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
|
||||
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus };
|
||||
};
|
||||
|
||||
bootstrapConnectionStatus();
|
||||
|
||||
const handleConnectionMessage = (event: MessageEvent) => {
|
||||
const msg = event.data;
|
||||
if (msg?.type === 'connectionStatus') {
|
||||
const payload: ConnectionStatus = msg.status;
|
||||
const error: string | undefined = msg.error;
|
||||
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error };
|
||||
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleConnectionMessage);
|
||||
|
||||
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
|
||||
if (typeof document === 'undefined' || !theme) return;
|
||||
const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light';
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(variant);
|
||||
|
||||
const background = theme.colors?.surface?.background;
|
||||
if (background) {
|
||||
document.body.style.backgroundColor = background;
|
||||
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'theme-color');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', background);
|
||||
}
|
||||
};
|
||||
|
||||
const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
|
||||
const palette = readVSCodeThemePalette(preferredKind);
|
||||
if (!palette) {
|
||||
return;
|
||||
}
|
||||
const theme = buildVSCodeThemeFromPalette(palette);
|
||||
window.__OPENCHAMBER_VSCODE_THEME__ = theme;
|
||||
applyInitialTheme(theme);
|
||||
window.dispatchEvent(new CustomEvent<VSCodeThemePayload>('openchamber:vscode-theme', {
|
||||
detail: { theme, palette },
|
||||
}));
|
||||
};
|
||||
|
||||
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
|
||||
|
||||
onThemeChange((payload) => {
|
||||
const kind = (typeof payload === 'string'
|
||||
? payload
|
||||
: typeof payload === 'object' && payload
|
||||
? payload.kind
|
||||
: undefined) as VSCodeThemeKind | undefined;
|
||||
emitVSCodeTheme(kind);
|
||||
});
|
||||
|
||||
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
||||
if (workspaceFolder) {
|
||||
window.__OPENCHAMBER_HOME__ = workspaceFolder;
|
||||
try {
|
||||
window.localStorage.setItem('lastDirectory', workspaceFolder);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist workspace folder', error);
|
||||
}
|
||||
sendBridgeMessage('api:opencode/directory', { path: workspaceFolder }).catch((error) => {
|
||||
console.warn('Failed to set OpenCode working directory from VS Code workspace', error);
|
||||
});
|
||||
}
|
||||
|
||||
const normalizeUrl = (input: string | URL) => {
|
||||
try {
|
||||
return typeof input === 'string' ? new URL(input, window.location.origin) : new URL(input.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || 'http://localhost:47339';
|
||||
|
||||
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Health endpoints: always return OK to avoid blocking VS Code UX
|
||||
if (pathname === '/health' || pathname === '/api/health') {
|
||||
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/openchamber/models-metadata')) {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
|
||||
try {
|
||||
const response = await fetch('https://models.dev/api.json', {
|
||||
signal: controller?.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`models.dev responded with ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] Failed to fetch models metadata, returning empty set:', error);
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/list')) {
|
||||
const targetPath = url.searchParams.get('path') || '';
|
||||
const data = await sendBridgeMessage('api:fs:list', { path: targetPath });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/search')) {
|
||||
const directory = url.searchParams.get('directory') || '';
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const limitParam = url.searchParams.get('limit');
|
||||
const limit = limitParam ? Number(limitParam) : undefined;
|
||||
const resolvedLimit = Number.isFinite(limit) ? limit : undefined;
|
||||
const data = await sendBridgeMessage('api:fs:search', { directory, query, limit: resolvedLimit });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/mkdir')) {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/home')) {
|
||||
const data = await sendBridgeMessage('api:fs/home');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/vscode/pick-files')) {
|
||||
const data = await sendBridgeMessage('api:files/pick');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/config/settings')) {
|
||||
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
||||
const settings = await sendBridgeMessage('api:config/settings:get');
|
||||
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const updated = await sendBridgeMessage('api:config/settings:save', body);
|
||||
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/config/reload')) {
|
||||
await sendBridgeMessage('api:config/reload');
|
||||
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/openchamber/models-metadata')) {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:models/metadata');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error);
|
||||
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/auth/session') {
|
||||
// VS Code host is trusted; mirror web server shape to keep UI logic happy
|
||||
const body = {
|
||||
authenticated: true,
|
||||
requireSetup: false,
|
||||
authenticatedAt: Date.now(),
|
||||
};
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/opencode/directory')) {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
|
||||
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
|
||||
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
|
||||
const pathname = targetUrl?.pathname || '';
|
||||
const normalizedPathname = pathname.replace(/\/+/, '/');
|
||||
if (targetUrl && normalizedPathname === '/health') {
|
||||
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
|
||||
const localResponse = await handleLocalApiRequest(targetUrl, init);
|
||||
if (localResponse) {
|
||||
return localResponse;
|
||||
}
|
||||
|
||||
const rewritten = new URL(targetUrl.href);
|
||||
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
|
||||
const fetchTarget = `${apiBaseUrl}${rewritten.pathname}${rewritten.search}`;
|
||||
|
||||
if (input instanceof Request) {
|
||||
const cloned = input.clone();
|
||||
const requestInit: RequestInit = {
|
||||
method: method,
|
||||
headers: cloned.headers,
|
||||
body: method === 'GET' || method === 'HEAD' ? undefined : await cloned.blob(),
|
||||
};
|
||||
return originalFetch(fetchTarget, requestInit);
|
||||
}
|
||||
|
||||
return originalFetch(fetchTarget, init);
|
||||
}
|
||||
|
||||
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:models/metadata');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error);
|
||||
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch(input as RequestInfo, init);
|
||||
};
|
||||
import('@openchamber/ui/main');
|
||||
@@ -0,0 +1,183 @@
|
||||
import { create } from 'zustand';
|
||||
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
|
||||
import type { Session, Message, Part } from '@opencode-ai/sdk';
|
||||
|
||||
const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339';
|
||||
const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
|
||||
|
||||
interface MessageRecord {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
// Client
|
||||
client: OpencodeClient | null;
|
||||
isConnected: boolean;
|
||||
|
||||
// Sessions
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
isLoadingSessions: boolean;
|
||||
|
||||
// Messages
|
||||
messages: Map<string, MessageRecord[]>;
|
||||
isLoadingMessages: boolean;
|
||||
isSending: boolean;
|
||||
streamingSessionId: string | null;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
loadSessions: () => Promise<void>;
|
||||
createSession: () => Promise<string | null>;
|
||||
selectSession: (sessionId: string) => Promise<void>;
|
||||
loadMessages: (sessionId: string) => Promise<void>;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
abortMessage: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
client: null,
|
||||
isConnected: false,
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
isLoadingSessions: false,
|
||||
messages: new Map(),
|
||||
isLoadingMessages: false,
|
||||
isSending: false,
|
||||
streamingSessionId: null,
|
||||
|
||||
initialize: async () => {
|
||||
const apiUrl = getApiUrl();
|
||||
const client = createOpencodeClient({ baseUrl: apiUrl });
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await client.session.list({ query: { directory: getWorkspaceFolder() } });
|
||||
set({ client, isConnected: true });
|
||||
await get().loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to OpenCode API:', error);
|
||||
set({ client, isConnected: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadSessions: async () => {
|
||||
const { client } = get();
|
||||
if (!client) return;
|
||||
|
||||
set({ isLoadingSessions: true });
|
||||
try {
|
||||
const response = await client.session.list({ query: { directory: getWorkspaceFolder() } });
|
||||
const sessionsArray = Array.isArray(response.data) ? response.data : [];
|
||||
const sessions = sessionsArray.sort(
|
||||
(a, b) => (b.time?.created || 0) - (a.time?.created || 0)
|
||||
);
|
||||
set({ sessions, isLoadingSessions: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
set({ isLoadingSessions: false });
|
||||
}
|
||||
},
|
||||
|
||||
createSession: async () => {
|
||||
const { client } = get();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const response = await client.session.create({ query: { directory: getWorkspaceFolder() }, body: {} });
|
||||
const session = response.data;
|
||||
if (!session) throw new Error('No session returned');
|
||||
await get().loadSessions();
|
||||
set({ currentSessionId: session.id });
|
||||
return session.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async (sessionId: string) => {
|
||||
set({ currentSessionId: sessionId });
|
||||
await get().loadMessages(sessionId);
|
||||
},
|
||||
|
||||
loadMessages: async (sessionId: string) => {
|
||||
const { client, messages } = get();
|
||||
if (!client) return;
|
||||
|
||||
set({ isLoadingMessages: true });
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
query: { directory: getWorkspaceFolder() }
|
||||
});
|
||||
const messageRecords: MessageRecord[] = (response.data || []).map((msg) => ({
|
||||
info: msg.info,
|
||||
parts: msg.parts || [],
|
||||
}));
|
||||
|
||||
const newMessages = new Map(messages);
|
||||
newMessages.set(sessionId, messageRecords);
|
||||
set({ messages: newMessages, isLoadingMessages: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
set({ isLoadingMessages: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
const { client, currentSessionId, messages } = get();
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
set({ isSending: true, streamingSessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
// Add user message optimistically
|
||||
const userMessage: MessageRecord = {
|
||||
info: {
|
||||
id: `temp-${Date.now()}`,
|
||||
sessionId: currentSessionId,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: content }],
|
||||
time: { created: Date.now() },
|
||||
} as Message,
|
||||
parts: [{ type: 'text', text: content }],
|
||||
};
|
||||
|
||||
const currentMessages = messages.get(currentSessionId) || [];
|
||||
const newMessages = new Map(messages);
|
||||
newMessages.set(currentSessionId, [...currentMessages, userMessage]);
|
||||
set({ messages: newMessages });
|
||||
|
||||
// Send message via session.prompt
|
||||
await client.session.prompt({
|
||||
path: { id: currentSessionId },
|
||||
query: { directory: getWorkspaceFolder() },
|
||||
body: {
|
||||
parts: [{ type: 'text', text: content }],
|
||||
},
|
||||
});
|
||||
|
||||
// Reload messages to get the actual response
|
||||
await get().loadMessages(currentSessionId);
|
||||
await get().loadSessions(); // Update session title if changed
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
} finally {
|
||||
set({ isSending: false, streamingSessionId: null });
|
||||
}
|
||||
},
|
||||
|
||||
abortMessage: async () => {
|
||||
const { client, currentSessionId } = get();
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
try {
|
||||
await client.session.abort({ path: { id: currentSessionId } });
|
||||
} catch (error) {
|
||||
console.error('Failed to abort:', error);
|
||||
}
|
||||
set({ isSending: false, streamingSessionId: null });
|
||||
},
|
||||
}));
|
||||
@@ -8,7 +8,7 @@ import { createWebNotificationsAPI } from './notifications';
|
||||
import { createWebToolsAPI } from './tools';
|
||||
|
||||
export const createWebAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'web', isDesktop: false, label: 'web' },
|
||||
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
|
||||
terminal: createWebTerminalAPI(),
|
||||
git: createWebGitAPI(),
|
||||
files: createWebFilesAPI(),
|
||||
|
||||
Generated
+1783
-4
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ onlyBuiltDependencies:
|
||||
- '@heroui/shared-utils'
|
||||
- '@ibm/plex'
|
||||
- '@tailwindcss/oxide'
|
||||
- '@vscode/vsce-sign'
|
||||
- electron
|
||||
- esbuild
|
||||
- keytar
|
||||
- node-pty
|
||||
|
||||
@@ -11,6 +11,7 @@ const PACKAGES = [
|
||||
'packages/ui/package.json',
|
||||
'packages/web/package.json',
|
||||
'packages/desktop/package.json',
|
||||
'packages/vscode/package.json',
|
||||
];
|
||||
|
||||
const TAURI_CONF = 'packages/desktop/src-tauri/tauri.conf.json';
|
||||
|
||||
Reference in New Issue
Block a user