Adds a singleton, ephemeral 'settings' pane kind to the workspace. Opened via a new bottom-pinned button in ProjectSidebar (emits an open_settings_pane event when a session is mounted; navigates to /settings otherwise). Pane has three sections — Session, Project, Theme — and a maximize toggle that hides sibling pane columns via display:none on desktop only. Settings panes don't count toward MAX_PANES and are filtered out of the localStorage persistence layer so reload always restores a clean workspace. Schema (additive): - projects.default_system_prompt TEXT NOT NULL DEFAULT '' - projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false - sessions.web_search_enabled BOOLEAN (nullable; null = inherit) Inference resolves user_prompt = session.system_prompt.trim() || project.default_system_prompt.trim() — empty/whitespace at either layer means "no override". Keeps the columns NOT NULL and matches the existing inherit semantics. Server routes: - GET /api/projects/:id (new; settings pane refetches on project_updated) - PATCH /api/projects/:id accepts default_system_prompt, default_web_search_enabled - PATCH /api/sessions/:id accepts web_search_enabled (tri-state) - POST /api/projects/:id/sessions/archive-all + GET /api/projects/:id/sessions/open-count - POST /api/sessions/:id/chats/archive-all + GET /api/sessions/:id/chats/open-count - PATCH /api/sessions/:id now broadcasts session_updated on every successful PATCH (was rename-only). Lets SettingsPane open in another tab pick up edits without a refetch. Bulk-archive publishes one session_archived / chat_archived frame per affected id so useSidebar's existing reducer cases handle them incrementally — no new frame type, no payload widening. ModelPicker refactored: shared ModelList inside a responsive shell. Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu button + BottomSheet. Header in Session.tsx drops the pill wrap on mobile since the new trigger is the visual. ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker when sessionId + webSearchEnabled props are provided. One item for now — Web search — with a checkmark reflecting the stored value (true), not the effective one. Click PATCHes the override; to restore inherit-from-project the user opens SettingsPane. ThemePicker lifted out of pages/Settings.tsx into a reusable component. The standalone /settings route is now a thin wrapper that mounts <ThemePicker /> with a Back button on top (navigate(-1) with fallback to '/'); the SettingsPane Theme tab renders the same picker bare. Project section delete-flow removed (button + confirm dialog + handler). Replaced with "Archive all sessions" using the same two-step count → confirm → fire pattern as "Archive all chats" in the Session section. api.projects.remove() stays in the client because useProjects.ts still uses it. Hand-rolled Switch primitive in SettingsPane (no shadcn switch in the project; spec said no new deps). Section nav is plain buttons (no shadcn Tabs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { DragEvent } from 'react';
|
|
import { toast } from 'sonner';
|
|
import type { WorkspacePane } from '@/api/types';
|
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
|
|
|
export const MAX_PANES = 5;
|
|
const STORAGE_KEY = 'boocode.workspace.panes';
|
|
|
|
function generateId(): string {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
function emptyPane(): WorkspacePane {
|
|
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
|
}
|
|
|
|
function chatPane(chatId: string): WorkspacePane {
|
|
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
|
}
|
|
|
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
|
// SettingsPane component renders Session/Project sections from the
|
|
// surrounding session/project.
|
|
function settingsPane(): WorkspacePane {
|
|
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
|
}
|
|
|
|
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
|
// page reload always returns to a clean workspace; the user re-opens via the
|
|
// sidebar Settings button when needed.
|
|
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
|
return panes.filter((p) => p.kind !== 'settings');
|
|
}
|
|
|
|
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
|
// Helper used at every pane-insertion site so the rule lives in one place.
|
|
function nonSettingsCount(panes: WorkspacePane[]): number {
|
|
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
|
|
}
|
|
|
|
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
|
try {
|
|
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as WorkspacePane[];
|
|
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
|
try {
|
|
localStorage.setItem(
|
|
`${STORAGE_KEY}.${sessionId}`,
|
|
JSON.stringify(persistablePanes(panes)),
|
|
);
|
|
} catch { /* quota or disabled */ }
|
|
}
|
|
|
|
export interface UseWorkspacePanesResult {
|
|
panes: WorkspacePane[];
|
|
activePaneIdx: number;
|
|
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
|
activePaneIdxRef: React.MutableRefObject<number>;
|
|
openChatInPane: (paneIdx: number, chatId: string) => void;
|
|
switchTab: (paneIdx: number, tabIdx: number) => void;
|
|
removeTab: (paneIdx: number, chatId: string) => void;
|
|
closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
|
|
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
|
closeAllTabs: (paneIdx: number) => void;
|
|
showLandingPage: (paneIdx: number) => void;
|
|
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
|
// v1.9: idempotent open-or-focus for the settings pane singleton. Appends
|
|
// a new settings pane if none exists, otherwise just focuses the existing
|
|
// one. Always succeeds — settings panes don't count toward MAX_PANES.
|
|
openOrFocusSettingsPane: () => void;
|
|
removePane: (idx: number) => void;
|
|
removeChatFromPanes: (chatId: string) => void;
|
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
|
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
|
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
|
handlePaneDragLeave: () => void;
|
|
handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
|
handlePaneDragEnd: () => void;
|
|
dragOverIdx: number | null;
|
|
draggingIdxRef: React.MutableRefObject<number | null>;
|
|
}
|
|
|
|
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
|
return loadPanes(sessionId) ?? [emptyPane()];
|
|
});
|
|
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
|
const draggingIdxRef = useRef<number | null>(null);
|
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
savePanes(sessionId, panes);
|
|
}, [sessionId, panes]);
|
|
|
|
useEffect(() => {
|
|
const active = panes[activePaneIdx];
|
|
if (!active) {
|
|
clearActivePane();
|
|
return;
|
|
}
|
|
setActivePaneInfo({
|
|
sessionId,
|
|
paneId: active.id,
|
|
kind: active.kind,
|
|
activeFile: null,
|
|
});
|
|
}, [sessionId, panes, activePaneIdx]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearActivePane();
|
|
};
|
|
}, []);
|
|
|
|
const activePaneIdxRef = useRef(activePaneIdx);
|
|
activePaneIdxRef.current = activePaneIdx;
|
|
|
|
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
const existing = pane.chatIds.indexOf(chatId);
|
|
if (existing >= 0) {
|
|
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
|
} else {
|
|
const newIds = [...pane.chatIds, chatId];
|
|
next[paneIdx] = {
|
|
...pane,
|
|
kind: 'chat',
|
|
chatId,
|
|
chatIds: newIds,
|
|
activeChatIdx: newIds.length - 1,
|
|
};
|
|
}
|
|
return next;
|
|
});
|
|
setActivePaneIdx(paneIdx);
|
|
}, []);
|
|
|
|
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
const chatId = pane.chatIds[tabIdx];
|
|
if (!chatId) return prev;
|
|
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const removeTab = useCallback((paneIdx: number, chatId: string) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
|
if (nextIds.length === 0) {
|
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
|
} else {
|
|
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
|
next[paneIdx] = {
|
|
...pane,
|
|
chatIds: nextIds,
|
|
activeChatIdx: nextActiveIdx,
|
|
chatId: nextIds[nextActiveIdx],
|
|
};
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Keep only the right-clicked tab open in this pane.
|
|
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
|
if (keepIdx < 0) return prev;
|
|
next[paneIdx] = {
|
|
...pane,
|
|
kind: 'chat',
|
|
chatId: keepChatId,
|
|
chatIds: [keepChatId],
|
|
activeChatIdx: 0,
|
|
};
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Close every tab to the right of the right-clicked one.
|
|
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
|
|
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
|
|
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
|
|
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
|
next[paneIdx] = {
|
|
...pane,
|
|
chatIds: nextIds,
|
|
activeChatIdx: nextActiveIdx,
|
|
chatId: nextIds[nextActiveIdx],
|
|
};
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Close every tab in this pane; land on landing page.
|
|
const closeAllTabs = useCallback((paneIdx: number) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const showLandingPage = useCallback((paneIdx: number) => {
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const pane = next[paneIdx]!;
|
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
|
if (kind === 'terminal') {
|
|
toast('Terminal panes coming in BooTerm');
|
|
return;
|
|
}
|
|
if (kind === 'agent') {
|
|
toast('Agent panes coming in BooCoder');
|
|
return;
|
|
}
|
|
setPanes((prev) => {
|
|
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
|
if (nonSettingsCount(prev) >= MAX_PANES) {
|
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
|
return prev;
|
|
}
|
|
const next = [...prev, emptyPane()];
|
|
setActivePaneIdx(next.length - 1);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const openOrFocusSettingsPane = useCallback(() => {
|
|
setPanes((prev) => {
|
|
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
|
if (existingIdx >= 0) {
|
|
setActivePaneIdx(existingIdx);
|
|
return prev;
|
|
}
|
|
const next = [...prev, settingsPane()];
|
|
setActivePaneIdx(next.length - 1);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const removePane = useCallback((idx: number) => {
|
|
setPanes((prev) => {
|
|
if (prev.length <= 1) return prev;
|
|
const next = prev.filter((_, i) => i !== idx);
|
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Replaces a single empty default pane with a chat pane. Used by the initial
|
|
// chat fetch to land on the most-recent open chat if no saved pane state.
|
|
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
|
setPanes((prev) => {
|
|
if (prev.length === 1 && prev[0]!.kind === 'empty') {
|
|
return [chatPane(chatId)];
|
|
}
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
const removeChatFromPanes = useCallback((chatId: string) => {
|
|
setPanes((prev) => prev.map((p) => {
|
|
const idx = p.chatIds.indexOf(chatId);
|
|
if (idx < 0) return p;
|
|
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
|
if (nextIds.length === 0) {
|
|
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
|
}
|
|
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
|
|
return {
|
|
...p,
|
|
chatIds: nextIds,
|
|
activeChatIdx: nextActiveIdx,
|
|
chatId: nextIds[nextActiveIdx],
|
|
};
|
|
}));
|
|
}, []);
|
|
|
|
const handlePaneDragStart = useCallback(
|
|
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
|
draggingIdxRef.current = idx;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', String(idx));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handlePaneDragOver = useCallback(
|
|
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
|
if (draggingIdxRef.current === null) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
if (dragOverIdx !== idx) setDragOverIdx(idx);
|
|
},
|
|
[dragOverIdx]
|
|
);
|
|
|
|
const handlePaneDragLeave = useCallback(() => {
|
|
setDragOverIdx(null);
|
|
}, []);
|
|
|
|
const handlePaneDrop = useCallback(
|
|
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
const fromIdx = draggingIdxRef.current;
|
|
draggingIdxRef.current = null;
|
|
setDragOverIdx(null);
|
|
if (fromIdx === null || fromIdx === targetIdx) return;
|
|
setPanes((prev) => {
|
|
const next = [...prev];
|
|
const [moved] = next.splice(fromIdx, 1);
|
|
if (!moved) return prev;
|
|
next.splice(targetIdx, 0, moved);
|
|
// Keep active selection on the same logical pane (the one being dragged).
|
|
setActivePaneIdx(targetIdx);
|
|
return next;
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handlePaneDragEnd = useCallback(() => {
|
|
draggingIdxRef.current = null;
|
|
setDragOverIdx(null);
|
|
}, []);
|
|
|
|
return {
|
|
panes,
|
|
activePaneIdx,
|
|
setActivePaneIdx,
|
|
activePaneIdxRef,
|
|
openChatInPane,
|
|
openOrFocusSettingsPane,
|
|
switchTab,
|
|
removeTab,
|
|
closeOtherTabs,
|
|
closeTabsToRight,
|
|
closeAllTabs,
|
|
showLandingPage,
|
|
addSplitPane,
|
|
removePane,
|
|
removeChatFromPanes,
|
|
initializeFirstChatIfEmpty,
|
|
handlePaneDragStart,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handlePaneDragEnd,
|
|
dragOverIdx,
|
|
draggingIdxRef,
|
|
};
|
|
}
|