From e09c67d65ce7aeaec2f20a2ce3953469ffbe3f2f Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 03:21:26 +0000 Subject: [PATCH] tab-close + chat archive/delete + landing-card buttons + 1000px content cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 — Tab close menu (pure local pane state, no API): - ChatTabBar context menu: Rename / sep / Close / Close others / Close to right / Close all - Workspace bulk-tab primitives: closeOtherTabs, closeTabsToRight, closeAllTabs (manipulate panes[].chatIds, no fetch) - Drop in-bar Delete; landing card's name-typed Delete is the canonical destructive path Feature 2 — Chat archive + delete: - chats.status vocabulary aligned with projects ('open' | 'archived'); DROP old inline CHECK, UPDATE 'closed' → 'archived', ADD new named chats_status_chk - POST /api/chats/:id/archive (204) + POST /api/chats/:id/unarchive (200) + GET /api/sessions/:id/chats?status=archived; DELETE publishes chat_deleted; PATCH simplified to name-only - 3 new WS frames: chat_archived, chat_unarchived, chat_deleted (renamed from chat_closed) - Same dedup discipline: server-only publish, no local sessionEvents.emit in client - SessionLandingPage: right-click ContextMenu (Open / Rename / Archive / sep / Delete-destructive), inline rename, archive confirm dialog, delete dialog with name-typed Input gated until typed text === chat.name, Archived chats collapsible section with Restore - Card-level Archive + Delete icon buttons reusing the same dialog state setters; stopPropagation on both so card click still opens the chat; archived cards keep only Restore UX — chat content width cap: - ChatPane content (MessageList, queue chips, stop button, ChatInput) wrapped in inner max-w-[1000px] mx-auto w-full so messages center; outer border-t / scroll containers stay full-width so pane chrome and backgrounds remain edge-to-edge - No new deps, no media queries (narrow viewports collapse to width naturally) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/src/routes/chats.ts | 89 ++++-- apps/server/src/schema.sql | 16 +- apps/server/src/types/api.ts | 21 +- apps/web/src/api/client.ts | 12 +- apps/web/src/api/types.ts | 3 +- apps/web/src/components/ChatInput.tsx | 2 + apps/web/src/components/ChatTabBar.tsx | 68 ++--- apps/web/src/components/MessageList.tsx | 12 +- .../web/src/components/SessionLandingPage.tsx | 264 ++++++++++++++++-- apps/web/src/components/Workspace.tsx | 96 ++++++- apps/web/src/components/panes/ChatPane.tsx | 8 +- apps/web/src/hooks/sessionEvents.ts | 19 +- apps/web/src/hooks/useSidebar.ts | 4 +- 13 files changed, 475 insertions(+), 139 deletions(-) diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts index 2e3b7d1..0ba7c86 100644 --- a/apps/server/src/routes/chats.ts +++ b/apps/server/src/routes/chats.ts @@ -9,8 +9,7 @@ const CreateBody = z.object({ }); const PatchBody = z.object({ - name: z.string().min(1).max(200).optional(), - status: z.enum(['open', 'closed']).optional(), + name: z.string().min(1).max(200), }); export function registerChatRoutes( @@ -18,7 +17,7 @@ export function registerChatRoutes( sql: Sql, broker: Broker ): void { - app.get<{ Params: { id: string } }>( + app.get<{ Params: { id: string }; Querystring: { status?: string } }>( '/api/sessions/:id/chats', async (req, reply) => { const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; @@ -26,10 +25,8 @@ export function registerChatRoutes( reply.code(404); return { error: 'session not found' }; } + const status = req.query.status === 'archived' ? 'archived' : 'open'; // Enriched list: computed per-chat fields via LATERAL joins. - // `effective_context_tokens` = ctx_used (prompt tokens) on the most - // recent complete assistant message — represents the current context - // window consumption post-compact. const rows = await sql` SELECT c.id, c.session_id, c.name, c.status, c.created_at, c.updated_at, @@ -55,7 +52,7 @@ export function registerChatRoutes( ORDER BY created_at DESC LIMIT 1 ) ec ON TRUE - WHERE c.session_id = ${req.params.id} + WHERE c.session_id = ${req.params.id} AND c.status = ${status} ORDER BY c.updated_at DESC `; return rows; @@ -98,17 +95,10 @@ export function registerChatRoutes( reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } - const { name, status } = parsed.data; - if (name === undefined && status === undefined) { - reply.code(400); - return { error: 'must provide name or status' }; - } const rows = await sql` UPDATE chats - SET - name = COALESCE(${name ?? null}, name), - status = COALESCE(${status ?? null}, status), - updated_at = clock_timestamp() + SET name = ${parsed.data.name}, + updated_at = clock_timestamp() WHERE id = ${req.params.id} RETURNING id, session_id, name, status, created_at, updated_at `; @@ -117,21 +107,54 @@ export function registerChatRoutes( return { error: 'chat not found' }; } const chat = rows[0]!; - if (status === 'closed') { - broker.publishUser('default', { - type: 'chat_closed', - chat_id: chat.id, - session_id: chat.session_id, - }); - } else { - broker.publishUser('default', { - type: 'chat_updated', - chat_id: chat.id, - session_id: chat.session_id, - name: chat.name, - updated_at: chat.updated_at, - }); + broker.publishUser('default', { + type: 'chat_updated', + chat_id: chat.id, + session_id: chat.session_id, + name: chat.name, + updated_at: chat.updated_at, + }); + return chat; + } + ); + + app.post<{ Params: { id: string } }>( + '/api/chats/:id/archive', + async (req, reply) => { + const rows = await sql<{ id: string; session_id: string }[]>` + UPDATE chats SET status = 'archived', updated_at = clock_timestamp() + WHERE id = ${req.params.id} AND status = 'open' + RETURNING id, session_id + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'chat not found or already archived' }; } + const row = rows[0]!; + broker.publishUser('default', { + type: 'chat_archived', + chat_id: row.id, + session_id: row.session_id, + }); + reply.code(204); + return null; + } + ); + + app.post<{ Params: { id: string } }>( + '/api/chats/:id/unarchive', + async (req, reply) => { + const rows = await sql` + UPDATE chats SET status = 'open', updated_at = clock_timestamp() + WHERE id = ${req.params.id} AND status = 'archived' + RETURNING id, session_id, name, status, created_at, updated_at + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'chat not found or not archived' }; + } + const chat = rows[0]!; + broker.publishUser('default', { type: 'chat_unarchived', chat }); return chat; } ); @@ -147,6 +170,12 @@ export function registerChatRoutes( reply.code(404); return { error: 'chat not found' }; } + const row = result[0]!; + broker.publishUser('default', { + type: 'chat_deleted', + chat_id: row.id, + session_id: row.session_id, + }); reply.code(204); return null; } diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index ce197eb..a11d754 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -77,7 +77,7 @@ CREATE TABLE IF NOT EXISTS chats ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, name TEXT, - status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'archived')), created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() ); @@ -144,3 +144,17 @@ BEGIN CHECK (status IN ('open', 'archived')); END IF; END $$; + +-- v1.3-tab-close-chat-archive: align chats.status vocabulary with projects ('archived' not 'closed') +-- KEEP IN SYNC: apps/server/src/types/api.ts CHAT_STATUSES +-- Order matters: (1) drop the OLD inline CHECK that only allowed ('open','closed'); +-- (2) migrate existing rows; (3) add new named CHECK allowing ('open','archived'). +ALTER TABLE chats DROP CONSTRAINT IF EXISTS chats_status_check; +UPDATE chats SET status = 'archived' WHERE status = 'closed'; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chats_status_chk') THEN + ALTER TABLE chats ADD CONSTRAINT chats_status_chk + CHECK (status IN ('open', 'archived')); + END IF; +END $$; diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index 398a8d7..e3b8ac8 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -30,7 +30,9 @@ export interface Session { updated_at: string; } -export type ChatStatus = 'open' | 'closed'; +// KEEP IN SYNC: apps/server/src/schema.sql chats_status_chk +export const CHAT_STATUSES = ['open', 'archived'] as const; +export type ChatStatus = typeof CHAT_STATUSES[number]; export interface Chat { id: string; @@ -191,8 +193,17 @@ export interface ChatUpdatedFrame { name: string | null; updated_at: string; } -export interface ChatClosedFrame { - type: 'chat_closed'; +export interface ChatArchivedFrame { + type: 'chat_archived'; + chat_id: string; + session_id: string; +} +export interface ChatUnarchivedFrame { + type: 'chat_unarchived'; + chat: Chat; +} +export interface ChatDeletedFrame { + type: 'chat_deleted'; chat_id: string; session_id: string; } @@ -218,7 +229,9 @@ export type UserStreamFrame = | SessionArchivedFrame | ChatCreatedFrame | ChatUpdatedFrame - | ChatClosedFrame + | ChatArchivedFrame + | ChatUnarchivedFrame + | ChatDeletedFrame | ProjectArchivedFrame | ProjectUnarchivedFrame | ProjectUpdatedFrame; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3aaec43..d5a9b2a 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -117,18 +117,24 @@ export const api = { }, chats: { - listForSession: (sessionId: string) => - request(`/api/sessions/${sessionId}/chats`), + listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) => + request( + `/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}` + ), create: (sessionId: string, body?: { name?: string }) => request(`/api/sessions/${sessionId}/chats`, { method: 'POST', body: JSON.stringify(body ?? {}), }), - update: (chatId: string, body: { name?: string; status?: 'open' | 'closed' }) => + update: (chatId: string, body: { name: string }) => request(`/api/chats/${chatId}`, { method: 'PATCH', body: JSON.stringify(body), }), + archive: (chatId: string) => + request(`/api/chats/${chatId}/archive`, { method: 'POST' }), + unarchive: (chatId: string) => + request(`/api/chats/${chatId}/unarchive`, { method: 'POST' }), remove: (chatId: string) => request(`/api/chats/${chatId}`, { method: 'DELETE' }), messages: (chatId: string) => diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index e700951..f56b379 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -29,7 +29,8 @@ export interface Session { updated_at: string; } -export type ChatStatus = 'open' | 'closed'; +export const CHAT_STATUSES = ['open', 'archived'] as const; +export type ChatStatus = typeof CHAT_STATUSES[number]; export interface Chat { id: string; diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index ad3107c..22baf48 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -220,6 +220,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) { return (
+
{attachments.length > 0 && (
{attachments.map(a => ( @@ -252,6 +253,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
+
setPreviewAttachment(null)} diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index 16cc0e1..7e9b124 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -8,14 +8,6 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; interface Props { @@ -23,11 +15,12 @@ interface Props { tabs: Chat[]; onSwitchTab: (tabIdx: number) => void; onRemoveTab: (chatId: string) => void; + onCloseOthers: (chatId: string) => void; + onCloseToRight: (chatId: string) => void; + onCloseAll: () => void; onNewChat: () => void; onShowHistory: () => void; onRename: (chatId: string, name: string) => Promise; - onClose: (chatId: string) => Promise; - onDelete: (chatId: string) => Promise; onRemovePane?: () => void; } @@ -36,16 +29,16 @@ export function ChatTabBar({ tabs, onSwitchTab, onRemoveTab, + onCloseOthers, + onCloseToRight, + onCloseAll, onNewChat, onShowHistory, onRename, - onClose, - onDelete, onRemovePane, }: Props) { const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); - const [deleteConfirm, setDeleteConfirm] = useState(null); function startRename(chatId: string, currentName: string | null) { setRenamingId(chatId); @@ -61,9 +54,10 @@ export function ChatTabBar({ return (
- {/* Chat tabs */} {tabs.map((chat, tabIdx) => { const isActive = tabIdx === pane.activeChatIdx; + const isLast = tabIdx === tabs.length - 1; + const onlyTab = tabs.length === 1; const label = chat.name ?? 'New chat'; return ( @@ -103,7 +97,7 @@ export function ChatTabBar({ onRemoveTab(chat.id); }} className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0" - aria-label="Remove from tab bar" + aria-label="Close tab" > @@ -114,21 +108,29 @@ export function ChatTabBar({ Rename - void onClose(chat.id)}> + onRemoveTab(chat.id)}> Close setDeleteConfirm(chat.id)} + disabled={onlyTab} + onSelect={() => onCloseOthers(chat.id)} > - Delete + Close others + + onCloseToRight(chat.id)} + > + Close to right + + onCloseAll()}> + Close all ); })} - {/* Empty state label */} {tabs.length === 0 && (
@@ -136,7 +138,6 @@ export function ChatTabBar({
)} - {/* Action buttons */}
- - { if (!open) setDeleteConfirm(null); }}> - - - Delete chat - - This will permanently delete this chat and all its messages. This cannot be undone. - - -
- - -
-
-
); } diff --git a/apps/web/src/components/MessageList.tsx b/apps/web/src/components/MessageList.tsx index b5fcbbc..26d93d7 100644 --- a/apps/web/src/components/MessageList.tsx +++ b/apps/web/src/components/MessageList.tsx @@ -23,11 +23,13 @@ export function MessageList({ messages, sessionChats }: Props) { } return ( -
- {messages.map((m) => ( - - ))} -
+
+
+ {messages.map((m) => ( + + ))} +
+
); } diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index 717667c..83a06e4 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -1,8 +1,23 @@ import { useState } from 'react'; -import { MessageSquare, Send, ChevronDown, ChevronRight } from 'lucide-react'; +import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react'; import type { Chat } from '@/api/types'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; import { formatTokens } from '@/lib/format'; interface Props { @@ -12,6 +27,9 @@ interface Props { onOpenChat: (chatId: string) => void; onSend: (content: string) => void; onReopenChat: (chatId: string) => Promise; + onArchiveChat: (chatId: string) => Promise; + onRenameChat: (chatId: string, name: string) => Promise; + onDeleteChat: (chatId: string) => Promise; } function relTime(iso: string): string { @@ -28,17 +46,39 @@ function relTime(iso: string): string { return `${day}d ago`; } +interface ChatRowProps { + chat: Chat; + onClick: () => void; + dimmed?: boolean; + trailing?: React.ReactNode; + actions?: React.ReactNode; + renamingId: string | null; + renameValue: string; + setRenameValue: (s: string) => void; + onFinishRename: () => void; + onCancelRename: () => void; + onContextStartRename: () => void; + onContextArchive: () => void; + onContextDelete: () => void; + showContextMenu: boolean; +} + function ChatRow({ chat, onClick, dimmed, trailing, -}: { - chat: Chat; - onClick: () => void; - dimmed?: boolean; - trailing?: string; -}) { + actions, + renamingId, + renameValue, + setRenameValue, + onFinishRename, + onCancelRename, + onContextStartRename, + onContextArchive, + onContextDelete, + showContextMenu, +}: ChatRowProps) { const meta: string[] = [relTime(chat.updated_at)]; if (chat.message_count !== undefined && chat.message_count > 0) { meta.push(`${chat.message_count} msg`); @@ -46,7 +86,9 @@ function ChatRow({ const tokens = formatTokens(chat.effective_context_tokens); if (tokens) meta.push(tokens); const preview = chat.last_message_preview; - return ( + const isRenaming = renamingId === chat.id; + + const inner = ( ); + + if (!showContextMenu) return inner; + + return ( + + {inner} + + Open + Rename + Archive + + + Delete + + + + ); } export function SessionLandingPage({ @@ -78,15 +155,23 @@ export function SessionLandingPage({ onOpenChat, onSend, onReopenChat, + onArchiveChat, + onRenameChat, + onDeleteChat, }: Props) { const [composerValue, setComposerValue] = useState(''); - const [showClosed, setShowClosed] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [archiveConfirm, setArchiveConfirm] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [deleteInput, setDeleteInput] = useState(''); const openChats = chats .filter((c) => c.status === 'open') .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); - const closedChats = chats - .filter((c) => c.status === 'closed') + const archivedChats = chats + .filter((c) => c.status === 'archived') .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); function handleSend() { @@ -96,47 +181,109 @@ export function SessionLandingPage({ setComposerValue(''); } + function startRename(chat: Chat) { + setRenamingId(chat.id); + setRenameValue(chat.name ?? ''); + } + + async function finishRename() { + if (renamingId && renameValue.trim()) { + await onRenameChat(renamingId, renameValue.trim()); + } + setRenamingId(null); + } + + const deleteExpected = deleteConfirm?.name ?? ''; + const deleteEnabled = deleteConfirm !== null && deleteInput === deleteExpected && deleteExpected.length > 0; + // TODO: Landing page chat counts are a snapshot at mount. New messages in // visible chats won't update the per-row stats until next mount/navigation. - // Wiring WS reactivity through here is deferred (rare use case: user is in - // a pane when messages stream, not on the landing page). return (
- {/* Open chats */} {openChats.length > 0 && (

Open chats

    {openChats.map((chat) => (
  • - onOpenChat(chat.id)} /> + onOpenChat(chat.id)} + renamingId={renamingId} + renameValue={renameValue} + setRenameValue={setRenameValue} + onFinishRename={() => void finishRename()} + onCancelRename={() => setRenamingId(null)} + onContextStartRename={() => startRename(chat)} + onContextArchive={() => setArchiveConfirm(chat)} + onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }} + showContextMenu + actions={ + <> + + + + } + />
  • ))}
)} - {/* Closed chats */} - {closedChats.length > 0 && ( + {archivedChats.length > 0 && (
- {showClosed && ( + {showArchived && (
    - {closedChats.map((chat) => ( + {archivedChats.map((chat) => (
  • void onReopenChat(chat.id)} dimmed - trailing="Reopen" + trailing={<>Restore} + renamingId={null} + renameValue="" + setRenameValue={() => {}} + onFinishRename={() => {}} + onCancelRename={() => {}} + onContextStartRename={() => {}} + onContextArchive={() => {}} + onContextDelete={() => {}} + showContextMenu={false} />
  • ))} @@ -145,14 +292,13 @@ export function SessionLandingPage({
)} - {openChats.length === 0 && closedChats.length === 0 && ( + {openChats.length === 0 && archivedChats.length === 0 && (
No chats yet. Type below to start a conversation.
)}
- {/* Composer */}