Files
boocode/apps/web/src/hooks/useWorkspacePanes.ts
indifferentketchup 09aecc4ee9 v1.9: settings pane + per-project defaults + bulk archive + themes lift
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>
2026-05-17 17:37:29 +00:00

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,
};
}