tab-close + chat archive/delete + landing-card buttons + 1000px content cap

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 03:21:26 +00:00
parent 48a972e139
commit e09c67d65c
13 changed files with 475 additions and 139 deletions

View File

@@ -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<Chat[]>`
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<Chat[]>`
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<Chat[]>`
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;
}

View File

@@ -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 $$;

View File

@@ -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;

View File

@@ -117,18 +117,24 @@ export const api = {
},
chats: {
listForSession: (sessionId: string) =>
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) =>
request<Chat[]>(
`/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}`
),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/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<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
archive: (chatId: string) =>
request<void>(`/api/chats/${chatId}/archive`, { method: 'POST' }),
unarchive: (chatId: string) =>
request<Chat>(`/api/chats/${chatId}/unarchive`, { method: 'POST' }),
remove: (chatId: string) =>
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
messages: (chatId: string) =>

View File

@@ -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;

View File

@@ -220,6 +220,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
return (
<div className="border-t">
<div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
{attachments.map(a => (
@@ -252,6 +253,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
<Send />
</Button>
</div>
</div>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}

View File

@@ -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<void>;
onClose: (chatId: string) => Promise<void>;
onDelete: (chatId: string) => Promise<void>;
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<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
@@ -61,9 +54,10 @@ export function ChatTabBar({
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
{/* 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 (
<ContextMenu key={chat.id}>
@@ -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"
>
<X size={10} />
</button>
@@ -114,21 +108,29 @@ export function ChatTabBar({
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void onClose(chat.id)}>
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
Close
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() => setDeleteConfirm(chat.id)}
disabled={onlyTab}
onSelect={() => onCloseOthers(chat.id)}
>
Delete
Close others
</ContextMenuItem>
<ContextMenuItem
disabled={isLast}
onSelect={() => onCloseToRight(chat.id)}
>
Close to right
</ContextMenuItem>
<ContextMenuItem onSelect={() => onCloseAll()}>
Close all
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{/* Empty state label */}
{tabs.length === 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<History size={12} className="shrink-0" />
@@ -136,7 +138,6 @@ export function ChatTabBar({
</div>
)}
{/* Action buttons */}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
@@ -171,31 +172,6 @@ export function ChatTabBar({
</button>
)}
</div>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
This will permanently delete this chat and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDelete(deleteConfirm);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -23,11 +23,13 @@ export function MessageList({ messages, sessionChats }: Props) {
}
return (
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
))}
<div ref={endRef} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
))}
<div ref={endRef} />
</div>
</div>
);
}

View File

@@ -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<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onRenameChat: (chatId: string, name: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
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 = (
<button
type="button"
onClick={onClick}
@@ -54,12 +96,30 @@ function ChatRow({
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
{chat.name ?? 'New chat'}
</span>
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => onFinishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') onFinishRename();
if (e.key === 'Escape') onCancelRename();
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
{chat.name ?? 'New chat'}
</span>
)}
{trailing && (
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
)}
{actions && (
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
)}
</div>
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
{meta.join(' · ')}
@@ -71,6 +131,23 @@ function ChatRow({
)}
</button>
);
if (!showContextMenu) return inner;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
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<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(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 (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Open chats */}
{openChats.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<ChatRow chat={chat} onClick={() => onOpenChat(chat.id)} />
<ChatRow
chat={chat}
onClick={() => 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={
<>
<Button
variant="ghost"
size="icon-sm"
aria-label="Archive chat"
title="Archive chat"
onClick={(e) => {
e.stopPropagation();
setArchiveConfirm(chat);
}}
>
<Archive size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete chat"
title="Delete chat"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
setDeleteInput('');
}}
>
<Trash2 size={14} />
</Button>
</>
}
/>
</li>
))}
</ul>
</div>
)}
{/* Closed chats */}
{closedChats.length > 0 && (
{archivedChats.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowClosed(!showClosed)}
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showClosed ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Closed chats ({closedChats.length})
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived chats ({archivedChats.length})
</button>
{showClosed && (
{showArchived && (
<ul className="divide-y rounded-md border">
{closedChats.map((chat) => (
{archivedChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => void onReopenChat(chat.id)}
dimmed
trailing="Reopen"
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
renamingId={null}
renameValue=""
setRenameValue={() => {}}
onFinishRename={() => {}}
onCancelRename={() => {}}
onContextStartRename={() => {}}
onContextArchive={() => {}}
onContextDelete={() => {}}
showContextMenu={false}
/>
</li>
))}
@@ -145,14 +292,13 @@ export function SessionLandingPage({
</div>
)}
{openChats.length === 0 && closedChats.length === 0 && (
{openChats.length === 0 && archivedChats.length === 0 && (
<div className="text-sm text-muted-foreground py-8 text-center">
No chats yet. Type below to start a conversation.
</div>
)}
</div>
{/* Composer */}
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
<Textarea
value={composerValue}
@@ -181,6 +327,68 @@ export function SessionLandingPage({
<Send />
</Button>
</div>
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive chat?</DialogTitle>
<DialogDescription>
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
setArchiveConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Type the chat name to confirm:
{' '}
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
</DialogDescription>
</DialogHeader>
<Input
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteExpected}
disabled={!deleteExpected}
/>
<div className="text-xs text-muted-foreground">
This will permanently delete this chat and all its messages. This cannot be undone.
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!deleteEnabled}
onClick={() => {
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
setDeleteInput('');
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -100,12 +100,24 @@ export function Workspace({ sessionId, projectId }: Props) {
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_closed') {
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'closed' as const } : c
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanes(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanes(event.chat_id);
}
});
}, [sessionId]);
@@ -180,6 +192,53 @@ export function Workspace({ sessionId, projectId }: Props) {
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
@@ -194,15 +253,21 @@ export function Workspace({ sessionId, projectId }: Props) {
}
}, [sessionId, openChatInPane]);
const closeChat = useCallback(async (chatId: string) => {
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.update(chatId, { status: 'closed' });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, status: 'closed' as const } : c
));
removeChatFromPanes(chatId);
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to close chat');
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
@@ -394,11 +459,12 @@ export function Workspace({ sessionId, projectId }: Props) {
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onClose={closeChat}
onDelete={deleteChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
@@ -414,12 +480,12 @@ export function Workspace({ sessionId, projectId }: Props) {
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {
await api.chats.update(chatId, { status: 'open' });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, status: 'open' as const } : c
));
await unarchiveChat(chatId);
openChatInPane(idx, chatId);
}}
onArchiveChat={archiveChat}
onRenameChat={renameChat}
onDeleteChat={deleteChat}
/>
)}
</div>

View File

@@ -107,7 +107,8 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
{/* Queued messages */}
{queue.length > 0 && (
<div className="px-4 py-1 border-t space-y-1">
<div className="border-t">
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
{queue.map((msg, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
<span className="font-medium shrink-0">Queued:</span>
@@ -141,12 +142,14 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
</button>
</div>
))}
</div>
</div>
)}
{/* Stop button when streaming */}
{streaming && (
<div className="flex justify-center py-1 border-t">
<div className="border-t py-1">
<div className="max-w-[1000px] mx-auto w-full flex justify-center">
<button
type="button"
onClick={() => void handleStop()}
@@ -155,6 +158,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<Square size={10} className="fill-current" />
Stop generating
</button>
</div>
</div>
)}

View File

@@ -77,8 +77,19 @@ export interface ChatUpdatedEvent {
updated_at: string;
}
export interface ChatClosedEvent {
type: 'chat_closed';
export interface ChatArchivedEvent {
type: 'chat_archived';
chat_id: string;
session_id: string;
}
export interface ChatUnarchivedEvent {
type: 'chat_unarchived';
chat: Chat;
}
export interface ChatDeletedEvent {
type: 'chat_deleted';
chat_id: string;
session_id: string;
}
@@ -112,7 +123,9 @@ export type SessionEvent =
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
| ChatClosedEvent
| ChatArchivedEvent
| ChatUnarchivedEvent
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;

View File

@@ -165,7 +165,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
}
case 'chat_created':
case 'chat_updated':
case 'chat_closed':
case 'chat_archived':
case 'chat_unarchived':
case 'chat_deleted':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);