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
+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 });
},
}));