Workspaces in Workspaces all around

This commit is contained in:
2026-02-19 01:49:15 +00:00
parent 72bca6cd42
commit dd8ab84df5
84 changed files with 3047 additions and 749 deletions
@@ -0,0 +1,291 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
import { Trash2, Archive } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
export type SelectedSession = {
id: string;
provider: 'claude' | 'opencode';
model?: string | null;
} | null;
const CHANNEL = 'chat:selected-session';
type ChatLocationState = {
initialMessage?: string;
prefillInput?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
} | null;
type DetailBarProps = {
provider: 'claude' | 'opencode';
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
onArchive: (() => void) | undefined;
onDelete: (() => void) | undefined;
};
const DetailBar = ({
provider,
sessionTitle,
isConnected,
isGenerating,
onArchive,
onDelete,
}: DetailBarProps) => (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1">
{provider === 'claude' && onArchive && (
<button
onClick={onArchive}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal transition-colors cursor-pointer"
>
<Archive className="h-4 w-4" />
</button>
)}
{onDelete && (
<button
onClick={onDelete}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
) : isGenerating ? (
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
</div>
</div>
);
type InnerProps = {
sessionId: string;
model?: string | null;
};
const ClaudeInner = ({ sessionId, model }: InnerProps) => {
const chat = useClaude(sessionId, model, { replaceUrl: false });
const models = useVisibleClaudeModels();
const { sessions, archiveSession, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
return (
<div className="flex flex-col h-full">
<DetailBar
provider="claude"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={async () => {
await archiveSession('claude', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
onDelete={async () => {
await deleteSession('claude', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="claude" availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const OpenCodeInner = ({ sessionId, model }: InnerProps) => {
const chat = useOpenCode(sessionId, model, { replaceUrl: false });
const models = useVisibleOpenCodeModels();
const { sessions, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
return (
<div className="flex flex-col h-full">
<DetailBar
provider="opencode"
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={async () => {
await deleteSession('opencode', sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat chat={chat} provider="opencode" availableModels={models} className="flex-1 min-h-0" />
</div>
);
};
const NewClaudeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = useClaude();
const models = useVisibleClaudeModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'claude', model: chat.model });
}
}, [chat.sessionId]);
useEffect(() => {
if (!locationState || initialSentRef.current || !chat.isConnected) return;
if (locationState.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!locationState.initialMessage) return;
initialSentRef.current = true;
if (locationState.model) chat.setSelectedModel(locationState.model);
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images, locationState.cwd);
window.history.replaceState({}, '', location.pathname);
}, [chat.isConnected, location.state]);
return (
<div className="flex flex-col h-full">
<DetailBar
provider="claude"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
chat={chat}
provider="claude"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
cwd={locationState?.cwd}
className="flex-1 min-h-0"
/>
</div>
);
};
const NewOpenCodeInner = ({ onProviderChange }: { onProviderChange: (p: 'claude' | 'opencode') => void }) => {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const initialSentRef = useRef(false);
const chat = useOpenCode();
const models = useVisibleOpenCodeModels();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, provider: 'opencode', model: chat.model });
}
}, [chat.sessionId]);
useEffect(() => {
if (!locationState || initialSentRef.current || !chat.isConnected) return;
if (locationState.prefillInput) {
initialSentRef.current = true;
window.history.replaceState({}, '', location.pathname);
return;
}
if (!locationState.initialMessage) return;
initialSentRef.current = true;
if (locationState.model) chat.setSelectedModel(locationState.model);
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images);
window.history.replaceState({}, '', location.pathname);
}, [chat.isConnected, location.state]);
return (
<div className="flex flex-col h-full">
<DetailBar
provider="opencode"
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onArchive={undefined}
onDelete={undefined}
/>
<EmbeddableChat
chat={chat}
provider="opencode"
availableModels={models}
onProviderChange={onProviderChange}
defaultInput={locationState?.prefillInput ?? ''}
className="flex-1 min-h-0"
/>
</div>
);
};
type NewChatPanelProps = {
initialProvider?: 'claude' | 'opencode';
};
const NewChatPanel = ({ initialProvider = 'claude' }: NewChatPanelProps) => {
const [selected, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const provider = selected?.provider ?? initialProvider;
const handleProviderChange = (p: 'claude' | 'opencode') => {
setSelected({ id: 'new', provider: p });
};
// Once a session is created, the inner component updates selected via the channel
if (selected && selected.id !== 'new') {
return selected.provider === 'claude' ? (
<ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />
) : (
<OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />
);
}
return provider === 'claude' ? (
<NewClaudeInner key="new-claude" onProviderChange={handleProviderChange} />
) : (
<NewOpenCodeInner key="new-opencode" onProviderChange={handleProviderChange} />
);
};
export const ChatDetailPanel = () => {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (!selected) {
return (
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
Select a session to view
</div>
);
}
if (selected.id === 'new') {
return <NewChatPanel key="new" initialProvider={selected.provider} />;
}
return selected.provider === 'claude' ? (
<ClaudeInner key={selected.id} sessionId={selected.id} model={selected.model} />
) : (
<OpenCodeInner key={selected.id} sessionId={selected.id} model={selected.model} />
);
};
@@ -1,66 +1,111 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import type { SelectedSession } from './ChatDetailPanel';
type Filter = 'all' | 'claude' | 'opencode';
export const SessionList = () => {
const [filter, setFilter] = useState<Filter>('all');
const { sessions, deleteSession } = useChatSessions();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const scrolledRef = useRef(false);
const selectedRef = useCallback(
(node: HTMLDivElement | null) => {
if (node && !scrolledRef.current) {
scrolledRef.current = true;
node.scrollIntoView({ block: 'center' });
}
},
[],
);
useEffect(() => {
scrolledRef.current = false;
}, [selected?.id, selected?.provider]);
const filtered = filter === 'all' ? sessions : sessions.filter((s) => s.provider === filter);
const handleSelect = (session: (typeof sessions)[number]) => {
setSelected({ id: session.id, provider: session.provider, model: session.model ?? null });
const path = session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`;
window.history.replaceState(null, '', path);
};
const handleDelete = async (provider: 'claude' | 'opencode', id: string) => {
if (selected?.id === id && selected?.provider === provider) {
setSelected(null);
window.history.replaceState(null, '', '/chat');
}
await deleteSession(provider, id);
};
return (
<div className="flex flex-col h-full items-center p-4 md:p-6">
<Card className="w-full max-w-2xl flex flex-col gap-4 h-full p-4 md:p-6 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-duck-dark/80">Sessions</h2>
<Button asChild className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer gap-2">
<Link to="/chat/new">
<Plus className="h-4 w-4" />
New Chat
</Link>
</Button>
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex items-center gap-2">
{/* Radio filter */}
<div className="flex items-center gap-0.5 rounded-lg bg-duck-dark/5 dark:bg-foreground/5 p-0.5">
{(['all', 'claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer ${
filter === value
? 'bg-background dark:bg-foreground/10 text-duck-dark dark:text-foreground shadow-sm'
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark/80 dark:hover:text-foreground/80'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
<button
onClick={() => {
setSelected({ id: 'new', provider: 'claude' });
window.history.replaceState(null, '', '/chat/new');
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
</div>
</div>
{/* Radio filter */}
<div className="flex items-center gap-1 rounded-lg bg-white/60 p-1">
{(['all', 'claude', 'opencode'] as const).map((value) => (
<button
key={value}
onClick={() => setFilter(value)}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
filter === value ? 'bg-white text-duck-dark shadow-sm' : 'text-duck-dark/70 hover:text-duck-dark/90'
}`}
>
{value === 'all' ? 'All' : value === 'claude' ? 'Claude' : 'OpenCode'}
</button>
))}
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1.5">
{filtered.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
</div>
)}
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{filtered.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 text-sm">No sessions yet. Start a new chat!</div>
)}
{filtered.map((session) => (
{filtered.map((session) => {
const isSelected = selected?.id === session.id && selected?.provider === session.provider;
return (
<div
key={`${session.provider}-${session.id}`}
className="group flex items-center gap-3 rounded-lg border border-duck-dark/10 bg-white/80 hover:bg-white/90 transition-colors"
ref={isSelected ? selectedRef : undefined}
className={`group flex items-center gap-3 rounded-lg border transition-colors cursor-pointer ${
isSelected
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
<Link
to={session.provider === 'claude' ? `/chat/${session.id}` : `/chat/opencode/${session.id}`}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0"
<button
onClick={() => handleSelect(session)}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 truncate">{session.title}</div>
<div className="text-xs text-duck-dark/40">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
@@ -74,20 +119,22 @@ export const SessionList = () => {
>
{session.provider === 'claude' ? 'Claude' : 'OpenCode'}
</span>
<span className="ml-2 font-mono text-duck-dark/25">{session.id.slice(0, 8)}</span>
<span className="ml-2 font-mono text-duck-dark/25 dark:text-foreground/25">
{session.id.slice(0, 8)}
</span>
</div>
</div>
</Link>
</button>
<button
onClick={() => deleteSession(session.provider, session.id)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => handleDelete(session.provider, session.id)}
className="shrink-0 p-2 mr-2 text-duck-dark/20 dark:text-foreground/20 hover:text-red-500 md:opacity-0 md:group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</Card>
);
})}
</div>
</div>
);
};
@@ -1,2 +1,57 @@
import { useMemo, useEffect } from 'react';
import { useParams } from 'react-router';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from '@/state/useChatSessions';
import { appRegistry } from '../Workspaces/app-registry';
import { SessionList } from './Screen';
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
export { ChatHistory as ChatHistoryApp } from './Widget';
export { SessionList } from './Screen';
export { SessionList };
const layout: LayoutNode = {
type: 'group',
id: 'chat-history-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'chat-list', appType: null }, size: 35 },
{ node: { type: 'panel', id: 'chat-detail', appType: null }, size: 65 },
],
};
type SessionListPageProps = {
provider?: 'claude' | 'opencode';
isNew?: boolean;
};
export const SessionListPage = ({ provider = 'claude', isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
useEffect(() => {
if (isNew) {
setSelected({ id: 'new', provider });
return;
}
if (!sessionId) return;
const session = sessions.find((s) => s.id === sessionId && s.provider === provider);
setSelected({ id: sessionId, provider, model: session?.model ?? null });
}, [sessionId, provider, isNew]);
const panelComponents: PanelComponents = useMemo(
() => ({
'chat-list': SessionList,
'chat-detail': ChatDetailPanel,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};