refactor: remove unused VSCode components
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user