ADR-007: Implement Multi-Session Architecture
Date: 2025-10-06 Status: Accepted Deciders: Development Team Tags: architecture, ux, sessions
Context​
Users need the ability to:
- Work on multiple projects simultaneously
- Switch between different llm conversations
- Keep separate editor states per session
- Maintain independent terminal contexts
- Isolate file workspaces
User Requirement: "we need tabs across the top of the main space below the header so we can have multiple sessions simultaneously"
Decision​
Implement a tab-based multi-session architecture where each session maintains:
- Independent editor tabs and content
- Separate llm conversation history
- Isolated file explorer state
- Dedicated terminal instance
- Session-specific settings
Rationale​
Why Multi-Session Tabs​
- Context Switching: Quick switching between projects/tasks
- Parallel Work: Multiple llm conversations simultaneously
- Isolation: Each session independent, no cross-contamination
- Productivity: No need to close/reopen workspaces
- Familiar UX: Browser tabs pattern everyone knows
- Persistence: Sessions saved across browser restarts
User Experience Benefits​
- Fast Switching: Cmd+1-9 shortcuts for sessions
- Visual Indicators: See which sessions have unsaved changes
- Drag & Drop: Reorder sessions
- Context Preservation: Return to exact state
- Session History: Restore closed sessions
Architecture​
Data Model​
Session Entity​
interface Session {
id: string; // Unique session ID
name: string; // User-defined name
icon?: string; // Optional icon
createdAt: Date;
updatedAt: Date;
// State references
editorState: {
tabs: editorTab[];
activeTabId: string | null;
scrollPosition: number;
};
llmState: {
messages: Message[];
primaryModel: string | null;
secondaryModel: string | null;
mode: WorkflowMode;
config: llmConfig;
};
fileState: {
workspaceRoot: string;
expandedFolders: string[];
selectedFile: string | null;
};
terminalState: {
cwd: string;
history: string[];
buffer: string;
};
// Metadata
isDirty: boolean; // Unsaved changes
isActive: boolean; // Currently active
order: number; // Tab order
}
Session Store​
interface SessionStore {
sessions: Session[];
activeSessionId: string | null;
// Actions
createSession: (name?: string) => void;
closeSession: (id: string) => void;
switchSession: (id: string) => void;
renameSession: (id: string, name: string) => void;
duplicateSession: (id: string) => void;
reorderSessions: (fromIndex: number, toIndex: number) => void;
// Persistence
saveSession: (id: string) => Promise<void>;
loadSession: (id: string) => Promise<Session>;
restoreLastSession: () => Promise<void>;
}
export const useSessionStore = create<SessionStore>()(
persist(
(set, get) => ({
sessions: [],
activeSessionId: null,
createSession: (name) => {
const newSession: Session = {
id: nanoid(),
name: name || `Session ${get().sessions.length + 1}`,
createdAt: new Date(),
updatedAt: new Date(),
editorState: { tabs: [], activeTabId: null, scrollPosition: 0 },
llmState: {
messages: [],
primaryModel: null,
secondaryModel: null,
mode: 'single',
config: defaultllmConfig
},
fileState: {
workspaceRoot: '/',
expandedFolders: [],
selectedFile: null
},
terminalState: {
cwd: '~',
history: [],
buffer: ''
},
isDirty: false,
isActive: true,
order: get().sessions.length
};
set(state => ({
sessions: [...state.sessions, newSession],
activeSessionId: newSession.id
}));
},
closeSession: (id) => {
const sessions = get().sessions.filter(s => s.id !== id);
const activeId = get().activeSessionId === id
? sessions[0]?.id || null
: get().activeSessionId;
set({ sessions, activeSessionId: activeId });
},
switchSession: (id) => {
set(state => ({
sessions: state.sessions.map(s => ({
...s,
isActive: s.id === id
})),
activeSessionId: id
}));
},
// ... other actions
}),
{
name: 'session-storage',
storage: createJSONStorage(() => fdbStorage), // Use FoundationDB
partialize: (state) => ({
sessions: state.sessions,
activeSessionId: state.activeSessionId
})
}
)
);
UI Implementation​
Session Tabs Component​
import { Tabs, TabList, Tab, IconButton, Badge } from '@chakra-ui/react';
import { FiPlus, FiX, FiEdit2 } from 'react-icons/fi';
export default function session-tabs() {
const { sessions, activeSessionId, switchSession, closeSession, createSession } =
useSessionStore();
const activeIndex = sessions.findIndex(s => s.id === activeSessionId);
return (
<Flex
h="40px"
bg="editor.sidebar"
borderBottom="1px"
borderColor="editor.border"
align="center"
px={2}
>
<Tabs
index={activeIndex}
onChange={(index) => switchSession(sessions[index].id)}
variant="unstyled"
flex={1}
>
<TabList>
{sessions.map((session) => (
<Tab
key={session.id}
position="relative"
fontSize="sm"
_selected={{
bg: 'editor.bg',
borderBottom: '2px solid',
borderColor: 'blue.500'
}}
>
<HStack spacing={2}>
{session.icon && <Text>{session.icon}</Text>}
<Text>{session.name}</Text>
{session.isDirty && (
<Badge colorScheme="orange" variant="solid" w="6px" h="6px" borderRadius="full" p={0} />
)}
</HStack>
<IconButton
icon={<FiX />}
size="xs"
variant="ghost"
ml={2}
onClick={(e) => {
e.stopPropagation();
closeSession(session.id);
}}
aria-label="Close session"
/>
</Tab>
))}
</TabList>
</Tabs>
<IconButton
icon={<FiPlus />}
size="sm"
variant="ghost"
onClick={() => createSession()}
aria-label="New session"
/>
</Flex>
);
}
Session Context Provider​
export function SessionProvider({ children }: { children: ReactNode }) {
const activeSessionId = useSessionStore(state => state.activeSessionId);
const activeSession = useSessionStore(state =>
state.sessions.find(s => s.id === activeSessionId)
);
// Auto-save session on state changes
useEffect(() => {
if (!activeSession) return;
const saveTimeout = setTimeout(() => {
useSessionStore.getState().saveSession(activeSession.id);
}, 500);
return () => clearTimeout(saveTimeout);
}, [activeSession]);
// Restore last session on mount
useEffect(() => {
useSessionStore.getState().restoreLastSession();
}, []);
if (!activeSession) {
return <EmptyState />;
}
return (
<SessionContext.Provider value={activeSession}>
{children}
</SessionContext.Provider>
);
}
Session-Aware Components​
// editor that respects current session
export function editorPanel() {
const session = useSession();
const { tabs, activeTabId } = session.editorState;
const activeTab = tabs.find(t => t.id === activeTabId);
return (
<Box h="100%">
<editorTabs tabs={tabs} activeTabId={activeTabId} />
<editor
value={activeTab?.content || ''}
language={activeTab?.language || 'plaintext'}
onChange={(content) => {
updateSessioneditorContent(session.id, activeTabId, content);
}}
/>
</Box>
);
}
// Chat panel that uses session's conversation
export function ChatPanel() {
const session = useSession();
const { messages, primaryModel, mode } = session.llmState;
return (
<Box h="100%">
<ModelSelector model={primaryModel} sessionId={session.id} />
<MessageList messages={messages} />
<ChatInput sessionId={session.id} />
</Box>
);
}
Keyboard Shortcuts​
export function useSessionShortcuts() {
useHotkeys('cmd+t', () => useSessionStore.getState().createSession());
useHotkeys('cmd+w', () => {
const activeId = useSessionStore.getState().activeSessionId;
if (activeId) useSessionStore.getState().closeSession(activeId);
});
// Switch to session 1-9
for (let i = 1; i <= 9; i++) {
useHotkeys(`cmd+${i}`, () => {
const session = useSessionStore.getState().sessions[i - 1];
if (session) useSessionStore.getState().switchSession(session.id);
});
}
// Cycle sessions
useHotkeys('cmd+shift+]', () => {
const { sessions, activeSessionId } = useSessionStore.getState();
const currentIndex = sessions.findIndex(s => s.id === activeSessionId);
const nextIndex = (currentIndex + 1) % sessions.length;
useSessionStore.getState().switchSession(sessions[nextIndex].id);
});
}
Persistence Strategy​
Save Strategy​
- Auto-save: Every 500ms after changes
- Explicit Save: Cmd+S saves current session
- On Switch: Save current before switching
- On Close: Save before closing browser
Load Strategy​
- On Mount: Restore last active session
- On Login: Load user's sessions from FoundationDB
- On Error: Fallback to OPFS cache
- On Conflict: Show merge UI
Session Management​
Session Lifecycle​
class SessionManager {
async createSession(name?: string): Promise<Session> {
const session = generateSession(name);
await fdbService.saveSession(session);
return session;
}
async closeSession(id: string): Promise<void> {
const session = await fdbService.loadSession(id);
if (session.isDirty) {
const shouldSave = await confirmSave();
if (shouldSave) {
await fdbService.saveSession(session);
}
}
await fdbService.deleteSession(id);
}
async duplicateSession(id: string): Promise<Session> {
const original = await fdbService.loadSession(id);
const duplicate = {
...original,
id: nanoid(),
name: `${original.name} (Copy)`,
createdAt: new Date()
};
await fdbService.saveSession(duplicate);
return duplicate;
}
async exportSession(id: string): Promise<Blob> {
const session = await fdbService.loadSession(id);
const json = JSON.stringify(session, null, 2);
return new Blob([json], { type: 'application/json' });
}
async importSession(file: File): Promise<Session> {
const text = await file.text();
const session = JSON.parse(text) as Session;
session.id = nanoid(); // New ID
await fdbService.saveSession(session);
return session;
}
}
Related ADRs​
- ADR-002: Use Zustand for State Management
- ADR-004: Use FoundationDB for Persistence
- ADR-006: Use OPFS for Browser Storage