diff --git a/AGENTS.md b/AGENTS.md
index 23f61f2e..c82ab65f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -53,7 +53,7 @@ Express server and CLI in `packages/web`: API adapters in `packages/web/src/api`
Native desktop app in `packages/desktop`: Tauri backend in `src-tauri/` (Rust), frontend API adapters in `src/api/` (settings, permissions, diagnostics, files, git, terminal, notifications, tools, updater), bridge layer in `src/lib/` for Tauri IPC communication.
### VS Code Extension Runtime
-Extension in `packages/vscode`: Extension entry in `src/` (ChatViewProvider, bridge, theme), webview API adapters in `webview/api/` (bridge, editor, files, permissions, settings, tools), webview components in `webview/components/` (ChatPanel, SessionsListView, VSCodeLayout).
+Extension in `packages/vscode`: Extension entry in `src/` (ChatViewProvider, bridge, theme), webview API adapters in `webview/api/` (bridge, editor, files, permissions, settings, tools), bootstrap script in `webview/main.tsx` that loads shared UI.
## Development Commands
diff --git a/packages/vscode/webview/App.tsx b/packages/vscode/webview/App.tsx
deleted file mode 100644
index b4ed10f8..00000000
--- a/packages/vscode/webview/App.tsx
+++ /dev/null
@@ -1,283 +0,0 @@
-import React from 'react';
-import { useChatStore } from './stores/chatStore';
-import { useNavigation } from './hooks/useNavigation';
-import { OpenChamberLogo } from './components/OpenChamberLogo';
-
-type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
-
-function ConnectionStatusBanner({ status, error, onRetry }: {
- status: ConnectionStatus;
- error?: string;
- onRetry: () => void;
-}) {
- if (status === 'connected') return null;
-
- // Show full-screen logo for connecting state
- if (status === 'connecting') {
- return (
-
-
-
- );
- }
-
- const messages: Record = {
- disconnected: 'Not connected to OpenCode API',
- connecting: '',
- connected: '',
- error: error || 'Connection error',
- };
-
- return (
-
- {messages[status]}
- {(status === 'disconnected' || status === 'error') && (
-
- )}
-
- );
-}
-
-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 (
-
-
-
Sessions
-
-
-
-
- {isLoadingSessions ? (
-
Loading...
- ) : sessions.length === 0 ? (
-
-
No sessions yet
-
-
- ) : (
-
- {sessions.map((session) => (
-
- ))}
-
- )}
-
-
- );
-}
-
-function ChatPanel() {
- const { currentSessionId, sessions, messages, sendMessage, abortMessage, isSending, streamingSessionId } = useChatStore();
- const { goToSessions } = useNavigation();
- const [input, setInput] = React.useState('');
- const messagesEndRef = React.useRef(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 (
-
-
-
-
{currentSession?.title || 'New Chat'}
-
-
-
- {sessionMessages.length === 0 ? (
-
- Start a conversation
-
- ) : (
-
- {sessionMessages.map((msg, idx) => (
-
- ))}
- {isStreaming && (
-
- )}
-
-
- )}
-
-
-
-
- );
-}
-
-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 (
-
- );
-}
-
-export function VSCodeApp() {
- const { initialize, isConnected } = useChatStore();
- const { currentView } = useNavigation();
- const [status, setStatus] = React.useState('connecting');
- const [error, setError] = React.useState();
-
- 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 (
-
-
-
- {currentView === 'sessions' ? (
-
- ) : (
-
- )}
-
-
- );
-}
-
-export default VSCodeApp;
diff --git a/packages/vscode/webview/components/ChatPanel.tsx b/packages/vscode/webview/components/ChatPanel.tsx
deleted file mode 100644
index ea0b663a..00000000
--- a/packages/vscode/webview/components/ChatPanel.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import React from 'react';
-import { useSessionStore } from '@openchamber/ui/stores/useSessionStore';
-import { useConfigStore } from '@openchamber/ui/stores/useConfigStore';
-import { useNavigation } from '../hooks/useNavigation';
-import { VSCodeHeader } from './VSCodeHeader';
-import { SimpleMessageRenderer } from './SimpleMessageRenderer';
-
-export function ChatPanel() {
- const { goToSessions } = useNavigation();
- const messagesEndRef = React.useRef(null);
- const scrollContainerRef = React.useRef(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 { currentProviderId, currentModelId, currentAgentName } = useConfigStore();
-
- 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();
-
- if (!currentProviderId || !currentModelId) {
- if (import.meta.env.DEV) {
- console.warn('Missing provider or model selection for sendMessage');
- }
- return;
- }
-
- setInputValue('');
- setIsSending(true);
-
- try {
- await sendMessage(messageText, currentProviderId, currentModelId, currentAgentName);
- } catch (error) {
- console.error('Failed to send message:', error);
- setInputValue(messageText); // Restore input on error
- } finally {
- setIsSending(false);
- }
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
-
- const handleAbort = () => {
- if (currentSessionId) {
- void abortCurrentOperation();
- }
- };
-
- return (
-
- {/* Header */}
-
-
- {/* Messages */}
-
- {sessionMessages.length === 0 ? (
-
-
- Start a conversation
-
-
- ) : (
-
- {sessionMessages.map((msg) => (
-
- ))}
-
-
- )}
-
-
- {/* Input */}
-
-
- );
-}
diff --git a/packages/vscode/webview/components/OpenChamberLogo.tsx b/packages/vscode/webview/components/OpenChamberLogo.tsx
deleted file mode 100644
index 3395f514..00000000
--- a/packages/vscode/webview/components/OpenChamberLogo.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import React, { useMemo } from 'react';
-
-interface OpenChamberLogoProps {
- className?: string;
- width?: number;
- height?: number;
- isAnimated?: boolean;
-}
-
-// Generate grid cells for a face (4x4 grid)
-const generateFaceGrid = (
- topLeft: { x: number; y: number },
- topRight: { x: number; y: number },
- bottomRight: { x: number; y: number },
- bottomLeft: { x: number; y: number },
- gridSize: number = 4
-) => {
- const cells: Array<{ path: string; row: number; col: number }> = [];
-
- for (let row = 0; row < gridSize; row++) {
- for (let col = 0; col < gridSize; col++) {
- const t1 = col / gridSize;
- const t2 = (col + 1) / gridSize;
- const s1 = row / gridSize;
- const s2 = (row + 1) / gridSize;
-
- const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
- const bilinear = (tl: number, tr: number, br: number, bl: number, t: number, s: number) => {
- const top = lerp(tl, tr, t);
- const bottom = lerp(bl, br, t);
- return lerp(top, bottom, s);
- };
-
- const p1 = {
- x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t1, s1),
- y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t1, s1),
- };
- const p2 = {
- x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t2, s1),
- y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t2, s1),
- };
- const p3 = {
- x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t2, s2),
- y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t2, s2),
- };
- const p4 = {
- x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t1, s2),
- y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t1, s2),
- };
-
- cells.push({
- path: `M${p1.x} ${p1.y} L${p2.x} ${p2.y} L${p3.x} ${p3.y} L${p4.x} ${p4.y} Z`,
- row,
- col,
- });
- }
- }
-
- return cells;
-};
-
-export const OpenChamberLogo: React.FC = ({
- className = '',
- width = 70,
- height = 70,
- isAnimated = false,
-}) => {
- // VSCode uses CSS variables for theming
- const strokeColor = 'var(--vscode-foreground)';
- const fillColor = 'var(--vscode-foreground)';
- const logoFillColor = 'var(--vscode-foreground)';
- const cellHighlightColor = 'var(--vscode-foreground)';
-
- // Isometric cube geometry
- const edge = 48;
- const cos30 = 0.866;
- const sin30 = 0.5;
- const centerY = 50;
-
- const top = { x: 50, y: centerY - edge };
- const left = { x: 50 - edge * cos30, y: centerY - edge * sin30 };
- const right = { x: 50 + edge * cos30, y: centerY - edge * sin30 };
- const center = { x: 50, y: centerY };
- const bottomLeft = { x: 50 - edge * cos30, y: centerY + edge * sin30 };
- const bottomRight = { x: 50 + edge * cos30, y: centerY + edge * sin30 };
- const bottom = { x: 50, y: centerY + edge };
-
- const topFaceCenterY = (top.y + left.y + center.y + right.y) / 4;
- const isoMatrix = `matrix(0.866, 0.5, -0.866, 0.5, 50, ${topFaceCenterY})`;
-
- const leftFaceCells = generateFaceGrid(left, center, bottom, bottomLeft);
- const rightFaceCells = generateFaceGrid(center, right, bottomRight, bottom);
-
- const cellOpacities = useMemo(() => {
- const opacities: number[] = [];
- for (let i = 0; i < 32; i++) {
- opacities.push(0.1 + Math.random() * 0.5);
- }
- return opacities;
- }, []);
-
- return (
-
- );
-};
-
-export default OpenChamberLogo;
diff --git a/packages/vscode/webview/components/SessionItem.tsx b/packages/vscode/webview/components/SessionItem.tsx
deleted file mode 100644
index 24e077b0..00000000
--- a/packages/vscode/webview/components/SessionItem.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-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 (
-
- );
-}
diff --git a/packages/vscode/webview/components/SessionsListView.tsx b/packages/vscode/webview/components/SessionsListView.tsx
deleted file mode 100644
index f657216c..00000000
--- a/packages/vscode/webview/components/SessionsListView.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-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 = (
-
- );
-
- return (
-
-
-
-
- {sortedSessions.length === 0 ? (
-
-
No sessions yet
-
-
- ) : (
-
- {sortedSessions.map((session) => (
- handleSelectSession(session.id)}
- />
- ))}
-
- )}
-
-
- );
-}
diff --git a/packages/vscode/webview/components/SimpleMessageRenderer.tsx b/packages/vscode/webview/components/SimpleMessageRenderer.tsx
deleted file mode 100644
index db3adaa3..00000000
--- a/packages/vscode/webview/components/SimpleMessageRenderer.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react';
-import type { Message, Part, ToolPart } 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 is ToolPart => part.type === 'tool');
-
- return (
-
-
- {/* Role indicator for assistant */}
- {!isUser && (
-
- Assistant
-
- )}
-
- {/* Text content */}
- {textContent && (
-
{textContent}
- )}
-
- {/* Tool activity indicator */}
- {toolParts.length > 0 && (
-
- {toolParts.map((part, idx) => (
-
- ))}
-
- )}
-
- {/* Empty message placeholder */}
- {!textContent && toolParts.length === 0 && (
-
...
- )}
-
-
- );
-}
-
-function ToolPartRenderer({ part }: { part: ToolPart }) {
- const toolName = typeof part.tool === 'string' ? part.tool : 'tool';
- const status = typeof part.state?.status === 'string' ? part.state.status : 'pending';
-
- return (
-
- {status === 'running' || status === 'pending' ? (
-
- ) : status === 'completed' ? (
- ✓
- ) : (
- ✗
- )}
- {toolName}
-
- );
-}
diff --git a/packages/vscode/webview/components/VSCodeHeader.tsx b/packages/vscode/webview/components/VSCodeHeader.tsx
deleted file mode 100644
index ab6fbc7e..00000000
--- a/packages/vscode/webview/components/VSCodeHeader.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react';
-
-interface VSCodeHeaderProps {
- title: string;
- showBack?: boolean;
- onBack?: () => void;
- actions?: React.ReactNode;
-}
-
-export function VSCodeHeader({ title, showBack, onBack, actions }: VSCodeHeaderProps) {
- return (
-
- {showBack && (
-
- )}
-
{title}
- {actions &&
{actions}
}
-
- );
-}
diff --git a/packages/vscode/webview/components/VSCodeLayout.tsx b/packages/vscode/webview/components/VSCodeLayout.tsx
deleted file mode 100644
index 14b06d79..00000000
--- a/packages/vscode/webview/components/VSCodeLayout.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-import { useNavigation } from '../hooks/useNavigation';
-import { SessionsListView } from './SessionsListView';
-import { ChatPanel } from './ChatPanel';
-
-export function VSCodeLayout() {
- const { currentView } = useNavigation();
-
- return (
-
- {currentView === 'sessions' ? : }
-
- );
-}
diff --git a/packages/vscode/webview/hooks/useNavigation.ts b/packages/vscode/webview/hooks/useNavigation.ts
deleted file mode 100644
index f2c722ed..00000000
--- a/packages/vscode/webview/hooks/useNavigation.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { create } from 'zustand';
-
-export type ViewType = 'sessions' | 'chat' | 'settings';
-
-interface NavigationState {
- currentView: ViewType;
- navigateTo: (view: ViewType) => void;
- goToChat: () => void;
- goToSessions: () => void;
- goToSettings: () => void;
-}
-
-export const useNavigation = create((set) => ({
- currentView: 'sessions',
- navigateTo: (view) => set({ currentView: view }),
- goToChat: () => set({ currentView: 'chat' }),
- goToSessions: () => set({ currentView: 'sessions' }),
- goToSettings: () => set({ currentView: 'settings' }),
-}));
diff --git a/packages/vscode/webview/stores/chatStore.ts b/packages/vscode/webview/stores/chatStore.ts
deleted file mode 100644
index d19c2098..00000000
--- a/packages/vscode/webview/stores/chatStore.ts
+++ /dev/null
@@ -1,413 +0,0 @@
-import { create } from 'zustand';
-import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
-import type { Session, Message, Part } from '@opencode-ai/sdk';
-
-const getApiUrl = () => '/api';
-const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
-
-const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt';
-const AUTO_DELETE_DEFAULT_DAYS = 30;
-const AUTO_DELETE_KEEP_RECENT = 5;
-const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000;
-
-const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
-const waitForApiReady = async (client: OpencodeClient, attempts = 10, delayMs = 500): Promise => {
- for (let attempt = 0; attempt < attempts; attempt += 1) {
- try {
- await client.session.list({ query: { directory: getWorkspaceFolder() } });
- return true;
- } catch {
- await sleep(delayMs);
- }
- }
- return false;
-};
-
-const waitForSessionReady = async (client: OpencodeClient, sessionId: string, attempts = 10, delayMs = 500): Promise => {
- for (let attempt = 0; attempt < attempts; attempt += 1) {
- try {
- const response = await client.session.get({
- path: { id: sessionId },
- query: { directory: getWorkspaceFolder() },
- });
- if (response.data) {
- return true;
- }
- } catch {
- await sleep(delayMs);
- }
- }
- return false;
-};
-
-let autoDeleteRunning = false;
-
-const getLastActivity = (session: Session): number => {
- return session.time?.updated ?? session.time?.created ?? 0;
-};
-
-const readAutoDeleteLastRunAt = (): number | null => {
- if (typeof window === 'undefined') return null;
- try {
- const value = window.localStorage.getItem(AUTO_DELETE_STORAGE_KEY);
- if (!value) return null;
- const parsed = Number(value);
- return Number.isFinite(parsed) ? parsed : null;
- } catch {
- return null;
- }
-};
-
-const writeAutoDeleteLastRunAt = (timestamp: number) => {
- if (typeof window === 'undefined') return;
- try {
- window.localStorage.setItem(AUTO_DELETE_STORAGE_KEY, String(timestamp));
- } catch {
- // ignore storage errors
- }
-};
-
-const buildAutoDeleteCandidates = (
- sessions: Session[],
- currentSessionId: string | null,
- cutoffDays: number,
- now = Date.now()
-): string[] => {
- if (!Array.isArray(sessions) || cutoffDays <= 0) {
- return [];
- }
-
- const cutoffTime = now - cutoffDays * 24 * 60 * 60 * 1000;
- const sorted = [...sessions].sort((a, b) => getLastActivity(b) - getLastActivity(a));
- const protectedIds = new Set(sorted.slice(0, AUTO_DELETE_KEEP_RECENT).map((session) => session.id));
-
- return sorted
- .filter((session) => {
- if (!session?.id) return false;
- if (protectedIds.has(session.id)) return false;
- if (session.id === currentSessionId) return false;
- if (session.share) return false;
- const lastActivity = getLastActivity(session);
- if (!lastActivity) return false;
- return lastActivity < cutoffTime;
- })
- .map((session) => session.id);
-};
-
-interface MessageRecord {
- info: Message;
- parts: Part[];
-}
-
-interface ChatState {
- // Client
- client: OpencodeClient | null;
- isConnected: boolean;
-
- // Sessions
- sessions: Session[];
- currentSessionId: string | null;
- isLoadingSessions: boolean;
- autoDeleteEnabled: boolean;
- autoDeleteAfterDays: number;
- autoDeleteLastRunAt: number | null;
-
- // Messages
- messages: Map;
- isLoadingMessages: boolean;
- isSending: boolean;
- streamingSessionId: string | null;
-
- // Actions
- initialize: () => Promise;
- loadAutoDeleteSettings: () => Promise;
- runAutoCleanup: (sessionsOverride?: Session[]) => Promise;
- loadSessions: () => Promise;
- createSession: () => Promise;
- selectSession: (sessionId: string) => Promise;
- loadMessages: (sessionId: string) => Promise;
- sendMessage: (content: string) => Promise;
- abortMessage: () => Promise;
-}
-
-export const useChatStore = create((set, get) => ({
- client: null,
- isConnected: false,
- sessions: [],
- currentSessionId: null,
- isLoadingSessions: false,
- autoDeleteEnabled: false,
- autoDeleteAfterDays: AUTO_DELETE_DEFAULT_DAYS,
- autoDeleteLastRunAt: readAutoDeleteLastRunAt(),
- 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().loadAutoDeleteSettings();
- await get().loadSessions();
- } catch (error) {
- console.error('Failed to connect to OpenCode API:', error);
- set({ client, isConnected: false });
- }
- },
-
- loadAutoDeleteSettings: async () => {
- try {
- const response = await fetch('/api/config/settings', {
- method: 'GET',
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- const lastRunAt = readAutoDeleteLastRunAt();
- set({ autoDeleteLastRunAt: lastRunAt });
- return;
- }
-
- const payload = await response.json().catch(() => ({}));
- const enabled = typeof payload.autoDeleteEnabled === 'boolean' ? payload.autoDeleteEnabled : false;
- const daysRaw = typeof payload.autoDeleteAfterDays === 'number'
- ? payload.autoDeleteAfterDays
- : Number(payload.autoDeleteAfterDays);
- const normalizedDays = Number.isFinite(daysRaw)
- ? Math.max(1, Math.min(365, daysRaw))
- : AUTO_DELETE_DEFAULT_DAYS;
- const lastRunAt = readAutoDeleteLastRunAt();
-
- set({
- autoDeleteEnabled: enabled,
- autoDeleteAfterDays: normalizedDays,
- autoDeleteLastRunAt: lastRunAt,
- });
- } catch {
- const lastRunAt = readAutoDeleteLastRunAt();
- set({ autoDeleteLastRunAt: lastRunAt });
- }
- },
-
- runAutoCleanup: async (sessionsOverride) => {
- const { client, autoDeleteEnabled, autoDeleteAfterDays, currentSessionId } = get();
- if (!client || !autoDeleteEnabled || autoDeleteAfterDays <= 0) {
- return;
- }
- if (autoDeleteRunning) {
- return;
- }
-
- const now = Date.now();
- const lastRunAt = readAutoDeleteLastRunAt();
- if (lastRunAt && now - lastRunAt < AUTO_DELETE_INTERVAL_MS) {
- set({ autoDeleteLastRunAt: lastRunAt });
- return;
- }
-
- const sessions = sessionsOverride ?? get().sessions;
- if (!sessions.length) {
- return;
- }
-
- const candidateIds = buildAutoDeleteCandidates(sessions, currentSessionId, autoDeleteAfterDays, now);
- if (candidateIds.length === 0) {
- writeAutoDeleteLastRunAt(now);
- set({ autoDeleteLastRunAt: now });
- return;
- }
-
- autoDeleteRunning = true;
- const deletedIds: string[] = [];
-
- try {
- for (const id of candidateIds) {
- try {
- const response = await client.session.delete({
- path: { id },
- query: { directory: getWorkspaceFolder() },
- });
- if (response.data) {
- deletedIds.push(id);
- }
- } catch {
- // ignore individual delete failures
- }
- }
- } finally {
- autoDeleteRunning = false;
- const finishedAt = Date.now();
- writeAutoDeleteLastRunAt(finishedAt);
- set({ autoDeleteLastRunAt: finishedAt });
- }
-
- if (deletedIds.length > 0) {
- set((state) => ({
- sessions: state.sessions.filter((session) => !deletedIds.includes(session.id)),
- }));
- }
- },
-
- 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 });
- void get().runAutoCleanup(sessions);
- } catch (error) {
- console.error('Failed to load sessions:', error);
- set({ isLoadingSessions: false });
- }
- },
-
- createSession: async () => {
- const { client } = get();
- if (!client) return null;
-
- try {
- const apiReady = await waitForApiReady(client);
- if (!apiReady) {
- throw new Error('OpenCode API not ready');
- }
-
- const response = await client.session.create({ query: { directory: getWorkspaceFolder() }, body: {} });
- const session = response.data;
- if (!session) throw new Error('No session returned');
- const sessionReady = await waitForSessionReady(client, session.id);
- if (!sessionReady) {
- console.warn('Session created but not ready yet; continuing');
- }
- 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 {
- const apiReady = await waitForApiReady(client);
- if (!apiReady) {
- throw new Error('OpenCode API not ready');
- }
-
- const sessionReady = await waitForSessionReady(client, currentSessionId);
- if (!sessionReady) {
- console.warn('Session not ready yet; attempting to prompt anyway');
- }
-
- // Add user message optimistically
- const messageId = `temp-${Date.now()}`;
- const partId = `part-${messageId}`;
- const userPart: Part = {
- id: partId,
- sessionID: currentSessionId,
- messageID: messageId,
- type: 'text',
- text: content,
- } as Part;
- const userMessage: MessageRecord = {
- info: {
- id: messageId,
- sessionID: currentSessionId,
- role: 'user',
- time: { created: Date.now() },
- } as Message,
- parts: [userPart],
- };
-
- const currentMessages = messages.get(currentSessionId) || [];
- const newMessages = new Map(messages);
- newMessages.set(currentSessionId, [...currentMessages, userMessage]);
- set({ messages: newMessages });
-
- const sendPrompt = () => client.session.prompt({
- path: { id: currentSessionId },
- query: { directory: getWorkspaceFolder() },
- body: {
- parts: [{ type: 'text', text: content }],
- },
- });
-
- try {
- await sendPrompt();
- } catch (error) {
- const recovered = await waitForSessionReady(client, currentSessionId, 6, 500);
- if (recovered) {
- await sendPrompt();
- } else {
- throw error;
- }
- }
-
- // 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 });
- },
-}));