feat: vscode extension (#59)
* feat: add initial VS Code extension plan and implementation tasks * feat(vscode): added initial version of an Openchamber VSCode extension * feat(vscode): enhance VS Code extension with theme integration and session management * feat(vscode): implement connection status handling and overlay in VSCode layout * feat: move extension to secondary sidebar * chore: upgrade @opencode-ai/sdk to 1.0.150 * vscode: editor bridge, file picker, click-to-open in tool parts * vscode: layout session lifecycle, theme sync, typography overrides * ui: compact mode for vscode, model search, autocomplete width fixes * perf: scroll force flag, raf placeholder, git polling backoff * ui: tool output styling, markdown code block fix, gitignore * refactor: update typography handling for VSCode runtime, remove unused styles * docs: update README with VS Code extension details and add extension image * docs: update changelog with new features and performance improvements
This commit is contained in:
committed by
GitHub
parent
610ccf4c62
commit
bb72c0fb0c
@@ -0,0 +1,272 @@
|
||||
import React from 'react';
|
||||
import { useChatStore } from './stores/chatStore';
|
||||
import { useNavigation } from './hooks/useNavigation';
|
||||
|
||||
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
function ConnectionStatusBanner({ status, error, onRetry }: {
|
||||
status: ConnectionStatus;
|
||||
error?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (status === 'connected') return null;
|
||||
|
||||
const messages: Record<ConnectionStatus, string> = {
|
||||
disconnected: 'Not connected to OpenCode API',
|
||||
connecting: 'Connecting...',
|
||||
connected: '',
|
||||
error: error || 'Connection error',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 px-4 py-2 text-sm border-b ${
|
||||
status === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{status === 'connecting' && (
|
||||
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{messages[status]}</span>
|
||||
{(status === 'disconnected' || status === 'error') && (
|
||||
<button onClick={onRetry} className="px-2 py-0.5 text-xs rounded bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsList() {
|
||||
const { sessions, currentSessionId, selectSession, createSession, isLoadingSessions } = useChatStore();
|
||||
const { goToChat } = useNavigation();
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
|
||||
const handleSelectSession = async (sessionId: string) => {
|
||||
await selectSession(sessionId);
|
||||
goToChat();
|
||||
};
|
||||
|
||||
const handleNewSession = async () => {
|
||||
setIsCreating(true);
|
||||
const sessionId = await createSession();
|
||||
setIsCreating(false);
|
||||
if (sessionId) {
|
||||
goToChat();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp?: number) => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000);
|
||||
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<h1 className="text-sm font-medium">Sessions</h1>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="p-1.5 rounded hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? (
|
||||
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoadingSessions ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">Loading...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm mb-4">No sessions yet</div>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Start New Chat'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={`w-full text-left px-3 py-2.5 hover:bg-muted/50 transition-colors ${
|
||||
session.id === currentSessionId ? 'bg-primary/10 border-l-2 border-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{session.title || 'New Session'}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatTime(session.time?.created)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPanel() {
|
||||
const { currentSessionId, sessions, messages, sendMessage, abortMessage, isSending, streamingSessionId } = useChatStore();
|
||||
const { goToSessions } = useNavigation();
|
||||
const [input, setInput] = React.useState('');
|
||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : [];
|
||||
const isStreaming = streamingSessionId === currentSessionId;
|
||||
|
||||
React.useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [sessionMessages.length]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isSending) return;
|
||||
const text = input.trim();
|
||||
setInput('');
|
||||
await sendMessage(text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<button onClick={goToSessions} className="p-1 rounded hover:bg-muted">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M11 2L5 8l6 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
<h1 className="text-sm font-medium truncate flex-1">{currentSession?.title || 'New Chat'}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
{sessionMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Start a conversation
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessionMessages.map((msg, idx) => (
|
||||
<MessageBubble key={msg.info.id || idx} message={msg} />
|
||||
))}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-muted rounded-lg px-3 py-2 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
disabled={isSending}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
style={{ minHeight: 40, maxHeight: 120 }}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button onClick={abortMessage} className="px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="currentColor"/></svg>
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSend} disabled={!input.trim() || isSending} className="px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l12-6-3.5 6 3.5 6L2 8z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: { info: { role: string }; parts: Array<{ type: string; text?: string }> } }) {
|
||||
const isUser = message.info.role === 'user';
|
||||
const text = message.parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
isUser ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||
}`}>
|
||||
<div className="whitespace-pre-wrap break-words">{text || '...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VSCodeApp() {
|
||||
const { initialize, isConnected } = useChatStore();
|
||||
const { currentView } = useNavigation();
|
||||
const [status, setStatus] = React.useState<ConnectionStatus>('connecting');
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
const connect = React.useCallback(async () => {
|
||||
setStatus('connecting');
|
||||
setError(undefined);
|
||||
try {
|
||||
await initialize();
|
||||
setStatus('connected');
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [initialize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isConnected) setStatus('connected');
|
||||
}, [isConnected]);
|
||||
|
||||
// Listen for extension messages
|
||||
React.useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'connectionStatus') {
|
||||
if (msg.status === 'connected') setStatus('connected');
|
||||
else if (msg.status === 'error') {
|
||||
setStatus('error');
|
||||
setError(msg.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background text-foreground">
|
||||
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
|
||||
<div className="flex-1 min-h-0">
|
||||
{currentView === 'sessions' ? <SessionsList /> : <ChatPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VSCodeApp;
|
||||
@@ -0,0 +1,114 @@
|
||||
declare const acquireVsCodeApi: () => {
|
||||
postMessage: (message: unknown) => void;
|
||||
getState: () => unknown;
|
||||
setState: (state: unknown) => void;
|
||||
};
|
||||
|
||||
interface VSCodeAPI {
|
||||
postMessage: (message: unknown) => void;
|
||||
}
|
||||
|
||||
let vscodeApi: VSCodeAPI | null = null;
|
||||
|
||||
function getVSCodeAPI(): VSCodeAPI {
|
||||
if (!vscodeApi) {
|
||||
vscodeApi = acquireVsCodeApi();
|
||||
}
|
||||
return vscodeApi;
|
||||
}
|
||||
|
||||
// Export vscode API for direct use
|
||||
export const vscode = {
|
||||
postMessage: (message: unknown) => getVSCodeAPI().postMessage(message),
|
||||
};
|
||||
|
||||
interface BridgeRequest {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface BridgeResponse {
|
||||
id: string;
|
||||
type: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}>();
|
||||
|
||||
let requestIdCounter = 0;
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
|
||||
const response = event.data;
|
||||
if (!response || typeof response.id !== 'string') return;
|
||||
|
||||
const pending = pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
pendingRequests.delete(response.id);
|
||||
if (response.success) {
|
||||
pending.resolve(response.data);
|
||||
} else {
|
||||
pending.reject(new Error(response.error || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++requestIdCounter}_${Date.now()}`;
|
||||
const request: BridgeRequest = { id, type, payload };
|
||||
|
||||
pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${type} timed out`));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
getVSCodeAPI().postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
type CommandHandler = (payload: unknown) => void;
|
||||
const commandHandlers = new Map<string, CommandHandler>();
|
||||
|
||||
export function onCommand(command: string, handler: CommandHandler): () => void {
|
||||
commandHandlers.set(command, handler);
|
||||
return () => commandHandlers.delete(command);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'command' && message.command) {
|
||||
const handler = commandHandlers.get(message.command);
|
||||
if (handler) {
|
||||
handler(message.payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type ThemeChangePayload = 'light' | 'dark' | { kind?: 'light' | 'dark' | 'high-contrast' };
|
||||
type ThemeChangeHandler = (theme: ThemeChangePayload) => void;
|
||||
let themeChangeHandler: ThemeChangeHandler | null = null;
|
||||
|
||||
export function onThemeChange(handler: ThemeChangeHandler): () => void {
|
||||
themeChangeHandler = handler;
|
||||
return () => { themeChangeHandler = null; };
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message?.type === 'themeChange' && themeChangeHandler) {
|
||||
themeChangeHandler(message.theme);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
import { sendBridgeMessage } from './bridge';
|
||||
import type { EditorAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodeEditorAPI = (): EditorAPI => ({
|
||||
openFile: async (path: string, line?: number, column?: number) => {
|
||||
await sendBridgeMessage('editor:openFile', { path, line, column });
|
||||
},
|
||||
openDiff: async (original: string, modified: string, label?: string) => {
|
||||
await sendBridgeMessage('editor:openDiff', { original, modified, label });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
export const createVSCodeFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const response = await fetch('/api/fs/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
maxResults: payload.maxResults,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const results = (await response.json()) as unknown;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
|
||||
.map((item) => ({
|
||||
path: normalizePath((item as FileSearchResult).path),
|
||||
score: (item as FileSearchResult).score,
|
||||
preview: (item as FileSearchResult).preview,
|
||||
}));
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RuntimeAPIs, TerminalAPI, GitAPI, NotificationsAPI } from '@openchamber/ui/lib/api/types';
|
||||
import { createVSCodeFilesAPI } from './files';
|
||||
import { createVSCodeSettingsAPI } from './settings';
|
||||
import { createVSCodePermissionsAPI } from './permissions';
|
||||
import { createVSCodeToolsAPI } from './tools';
|
||||
import { createVSCodeEditorAPI } from './editor';
|
||||
|
||||
// Stub APIs return sensible defaults instead of throwing
|
||||
const createStubTerminalAPI = (): TerminalAPI => ({
|
||||
createSession: async () => ({ sessionId: '', cols: 80, rows: 24 }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
});
|
||||
|
||||
const createStubGitAPI = (): GitAPI => ({
|
||||
checkIsGitRepository: async () => false,
|
||||
getGitStatus: async () => ({ current: '', tracking: null, ahead: 0, behind: 0, files: [], isClean: true }),
|
||||
getGitDiff: async () => ({ diff: '' }),
|
||||
getGitFileDiff: async () => ({ original: '', modified: '', path: '' }),
|
||||
revertGitFile: async () => {},
|
||||
isLinkedWorktree: async () => false,
|
||||
getGitBranches: async () => ({ all: [], current: '', branches: {} }),
|
||||
deleteGitBranch: async () => ({ success: false }),
|
||||
deleteRemoteBranch: async () => ({ success: false }),
|
||||
generateCommitMessage: async () => ({ message: { subject: '', highlights: [] } }),
|
||||
listGitWorktrees: async () => [],
|
||||
addGitWorktree: async () => ({ success: false, path: '', branch: '' }),
|
||||
removeGitWorktree: async () => ({ success: false }),
|
||||
ensureOpenChamberIgnored: async () => {},
|
||||
createGitCommit: async () => ({ success: false, commit: '', branch: '', summary: { changes: 0, insertions: 0, deletions: 0 } }),
|
||||
gitPush: async () => ({ success: false, pushed: [], repo: '', ref: null }),
|
||||
gitPull: async () => ({ success: false, summary: { changes: 0, insertions: 0, deletions: 0 }, files: [], insertions: 0, deletions: 0 }),
|
||||
gitFetch: async () => ({ success: false }),
|
||||
checkoutBranch: async () => ({ success: false, branch: '' }),
|
||||
createBranch: async () => ({ success: false, branch: '' }),
|
||||
getGitLog: async () => ({ all: [], latest: null, total: 0 }),
|
||||
getCommitFiles: async () => ({ files: [] }),
|
||||
getCurrentGitIdentity: async () => null,
|
||||
setGitIdentity: async () => ({ success: false, profile: { id: '', name: '', userName: '', userEmail: '' } }),
|
||||
getGitIdentities: async () => [],
|
||||
createGitIdentity: async (p) => p,
|
||||
updateGitIdentity: async (_, p) => p,
|
||||
deleteGitIdentity: async () => {},
|
||||
});
|
||||
|
||||
const createStubNotificationsAPI = (): NotificationsAPI => ({
|
||||
notifyAgentCompletion: async () => true,
|
||||
canNotify: () => true,
|
||||
});
|
||||
|
||||
export const createVSCodeAPIs = (): RuntimeAPIs => ({
|
||||
runtime: { platform: 'vscode', isDesktop: false, isVSCode: true, label: 'VS Code Extension' },
|
||||
terminal: createStubTerminalAPI(),
|
||||
git: createStubGitAPI(),
|
||||
files: createVSCodeFilesAPI(),
|
||||
settings: createVSCodeSettingsAPI(),
|
||||
permissions: createVSCodePermissionsAPI(),
|
||||
notifications: createStubNotificationsAPI(),
|
||||
tools: createVSCodeToolsAPI(),
|
||||
editor: createVSCodeEditorAPI(),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
export const createVSCodePermissionsAPI = (): PermissionsAPI => ({
|
||||
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
|
||||
// VS Code handles permissions via workspace
|
||||
return { success: true, path: request.path };
|
||||
},
|
||||
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
|
||||
void path;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const SETTINGS_ENDPOINT = '/api/config/settings';
|
||||
const RELOAD_ENDPOINT = '/api/config/reload';
|
||||
|
||||
const sanitizePayload = (data: unknown): SettingsPayload => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return data as SettingsPayload;
|
||||
};
|
||||
|
||||
export const createVSCodeSettingsAPI = (): SettingsAPI => ({
|
||||
async load(): Promise<SettingsLoadResult> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to VS Code config
|
||||
return {
|
||||
settings: {
|
||||
themeVariant: window.__VSCODE_CONFIG__?.theme === 'light' ? 'light' : 'dark',
|
||||
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || '',
|
||||
},
|
||||
source: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return {
|
||||
settings: {
|
||||
...payload,
|
||||
// Override with VS Code settings
|
||||
lastDirectory: window.__VSCODE_CONFIG__?.workspaceFolder || payload.lastDirectory || '',
|
||||
},
|
||||
source: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
|
||||
const response = await fetch(SETTINGS_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const payload = sanitizePayload(await response.json().catch(() => ({})));
|
||||
return payload;
|
||||
},
|
||||
|
||||
async restartOpenCode(): Promise<{ restarted: boolean }> {
|
||||
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to restart OpenCode');
|
||||
}
|
||||
return { restarted: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoint as web - fetch interceptor handles URL rewriting
|
||||
export const createVSCodeToolsAPI = (): ToolsAPI => ({
|
||||
async getAvailableTools(): Promise<string[]> {
|
||||
const response = await fetch('/api/experimental/tool/ids');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Tools API returned invalid data format');
|
||||
}
|
||||
|
||||
return data
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
|
||||
.sort();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { VSCodeHeader } from './VSCodeHeader';
|
||||
import { SimpleMessageRenderer } from './SimpleMessageRenderer';
|
||||
|
||||
export function ChatPanel() {
|
||||
const { goToSessions } = useNavigation();
|
||||
const messagesEndRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const messages = useSessionStore((s) => s.messages);
|
||||
const sessions = useSessionStore((s) => s.sessions);
|
||||
const sendMessage = useSessionStore((s) => s.sendMessage);
|
||||
const abortCurrentOperation = useSessionStore((s) => s.abortCurrentOperation);
|
||||
const streamingMessageIds = useSessionStore((s) => s.streamingMessageIds);
|
||||
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const [isSending, setIsSending] = React.useState(false);
|
||||
|
||||
const currentSession = sessions.find((s) => s.id === currentSessionId);
|
||||
const sessionTitle = currentSession?.title || 'New Chat';
|
||||
const sessionMessages = currentSessionId ? messages.get(currentSessionId) || [] : [];
|
||||
const isStreaming = currentSessionId ? streamingMessageIds.has(currentSessionId) : false;
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
React.useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [sessionMessages.length]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || !currentSessionId || isSending) return;
|
||||
|
||||
const messageText = inputValue.trim();
|
||||
setInputValue('');
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
await sendMessage(messageText);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
setInputValue(messageText); // Restore input on error
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
if (currentSessionId) {
|
||||
abortCurrentOperation(currentSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<VSCodeHeader
|
||||
title={sessionTitle}
|
||||
showBack
|
||||
onBack={goToSessions}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-2"
|
||||
>
|
||||
{sessionMessages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Start a conversation
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessionMessages.map((msg) => (
|
||||
<SimpleMessageRenderer key={msg.info.id} message={msg} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-3 bg-background">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
disabled={isSending}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
style={{ minHeight: '40px', maxHeight: '120px' }}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleAbort}
|
||||
className="px-3 py-2 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="3" y="3" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isSending}
|
||||
className="px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 8l14-7-4 7 4 7L1 8z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk';
|
||||
|
||||
interface SessionItemProps {
|
||||
session: Session;
|
||||
isActive: boolean;
|
||||
isStreaming?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const formatRelativeTime = (timestamp: number | undefined): string => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
export function SessionItem({ session, isActive, isStreaming, onClick }: SessionItemProps) {
|
||||
const title = session.title || 'New Session';
|
||||
const time = formatRelativeTime(session.time?.created);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 flex items-start gap-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 border-l-2 border-primary'
|
||||
: 'hover:bg-muted/50 border-l-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
{/* Activity indicator */}
|
||||
<div className="mt-1.5 flex-shrink-0">
|
||||
{isStreaming ? (
|
||||
<span className="block w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
) : (
|
||||
<span className={`block w-2 h-2 rounded-full ${isActive ? 'bg-primary' : 'bg-muted-foreground/30'}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{title}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span>{time}</span>
|
||||
{session.summary && (
|
||||
<span className="text-[10px]">
|
||||
{session.summary.additions !== undefined && (
|
||||
<span className="text-green-600">+{session.summary.additions}</span>
|
||||
)}
|
||||
{session.summary.deletions !== undefined && (
|
||||
<span className="text-red-500 ml-1">-{session.summary.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { VSCodeHeader } from './VSCodeHeader';
|
||||
import { SessionItem } from './SessionItem';
|
||||
|
||||
export function SessionsListView() {
|
||||
const { sessions, currentSessionId, setCurrentSession, createSession, streamingMessageIds } = useSessionStore();
|
||||
const { goToChat } = useNavigation();
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0));
|
||||
}, [sessions]);
|
||||
|
||||
const handleSelectSession = async (sessionId: string) => {
|
||||
await setCurrentSession(sessionId);
|
||||
goToChat();
|
||||
};
|
||||
|
||||
const handleNewSession = async () => {
|
||||
if (isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createSession();
|
||||
goToChat();
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const newButton = (
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="New session"
|
||||
>
|
||||
{isCreating ? (
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<VSCodeHeader title="Sessions" actions={newButton} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sortedSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm mb-4">No sessions yet</div>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
disabled={isCreating}
|
||||
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Start New Chat'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sortedSessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
isActive={session.id === currentSessionId}
|
||||
isStreaming={streamingMessageIds.has(session.id)}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk';
|
||||
|
||||
interface SimpleMessageRendererProps {
|
||||
message: { info: Message; parts: Part[] };
|
||||
}
|
||||
|
||||
export function SimpleMessageRenderer({ message }: SimpleMessageRendererProps) {
|
||||
const { info, parts } = message;
|
||||
const isUser = info.role === 'user';
|
||||
|
||||
// Extract text content from parts
|
||||
const textContent = parts
|
||||
.filter((part): part is Part & { type: 'text' } => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n');
|
||||
|
||||
// Check for tool calls
|
||||
const toolParts = parts.filter((part) => part.type === 'tool-invocation' || part.type === 'tool-result');
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
{/* Role indicator for assistant */}
|
||||
{!isUser && (
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Assistant
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text content */}
|
||||
{textContent && (
|
||||
<div className="whitespace-pre-wrap break-words">{textContent}</div>
|
||||
)}
|
||||
|
||||
{/* Tool activity indicator */}
|
||||
{toolParts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
{toolParts.map((part, idx) => (
|
||||
<ToolPartRenderer key={idx} part={part} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty message placeholder */}
|
||||
{!textContent && toolParts.length === 0 && (
|
||||
<div className="text-muted-foreground italic">...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolPartRenderer({ part }: { part: Part }) {
|
||||
if (part.type === 'tool-invocation') {
|
||||
const toolName = part.toolInvocation?.toolName || 'tool';
|
||||
const state = part.toolInvocation?.state || 'pending';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{state === 'pending' || state === 'streaming' ? (
|
||||
<span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : state === 'result' ? (
|
||||
<span className="text-green-500">✓</span>
|
||||
) : (
|
||||
<span className="text-red-500">✗</span>
|
||||
)}
|
||||
<span className="font-mono">{toolName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === 'tool-result') {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
Result: {typeof part.result === 'string' ? part.result.slice(0, 50) : '...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface VSCodeHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
onBack?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function VSCodeHeader({ title, showBack, onBack, actions }: VSCodeHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-background/80 backdrop-blur-sm sticky top-0 z-10">
|
||||
{showBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 -ml-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11 2L5 8l6 6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<h1 className="flex-1 text-sm font-medium truncate">{title}</h1>
|
||||
{actions && <div className="flex items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import { SessionsListView } from './SessionsListView';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
|
||||
export function VSCodeLayout() {
|
||||
const { currentView } = useNavigation();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-background text-foreground">
|
||||
{currentView === 'sessions' ? <SessionsListView /> : <ChatPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ViewType = 'sessions' | 'chat';
|
||||
|
||||
interface NavigationState {
|
||||
currentView: ViewType;
|
||||
navigateTo: (view: ViewType) => void;
|
||||
goToChat: () => void;
|
||||
goToSessions: () => void;
|
||||
}
|
||||
|
||||
export const useNavigation = create<NavigationState>((set) => ({
|
||||
currentView: 'sessions',
|
||||
navigateTo: (view) => set({ currentView: view }),
|
||||
goToChat: () => set({ currentView: 'chat' }),
|
||||
goToSessions: () => set({ currentView: 'sessions' }),
|
||||
}));
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenChamber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,281 @@
|
||||
import { createVSCodeAPIs } from './api';
|
||||
import { onThemeChange, sendBridgeMessage } from './api/bridge';
|
||||
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
||||
import {
|
||||
buildVSCodeThemeFromPalette,
|
||||
readVSCodeThemePalette,
|
||||
type VSCodeThemeKind,
|
||||
type VSCodeThemePayload,
|
||||
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
||||
|
||||
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
__VSCODE_CONFIG__?: {
|
||||
apiUrl: string;
|
||||
workspaceFolder: string;
|
||||
theme: string;
|
||||
connectionStatus: string;
|
||||
};
|
||||
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
||||
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[OpenChamber] VS Code webview starting...');
|
||||
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
|
||||
|
||||
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
|
||||
|
||||
const bootstrapConnectionStatus = () => {
|
||||
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
|
||||
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus };
|
||||
};
|
||||
|
||||
bootstrapConnectionStatus();
|
||||
|
||||
const handleConnectionMessage = (event: MessageEvent) => {
|
||||
const msg = event.data;
|
||||
if (msg?.type === 'connectionStatus') {
|
||||
const payload: ConnectionStatus = msg.status;
|
||||
const error: string | undefined = msg.error;
|
||||
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error };
|
||||
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleConnectionMessage);
|
||||
|
||||
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
|
||||
if (typeof document === 'undefined' || !theme) return;
|
||||
const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light';
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(variant);
|
||||
|
||||
const background = theme.colors?.surface?.background;
|
||||
if (background) {
|
||||
document.body.style.backgroundColor = background;
|
||||
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'theme-color');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', background);
|
||||
}
|
||||
};
|
||||
|
||||
const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
|
||||
const palette = readVSCodeThemePalette(preferredKind);
|
||||
if (!palette) {
|
||||
return;
|
||||
}
|
||||
const theme = buildVSCodeThemeFromPalette(palette);
|
||||
window.__OPENCHAMBER_VSCODE_THEME__ = theme;
|
||||
applyInitialTheme(theme);
|
||||
window.dispatchEvent(new CustomEvent<VSCodeThemePayload>('openchamber:vscode-theme', {
|
||||
detail: { theme, palette },
|
||||
}));
|
||||
};
|
||||
|
||||
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
|
||||
|
||||
onThemeChange((payload) => {
|
||||
const kind = (typeof payload === 'string'
|
||||
? payload
|
||||
: typeof payload === 'object' && payload
|
||||
? payload.kind
|
||||
: undefined) as VSCodeThemeKind | undefined;
|
||||
emitVSCodeTheme(kind);
|
||||
});
|
||||
|
||||
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
||||
if (workspaceFolder) {
|
||||
window.__OPENCHAMBER_HOME__ = workspaceFolder;
|
||||
try {
|
||||
window.localStorage.setItem('lastDirectory', workspaceFolder);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist workspace folder', error);
|
||||
}
|
||||
sendBridgeMessage('api:opencode/directory', { path: workspaceFolder }).catch((error) => {
|
||||
console.warn('Failed to set OpenCode working directory from VS Code workspace', error);
|
||||
});
|
||||
}
|
||||
|
||||
const normalizeUrl = (input: string | URL) => {
|
||||
try {
|
||||
return typeof input === 'string' ? new URL(input, window.location.origin) : new URL(input.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || 'http://localhost:47339';
|
||||
|
||||
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Health endpoints: always return OK to avoid blocking VS Code UX
|
||||
if (pathname === '/health' || pathname === '/api/health') {
|
||||
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/openchamber/models-metadata')) {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
|
||||
try {
|
||||
const response = await fetch('https://models.dev/api.json', {
|
||||
signal: controller?.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`models.dev responded with ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] Failed to fetch models metadata, returning empty set:', error);
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/list')) {
|
||||
const targetPath = url.searchParams.get('path') || '';
|
||||
const data = await sendBridgeMessage('api:fs:list', { path: targetPath });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/search')) {
|
||||
const directory = url.searchParams.get('directory') || '';
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const limitParam = url.searchParams.get('limit');
|
||||
const limit = limitParam ? Number(limitParam) : undefined;
|
||||
const resolvedLimit = Number.isFinite(limit) ? limit : undefined;
|
||||
const data = await sendBridgeMessage('api:fs:search', { directory, query, limit: resolvedLimit });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/mkdir')) {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/fs/home')) {
|
||||
const data = await sendBridgeMessage('api:fs/home');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/vscode/pick-files')) {
|
||||
const data = await sendBridgeMessage('api:files/pick');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/config/settings')) {
|
||||
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
||||
const settings = await sendBridgeMessage('api:config/settings:get');
|
||||
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const updated = await sendBridgeMessage('api:config/settings:save', body);
|
||||
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/config/reload')) {
|
||||
await sendBridgeMessage('api:config/reload');
|
||||
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/openchamber/models-metadata')) {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:models/metadata');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error);
|
||||
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/auth/session') {
|
||||
// VS Code host is trusted; mirror web server shape to keep UI logic happy
|
||||
const body = {
|
||||
authenticated: true,
|
||||
requireSetup: false,
|
||||
authenticatedAt: Date.now(),
|
||||
};
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/opencode/directory')) {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
|
||||
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
|
||||
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
|
||||
const pathname = targetUrl?.pathname || '';
|
||||
const normalizedPathname = pathname.replace(/\/+/, '/');
|
||||
if (targetUrl && normalizedPathname === '/health') {
|
||||
return new Response(JSON.stringify({ status: 'ok', isOpenCodeReady: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
|
||||
const localResponse = await handleLocalApiRequest(targetUrl, init);
|
||||
if (localResponse) {
|
||||
return localResponse;
|
||||
}
|
||||
|
||||
const rewritten = new URL(targetUrl.href);
|
||||
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
|
||||
const fetchTarget = `${apiBaseUrl}${rewritten.pathname}${rewritten.search}`;
|
||||
|
||||
if (input instanceof Request) {
|
||||
const cloned = input.clone();
|
||||
const requestInit: RequestInit = {
|
||||
method: method,
|
||||
headers: cloned.headers,
|
||||
body: method === 'GET' || method === 'HEAD' ? undefined : await cloned.blob(),
|
||||
};
|
||||
return originalFetch(fetchTarget, requestInit);
|
||||
}
|
||||
|
||||
return originalFetch(fetchTarget, init);
|
||||
}
|
||||
|
||||
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:models/metadata');
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error);
|
||||
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch(input as RequestInfo, init);
|
||||
};
|
||||
import('@openchamber/ui/main');
|
||||
@@ -0,0 +1,183 @@
|
||||
import { create } from 'zustand';
|
||||
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
|
||||
import type { Session, Message, Part } from '@opencode-ai/sdk';
|
||||
|
||||
const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339';
|
||||
const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
|
||||
|
||||
interface MessageRecord {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
// Client
|
||||
client: OpencodeClient | null;
|
||||
isConnected: boolean;
|
||||
|
||||
// Sessions
|
||||
sessions: Session[];
|
||||
currentSessionId: string | null;
|
||||
isLoadingSessions: boolean;
|
||||
|
||||
// Messages
|
||||
messages: Map<string, MessageRecord[]>;
|
||||
isLoadingMessages: boolean;
|
||||
isSending: boolean;
|
||||
streamingSessionId: string | null;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
loadSessions: () => Promise<void>;
|
||||
createSession: () => Promise<string | null>;
|
||||
selectSession: (sessionId: string) => Promise<void>;
|
||||
loadMessages: (sessionId: string) => Promise<void>;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
abortMessage: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
client: null,
|
||||
isConnected: false,
|
||||
sessions: [],
|
||||
currentSessionId: null,
|
||||
isLoadingSessions: false,
|
||||
messages: new Map(),
|
||||
isLoadingMessages: false,
|
||||
isSending: false,
|
||||
streamingSessionId: null,
|
||||
|
||||
initialize: async () => {
|
||||
const apiUrl = getApiUrl();
|
||||
const client = createOpencodeClient({ baseUrl: apiUrl });
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await client.session.list({ query: { directory: getWorkspaceFolder() } });
|
||||
set({ client, isConnected: true });
|
||||
await get().loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to OpenCode API:', error);
|
||||
set({ client, isConnected: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadSessions: async () => {
|
||||
const { client } = get();
|
||||
if (!client) return;
|
||||
|
||||
set({ isLoadingSessions: true });
|
||||
try {
|
||||
const response = await client.session.list({ query: { directory: getWorkspaceFolder() } });
|
||||
const sessionsArray = Array.isArray(response.data) ? response.data : [];
|
||||
const sessions = sessionsArray.sort(
|
||||
(a, b) => (b.time?.created || 0) - (a.time?.created || 0)
|
||||
);
|
||||
set({ sessions, isLoadingSessions: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
set({ isLoadingSessions: false });
|
||||
}
|
||||
},
|
||||
|
||||
createSession: async () => {
|
||||
const { client } = get();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const response = await client.session.create({ query: { directory: getWorkspaceFolder() }, body: {} });
|
||||
const session = response.data;
|
||||
if (!session) throw new Error('No session returned');
|
||||
await get().loadSessions();
|
||||
set({ currentSessionId: session.id });
|
||||
return session.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async (sessionId: string) => {
|
||||
set({ currentSessionId: sessionId });
|
||||
await get().loadMessages(sessionId);
|
||||
},
|
||||
|
||||
loadMessages: async (sessionId: string) => {
|
||||
const { client, messages } = get();
|
||||
if (!client) return;
|
||||
|
||||
set({ isLoadingMessages: true });
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
query: { directory: getWorkspaceFolder() }
|
||||
});
|
||||
const messageRecords: MessageRecord[] = (response.data || []).map((msg) => ({
|
||||
info: msg.info,
|
||||
parts: msg.parts || [],
|
||||
}));
|
||||
|
||||
const newMessages = new Map(messages);
|
||||
newMessages.set(sessionId, messageRecords);
|
||||
set({ messages: newMessages, isLoadingMessages: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
set({ isLoadingMessages: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
const { client, currentSessionId, messages } = get();
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
set({ isSending: true, streamingSessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
// Add user message optimistically
|
||||
const userMessage: MessageRecord = {
|
||||
info: {
|
||||
id: `temp-${Date.now()}`,
|
||||
sessionId: currentSessionId,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: content }],
|
||||
time: { created: Date.now() },
|
||||
} as Message,
|
||||
parts: [{ type: 'text', text: content }],
|
||||
};
|
||||
|
||||
const currentMessages = messages.get(currentSessionId) || [];
|
||||
const newMessages = new Map(messages);
|
||||
newMessages.set(currentSessionId, [...currentMessages, userMessage]);
|
||||
set({ messages: newMessages });
|
||||
|
||||
// Send message via session.prompt
|
||||
await client.session.prompt({
|
||||
path: { id: currentSessionId },
|
||||
query: { directory: getWorkspaceFolder() },
|
||||
body: {
|
||||
parts: [{ type: 'text', text: content }],
|
||||
},
|
||||
});
|
||||
|
||||
// Reload messages to get the actual response
|
||||
await get().loadMessages(currentSessionId);
|
||||
await get().loadSessions(); // Update session title if changed
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
} finally {
|
||||
set({ isSending: false, streamingSessionId: null });
|
||||
}
|
||||
},
|
||||
|
||||
abortMessage: async () => {
|
||||
const { client, currentSessionId } = get();
|
||||
if (!client || !currentSessionId) return;
|
||||
|
||||
try {
|
||||
await client.session.abort({ path: { id: currentSessionId } });
|
||||
} catch (error) {
|
||||
console.error('Failed to abort:', error);
|
||||
}
|
||||
set({ isSending: false, streamingSessionId: null });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user