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>
This commit is contained in:
2026-05-17 17:37:29 +00:00
parent 32c1a2b5f6
commit 09aecc4ee9
23 changed files with 1244 additions and 181 deletions

View File

@@ -62,6 +62,14 @@ export interface OpenChatInActivePaneEvent {
chat_id: string;
}
// v1.9: client-side event fired by the sidebar Settings button when a
// session is currently mounted. Session.tsx subscribes and calls
// panesHook.openOrFocusSettingsPane(). Sidebar handles the no-session case
// by navigating to /settings (themes page) directly.
export interface OpenSettingsPaneEvent {
type: 'open_settings_pane';
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
@@ -139,6 +147,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent

View File

@@ -151,6 +151,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_settings_pane':
// v1.9: consumed by Session.tsx (calls openOrFocusSettingsPane on its
// panesHook). Sidebar data is untouched.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {

View File

@@ -19,6 +19,26 @@ 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}`);
@@ -33,7 +53,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
localStorage.setItem(
`${STORAGE_KEY}.${sessionId}`,
JSON.stringify(persistablePanes(panes)),
);
} catch { /* quota or disabled */ }
}
@@ -50,6 +73,10 @@ export interface UseWorkspacePanesResult {
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;
@@ -216,7 +243,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
// 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;
}
@@ -226,6 +254,19 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
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;
@@ -318,6 +359,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
openOrFocusSettingsPane,
switchTab,
removeTab,
closeOtherTabs,