Files
boocode/apps/web/src/components/panes/SettingsPane.tsx
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

521 lines
18 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Archive, Maximize2, Minimize2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ModelPicker } from '@/components/ModelPicker';
import { ThemePicker } from '@/components/ThemePicker';
import { cn } from '@/lib/utils';
type Section = 'session' | 'project' | 'theme';
interface Props {
session: Session;
project: Project;
maximized: boolean;
onToggleMaximize: () => void;
isMobile: boolean;
}
// v1.9: hand-rolled Switch primitive. No shadcn switch in the existing
// ui/ set and the dispatch said don't pnpm dlx for v1.9 either. Single
// purpose — clicking flips aria-checked + calls onCheckedChange.
function Switch({
checked,
onCheckedChange,
disabled,
id,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
id?: string;
}) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform',
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5',
)}
/>
</button>
);
}
export function SettingsPane({ session, project, maximized, onToggleMaximize, isMobile }: Props) {
const [activeSection, setActiveSection] = useState<Section>('session');
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(['session', 'project', 'theme'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setActiveSection(s)}
className={cn(
'text-xs px-2 py-1 rounded capitalize',
activeSection === s
? 'bg-background text-foreground'
: 'text-muted-foreground hover:bg-muted',
)}
>
{s}
</button>
))}
</div>
{!isMobile && (
<button
type="button"
onClick={onToggleMaximize}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={maximized ? 'Restore' : 'Maximize'}
title={maximized ? 'Restore (Esc)' : 'Maximize'}
>
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-[720px] mx-auto w-full px-4 py-4 space-y-6">
{activeSection === 'session' && <SessionSection session={session} project={project} />}
{activeSection === 'project' && <ProjectSection project={project} />}
{activeSection === 'theme' && <ThemePicker />}
</div>
</div>
</div>
);
}
function SessionSection({ session, project }: { session: Session; project: Project }) {
const [name, setName] = useState(session.name);
const [systemPrompt, setSystemPrompt] = useState(session.system_prompt);
// v1.9: tri-state on the wire (null = inherit). UI surfaces a 3-way toggle
// via "Inherit project default" checkbox plus the override switch.
const [webSearch, setWebSearch] = useState<boolean | null>(session.web_search_enabled);
const [saving, setSaving] = useState(false);
// v1.9: bulk-archive chats. Two-step: openChatsCount → confirm dialog →
// archiveAllChats. Server publishes one chat_archived frame per id so
// useSidebar / chat lists update incrementally.
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveCount, setArchiveCount] = useState(0);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
setName(session.name);
setSystemPrompt(session.system_prompt);
setWebSearch(session.web_search_enabled);
}, [session.id, session.name, session.system_prompt, session.web_search_enabled]);
const dirty =
name !== session.name ||
systemPrompt !== session.system_prompt ||
webSearch !== session.web_search_enabled;
const effectiveWebSearch = webSearch ?? project.default_web_search_enabled;
const projectPreview = project.default_system_prompt.trim().slice(0, 200);
async function save() {
if (saving) return;
setSaving(true);
try {
await api.sessions.update(session.id, {
name: name.trim() || session.name,
system_prompt: systemPrompt,
web_search_enabled: webSearch,
});
toast.success('Session saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function resetSystemPrompt() {
if (saving) return;
setSaving(true);
try {
await api.sessions.update(session.id, { system_prompt: '' });
toast.success('Reset to project default');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'reset failed');
} finally {
setSaving(false);
}
}
async function openArchiveDialog() {
if (archiving) return;
try {
const { count } = await api.sessions.openChatsCount(session.id);
if (count === 0) {
toast('No open chats to archive.');
return;
}
setArchiveCount(count);
setArchiveOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to count chats');
}
}
async function confirmArchive() {
if (archiving) return;
setArchiving(true);
try {
const { archived } = await api.sessions.archiveAllChats(session.id);
toast.success(`Archived ${archived} chat${archived === 1 ? '' : 's'}`);
setArchiveOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'archive failed');
} finally {
setArchiving(false);
}
}
return (
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Model
</label>
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
try {
await api.sessions.update(session.id, { model });
toast.success('Model updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to set model');
}
}}
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="session-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Web search
</label>
<Switch
id="session-web-search"
checked={effectiveWebSearch}
onCheckedChange={(v) => setWebSearch(v)}
/>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
id="session-web-search-inherit"
checked={webSearch === null}
onChange={(e) => setWebSearch(e.target.checked ? null : project.default_web_search_enabled)}
/>
<label htmlFor="session-web-search-inherit" className="cursor-pointer">
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
</label>
</div>
<p className="text-xs text-muted-foreground italic">
Plumbed for Batch 8 (web_search tool). No effect yet.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
System prompt
</label>
<button
type="button"
onClick={() => void resetSystemPrompt()}
disabled={saving || session.system_prompt === ''}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
>
Reset to project default
</button>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={6}
className="resize-y min-h-[120px] max-h-[60vh]"
placeholder="Per-session override (optional). Empty = inherit project default."
/>
{systemPrompt.trim().length === 0 && projectPreview.length > 0 && (
<p className="text-xs text-muted-foreground">
Falls back to project default: <span className="italic">{projectPreview}{projectPreview.length === 200 ? '…' : ''}</span>
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<div className="border-t pt-4">
<Button
variant="outline"
onClick={() => void openArchiveDialog()}
disabled={archiving}
className="gap-1.5"
>
<Archive size={14} /> Archive all chats
</Button>
</div>
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive all chats?</DialogTitle>
<DialogDescription>
Archive {archiveCount} open chat{archiveCount === 1 ? '' : 's'} in this session?
Archived chats stay accessible via the archive view.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
Cancel
</Button>
<Button onClick={() => void confirmArchive()} disabled={archiving}>
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ProjectSection({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
const [defaultWebSearch, setDefaultWebSearch] = useState(project.default_web_search_enabled);
const [saving, setSaving] = useState(false);
// v1.9: bulk-archive sessions. Same shape as the chats-archive flow in
// SessionSection — count, confirm, fire.
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveCount, setArchiveCount] = useState(0);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
setName(project.name);
setDefaultPrompt(project.default_system_prompt);
setDefaultWebSearch(project.default_web_search_enabled);
}, [
project.id,
project.name,
project.default_system_prompt,
project.default_web_search_enabled,
]);
const dirty =
name !== project.name ||
defaultPrompt !== project.default_system_prompt ||
defaultWebSearch !== project.default_web_search_enabled;
async function save() {
if (saving) return;
setSaving(true);
try {
await api.projects.update(project.id, {
name: name.trim() || project.name,
default_system_prompt: defaultPrompt,
default_web_search_enabled: defaultWebSearch,
});
toast.success('Project saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function clearDefaultPrompt() {
if (saving) return;
setSaving(true);
try {
await api.projects.update(project.id, { default_system_prompt: '' });
toast.success('Cleared');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'clear failed');
} finally {
setSaving(false);
}
}
async function openArchiveDialog() {
if (archiving) return;
try {
const { count } = await api.projects.openSessionsCount(project.id);
if (count === 0) {
toast('No open sessions to archive.');
return;
}
setArchiveCount(count);
setArchiveOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to count sessions');
}
}
async function confirmArchive() {
if (archiving) return;
setArchiving(true);
try {
const { archived } = await api.projects.archiveAllSessions(project.id);
toast.success(`Archived ${archived} session${archived === 1 ? '' : 's'}`);
setArchiveOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'archive failed');
} finally {
setArchiving(false);
}
}
return (
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Project name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Root path
</label>
<div className="font-mono text-xs text-muted-foreground bg-muted/40 rounded px-2 py-1.5 select-all">
{project.path}
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="project-default-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Default web search
</label>
<Switch
id="project-default-web-search"
checked={defaultWebSearch}
onCheckedChange={setDefaultWebSearch}
/>
</div>
<p className="text-xs text-muted-foreground italic">
Applies to new sessions only. Plumbed for Batch 8.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Default system prompt
</label>
<button
type="button"
onClick={() => void clearDefaultPrompt()}
disabled={saving || project.default_system_prompt === ''}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear
</button>
</div>
<Textarea
value={defaultPrompt}
onChange={(e) => setDefaultPrompt(e.target.value)}
rows={6}
className="resize-y min-h-[120px] max-h-[60vh]"
placeholder="Prepended to every new session's system prompt (when its own is empty). Empty = no project default."
/>
</div>
<p className="text-xs text-muted-foreground">
Existing sessions are not affected by changes here.
</p>
<div className="flex justify-end gap-2">
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<div className="border-t pt-4">
<Button
variant="outline"
onClick={() => void openArchiveDialog()}
disabled={archiving}
className="gap-1.5"
>
<Archive size={14} /> Archive all sessions
</Button>
</div>
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive all sessions?</DialogTitle>
<DialogDescription>
Archive {archiveCount} open session{archiveCount === 1 ? '' : 's'} in this project?
Archived sessions stay accessible via the archive view.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
Cancel
</Button>
<Button onClick={() => void confirmArchive()} disabled={archiving}>
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}