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;