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:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { ModelInfo } from '@/api/types';
|
||||
import {
|
||||
@@ -8,26 +8,94 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (model: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// v1.9: shared list rendered inside both shells. Lazy-fetches /api/models on
|
||||
// first open so the picker doesn't pay for a request when it's never shown.
|
||||
function ModelList({
|
||||
models,
|
||||
error,
|
||||
value,
|
||||
onPick,
|
||||
}: {
|
||||
models: ModelInfo[] | null;
|
||||
error: string | null;
|
||||
value: string;
|
||||
onPick: (id: string) => void;
|
||||
}) {
|
||||
if (error) {
|
||||
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
|
||||
}
|
||||
if (models === null) {
|
||||
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onPick(m.id)}
|
||||
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Check className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate">{m.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPicker({ value, onChange }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [models, setModels] = useState<ModelInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || models !== null) return;
|
||||
api.models()
|
||||
api
|
||||
.models()
|
||||
.then(setModels)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : 'failed to load models')
|
||||
setError(err instanceof Error ? err.message : 'failed to load models'),
|
||||
);
|
||||
}, [open, models]);
|
||||
|
||||
function handlePick(id: string) {
|
||||
setOpen(false);
|
||||
void onChange(id);
|
||||
}
|
||||
|
||||
// v1.9: mobile = icon-only trigger + bottom-sheet shell. Desktop = labeled
|
||||
// trigger (model name + chevron) + dropdown. Same ModelList under the hood.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Model: ${value}`}
|
||||
title={value}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Model">
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
<ModelList models={models} error={error} value={value} onPick={handlePick} />
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
||||
{models?.map((m) => (
|
||||
<DropdownMenuItem
|
||||
key={m.id}
|
||||
onSelect={() => void onChange(m.id)}
|
||||
onSelect={() => handlePick(m.id)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<Check
|
||||
|
||||
Reference in New Issue
Block a user