refactor: remove unused VSCode components

This commit is contained in:
Bohdan Triapitsyn
2025-12-30 17:36:52 +02:00
parent 4c4e3eed99
commit 2ad59c4ad0
11 changed files with 1 additions and 1324 deletions
+1 -1
View File
@@ -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
-283
View File
@@ -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 (
<div className="flex flex-col items-center justify-center h-full gap-4">
<OpenChamberLogo width={80} height={80} isAnimated />
</div>
);
}
const messages: Record<ConnectionStatus, string> = {
disconnected: 'Not connected to OpenCode API',
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'
}`}>
<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;
@@ -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<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 { 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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleAbort = () => {
if (currentSessionId) {
void abortCurrentOperation();
}
};
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>
);
}
@@ -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<OpenChamberLogoProps> = ({
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 (
<svg
width={width}
height={height}
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
role="img"
aria-label="OpenChamber logo"
>
{/* Left face */}
<path
d={`M${center.x} ${center.y} L${left.x} ${left.y} L${bottomLeft.x} ${bottomLeft.y} L${bottom.x} ${bottom.y} Z`}
fill={fillColor}
fillOpacity="0.15"
stroke={strokeColor}
strokeWidth="2"
strokeLinejoin="round"
/>
{/* Left face grid cells */}
{leftFaceCells.map((cell, i) => (
<path
key={`left-${i}`}
d={cell.path}
fill={cellHighlightColor}
fillOpacity={0.35 * cellOpacities[i]}
/>
))}
{/* Right face */}
<path
d={`M${center.x} ${center.y} L${right.x} ${right.y} L${bottomRight.x} ${bottomRight.y} L${bottom.x} ${bottom.y} Z`}
fill={fillColor}
fillOpacity="0.15"
stroke={strokeColor}
strokeWidth="2"
strokeLinejoin="round"
/>
{/* Right face grid cells */}
{rightFaceCells.map((cell, i) => (
<path
key={`right-${i}`}
d={cell.path}
fill={cellHighlightColor}
fillOpacity={0.35 * cellOpacities[i + 16]}
/>
))}
{/* Top face - open */}
<path
d={`M${top.x} ${top.y} L${left.x} ${left.y} L${center.x} ${center.y} L${right.x} ${right.y} Z`}
fill="none"
stroke={strokeColor}
strokeWidth="2"
strokeLinejoin="round"
/>
{/* OpenCode logo on top face */}
<g opacity={isAnimated ? undefined : 1}>
{isAnimated && (
<animate
attributeName="opacity"
values="0.4;1;0.4"
dur="3s"
repeatCount="indefinite"
calcMode="spline"
keySplines="0.4 0 0.6 1; 0.4 0 0.6 1"
/>
)}
<g transform={`${isoMatrix} scale(0.75)`}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z"
fill={logoFillColor}
/>
<path
d="M-8 -4 L8 -4 L8 12 L-8 12 Z"
fill={logoFillColor}
fillOpacity="0.4"
/>
</g>
</g>
</svg>
);
};
export default OpenChamberLogo;
@@ -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 (
<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>
);
}
@@ -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 = (
<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>
);
}
@@ -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 (
<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: ToolPart }) {
const toolName = typeof part.tool === 'string' ? part.tool : 'tool';
const status = typeof part.state?.status === 'string' ? part.state.status : 'pending';
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{status === 'running' || status === 'pending' ? (
<span className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : status === 'completed' ? (
<span className="text-green-500"></span>
) : (
<span className="text-red-500"></span>
)}
<span className="font-mono">{toolName}</span>
</div>
);
}
@@ -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 (
<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>
);
}
@@ -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 (
<div className="h-full w-full bg-background text-foreground">
{currentView === 'sessions' ? <SessionsListView /> : <ChatPanel />}
</div>
);
}
@@ -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<NavigationState>((set) => ({
currentView: 'sessions',
navigateTo: (view) => set({ currentView: view }),
goToChat: () => set({ currentView: 'chat' }),
goToSessions: () => set({ currentView: 'sessions' }),
goToSettings: () => set({ currentView: 'settings' }),
}));
-413
View File
@@ -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<boolean> => {
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<boolean> => {
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<string, MessageRecord[]>;
isLoadingMessages: boolean;
isSending: boolean;
streamingSessionId: string | null;
// Actions
initialize: () => Promise<void>;
loadAutoDeleteSettings: () => Promise<void>;
runAutoCleanup: (sessionsOverride?: Session[]) => 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,
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 });
},
}));