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,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import {
|
||||
@@ -24,6 +26,9 @@ interface Props {
|
||||
// (MobileTabSwitcher) can share state with the pane grid.
|
||||
panesHook: UseWorkspacePanesResult;
|
||||
chatsHook: UseSessionChatsResult;
|
||||
// v1.9: passed through to SettingsPane when one is mounted in the grid.
|
||||
session: Session;
|
||||
project: Project | null;
|
||||
}
|
||||
|
||||
export function Workspace({
|
||||
@@ -33,6 +38,8 @@ export function Workspace({
|
||||
onAgentChange,
|
||||
panesHook,
|
||||
chatsHook,
|
||||
session,
|
||||
project,
|
||||
}: Props) {
|
||||
const {
|
||||
panes,
|
||||
@@ -67,6 +74,28 @@ export function Workspace({
|
||||
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// v1.9: workspace-level maximize state for the settings pane. CSS-only:
|
||||
// sibling panes get display:none, the maximized pane fills the grid cell.
|
||||
// ESC listener only mounted while maximized. Mobile is always full-width
|
||||
// for a single pane so maximize doesn't apply.
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const settingsIdx = panes.findIndex((p) => p.kind === 'settings');
|
||||
|
||||
useEffect(() => {
|
||||
if (!maximized) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setMaximized(false);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [maximized]);
|
||||
|
||||
// If the settings pane was closed (no longer in panes) while maximized,
|
||||
// clear the maximize state so the grid renders normally.
|
||||
useEffect(() => {
|
||||
if (maximized && settingsIdx < 0) setMaximized(false);
|
||||
}, [maximized, settingsIdx]);
|
||||
|
||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||
return pane.chatIds
|
||||
.map((id) => chats.find((c) => c.id === id))
|
||||
@@ -81,10 +110,12 @@ export function Workspace({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
// v1.9: settings panes excluded from the MAX cap (decision c).
|
||||
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
|
||||
'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
@@ -114,12 +145,24 @@ export function Workspace({
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
||||
: maximized && settingsIdx >= 0
|
||||
? { gridTemplateColumns: 'minmax(0, 1fr)' }
|
||||
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
{panes.map((pane, idx) => {
|
||||
const visible = !isMobile || idx === activePaneIdx;
|
||||
if (!visible) return null;
|
||||
const isSettings = pane.kind === 'settings';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
// survive the toggle without re-mount cost.
|
||||
const hiddenForMaximize = !isMobile && maximized && idx !== settingsIdx;
|
||||
const visible = (!isMobile || idx === activePaneIdx) && !hiddenForMaximize;
|
||||
if (!visible) {
|
||||
if (hiddenForMaximize) {
|
||||
return <div key={pane.id} className="hidden" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -131,19 +174,19 @@ export function Workspace({
|
||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||
)}
|
||||
onClick={() => setActivePaneIdx(idx)}
|
||||
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
>
|
||||
<div
|
||||
draggable={!isMobile && panes.length > 1}
|
||||
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
draggable={!isMobile && !isSettings && panes.length > 1}
|
||||
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
{/* Hidden on mobile per v1.8: chat-within-pane navigation
|
||||
is not exposed on small screens; users switch panes via
|
||||
the header pill instead. */}
|
||||
{!isMobile && (
|
||||
{/* Hidden on mobile per v1.8; settings panes own their own
|
||||
section nav / maximize toggle so they skip ChatTabBar
|
||||
entirely. */}
|
||||
{!isMobile && !isSettings && (
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
@@ -161,7 +204,15 @@ export function Workspace({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{pane.kind === 'chat' && pane.chatId ? (
|
||||
{isSettings && project ? (
|
||||
<SettingsPane
|
||||
session={session}
|
||||
project={project}
|
||||
maximized={maximized}
|
||||
onToggleMaximize={() => setMaximized((v) => !v)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
) : pane.kind === 'chat' && pane.chatId ? (
|
||||
<ChatPane
|
||||
sessionId={sessionId}
|
||||
chatId={pane.chatId}
|
||||
@@ -169,6 +220,7 @@ export function Workspace({
|
||||
agentId={agentId}
|
||||
onAgentChange={onAgentChange}
|
||||
sessionChats={chats}
|
||||
webSearchEnabled={session.web_search_enabled}
|
||||
/>
|
||||
) : (
|
||||
<SessionLandingPage
|
||||
|
||||
Reference in New Issue
Block a user