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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user