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:
Bohdan Triapitsyn
2025-12-13 16:34:17 +02:00
committed by GitHub
parent 610ccf4c62
commit bb72c0fb0c
76 changed files with 6097 additions and 296 deletions
+61
View File
@@ -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 }}
+1
View File
@@ -11,6 +11,7 @@ dist-ssr
release
*.local
*.tgz
*.vsix
/npm
/tsc
/openchamber@*
+3
View File
@@ -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
+5
View File
@@ -16,6 +16,7 @@ The whole project was built entirely with AI coding agents under my supervision.
![Tool Output](docs/references/tool_output_example.png)
![Settings](docs/references/settings_example.png)
![Web Version](docs/references/web_version_example.png)
![VS Code Extension](docs/references/vscode_extension.png)
<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

+165
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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;
}
+110 -53
View File
@@ -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') {
+29 -6
View File
@@ -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]);
+2
View File
@@ -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);
+9 -19
View File
@@ -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) {
+46
View File
@@ -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 */
+9 -1
View File
@@ -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[];
}
+6
View File
@@ -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;
+9 -7
View File
@@ -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;');
+268
View File
@@ -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,
},
},
},
};
};
+15 -3
View File
@@ -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',
+19 -5
View File
@@ -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 => {
+46 -13
View File
@@ -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 });
}
},
+11
View File
@@ -0,0 +1,11 @@
.vscode/**
node_modules/**
src/**
webview/**
.gitignore
tsconfig.json
tsconfig.webview.json
vite.config.ts
*.map
**/*.ts
!dist/**
+21
View File
@@ -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.
+47
View File
@@ -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.
![VS Code Extension](../../docs/references/vscode_extension.png)
## 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

+9
View File
@@ -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

+121
View File
@@ -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"
}
}
+120
View File
@@ -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>`;
}
}
+338
View File
@@ -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 };
}
}
+76
View File
@@ -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;
}
+365
View File
@@ -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));
},
};
}
+15
View File
@@ -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';
}
}
+18
View File
@@ -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"]
}
+30
View File
@@ -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"
]
}
+41
View File
@@ -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]',
},
},
},
});
+272
View File
@@ -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;
+114
View File
@@ -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);
}
});
+12
View File
@@ -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 });
},
});
+71
View File
@@ -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,
};
},
});
+63
View File
@@ -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 };
},
});
+70
View File
@@ -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 };
},
});
+22
View File
@@ -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' }),
}));
+12
View File
@@ -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>
+281
View File
@@ -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');
+183
View File
@@ -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 });
},
}));
+1 -1
View File
@@ -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(),
+1783 -4
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -5,6 +5,8 @@ onlyBuiltDependencies:
- '@heroui/shared-utils'
- '@ibm/plex'
- '@tailwindcss/oxide'
- '@vscode/vsce-sign'
- electron
- esbuild
- keytar
- node-pty
+1
View File
@@ -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';