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