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:
@@ -9,8 +9,7 @@ const CreateBody = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PatchBody = z.object({
|
const PatchBody = z.object({
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200),
|
||||||
status: z.enum(['open', 'closed']).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function registerChatRoutes(
|
export function registerChatRoutes(
|
||||||
@@ -18,7 +17,7 @@ export function registerChatRoutes(
|
|||||||
sql: Sql,
|
sql: Sql,
|
||||||
broker: Broker
|
broker: Broker
|
||||||
): void {
|
): void {
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
|
||||||
'/api/sessions/:id/chats',
|
'/api/sessions/:id/chats',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
||||||
@@ -26,10 +25,8 @@ export function registerChatRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
|
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||||
// Enriched list: computed per-chat fields via LATERAL joins.
|
// 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[]>`
|
const rows = await sql<Chat[]>`
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.session_id, c.name, c.status, c.created_at, c.updated_at,
|
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
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) ec ON TRUE
|
) 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
|
ORDER BY c.updated_at DESC
|
||||||
`;
|
`;
|
||||||
return rows;
|
return rows;
|
||||||
@@ -98,17 +95,10 @@ export function registerChatRoutes(
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
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[]>`
|
const rows = await sql<Chat[]>`
|
||||||
UPDATE chats
|
UPDATE chats
|
||||||
SET
|
SET name = ${parsed.data.name},
|
||||||
name = COALESCE(${name ?? null}, name),
|
updated_at = clock_timestamp()
|
||||||
status = COALESCE(${status ?? null}, status),
|
|
||||||
updated_at = clock_timestamp()
|
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, session_id, name, status, created_at, updated_at
|
RETURNING id, session_id, name, status, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
@@ -117,21 +107,54 @@ export function registerChatRoutes(
|
|||||||
return { error: 'chat not found' };
|
return { error: 'chat not found' };
|
||||||
}
|
}
|
||||||
const chat = rows[0]!;
|
const chat = rows[0]!;
|
||||||
if (status === 'closed') {
|
broker.publishUser('default', {
|
||||||
broker.publishUser('default', {
|
type: 'chat_updated',
|
||||||
type: 'chat_closed',
|
chat_id: chat.id,
|
||||||
chat_id: chat.id,
|
session_id: chat.session_id,
|
||||||
session_id: chat.session_id,
|
name: chat.name,
|
||||||
});
|
updated_at: chat.updated_at,
|
||||||
} else {
|
});
|
||||||
broker.publishUser('default', {
|
return chat;
|
||||||
type: 'chat_updated',
|
}
|
||||||
chat_id: chat.id,
|
);
|
||||||
session_id: chat.session_id,
|
|
||||||
name: chat.name,
|
app.post<{ Params: { id: string } }>(
|
||||||
updated_at: chat.updated_at,
|
'/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;
|
return chat;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -147,6 +170,12 @@ export function registerChatRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'chat not found' };
|
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);
|
reply.code(204);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ CREATE TABLE IF NOT EXISTS chats (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
name TEXT,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
updated_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'));
|
CHECK (status IN ('open', 'archived'));
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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 $$;
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export interface Session {
|
|||||||
updated_at: string;
|
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 {
|
export interface Chat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -191,8 +193,17 @@ export interface ChatUpdatedFrame {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
export interface ChatClosedFrame {
|
export interface ChatArchivedFrame {
|
||||||
type: 'chat_closed';
|
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;
|
chat_id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
}
|
}
|
||||||
@@ -218,7 +229,9 @@ export type UserStreamFrame =
|
|||||||
| SessionArchivedFrame
|
| SessionArchivedFrame
|
||||||
| ChatCreatedFrame
|
| ChatCreatedFrame
|
||||||
| ChatUpdatedFrame
|
| ChatUpdatedFrame
|
||||||
| ChatClosedFrame
|
| ChatArchivedFrame
|
||||||
|
| ChatUnarchivedFrame
|
||||||
|
| ChatDeletedFrame
|
||||||
| ProjectArchivedFrame
|
| ProjectArchivedFrame
|
||||||
| ProjectUnarchivedFrame
|
| ProjectUnarchivedFrame
|
||||||
| ProjectUpdatedFrame;
|
| ProjectUpdatedFrame;
|
||||||
|
|||||||
@@ -117,18 +117,24 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
chats: {
|
chats: {
|
||||||
listForSession: (sessionId: string) =>
|
listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) =>
|
||||||
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
|
request<Chat[]>(
|
||||||
|
`/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}`
|
||||||
|
),
|
||||||
create: (sessionId: string, body?: { name?: string }) =>
|
create: (sessionId: string, body?: { name?: string }) =>
|
||||||
request<Chat>(`/api/sessions/${sessionId}/chats`, {
|
request<Chat>(`/api/sessions/${sessionId}/chats`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
}),
|
}),
|
||||||
update: (chatId: string, body: { name?: string; status?: 'open' | 'closed' }) =>
|
update: (chatId: string, body: { name: string }) =>
|
||||||
request<Chat>(`/api/chats/${chatId}`, {
|
request<Chat>(`/api/chats/${chatId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
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) =>
|
remove: (chatId: string) =>
|
||||||
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
|
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
|
||||||
messages: (chatId: string) =>
|
messages: (chatId: string) =>
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export interface Session {
|
|||||||
updated_at: string;
|
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 {
|
export interface Chat {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t">
|
<div className="border-t">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||||
{attachments.map(a => (
|
{attachments.map(a => (
|
||||||
@@ -252,6 +253,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
<Send />
|
<Send />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<AttachmentPreviewModal
|
<AttachmentPreviewModal
|
||||||
attachment={previewAttachment}
|
attachment={previewAttachment}
|
||||||
onClose={() => setPreviewAttachment(null)}
|
onClose={() => setPreviewAttachment(null)}
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} 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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,11 +15,12 @@ interface Props {
|
|||||||
tabs: Chat[];
|
tabs: Chat[];
|
||||||
onSwitchTab: (tabIdx: number) => void;
|
onSwitchTab: (tabIdx: number) => void;
|
||||||
onRemoveTab: (chatId: string) => void;
|
onRemoveTab: (chatId: string) => void;
|
||||||
|
onCloseOthers: (chatId: string) => void;
|
||||||
|
onCloseToRight: (chatId: string) => void;
|
||||||
|
onCloseAll: () => void;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onShowHistory: () => void;
|
onShowHistory: () => void;
|
||||||
onRename: (chatId: string, name: string) => Promise<void>;
|
onRename: (chatId: string, name: string) => Promise<void>;
|
||||||
onClose: (chatId: string) => Promise<void>;
|
|
||||||
onDelete: (chatId: string) => Promise<void>;
|
|
||||||
onRemovePane?: () => void;
|
onRemovePane?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,16 +29,16 @@ export function ChatTabBar({
|
|||||||
tabs,
|
tabs,
|
||||||
onSwitchTab,
|
onSwitchTab,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
|
onCloseOthers,
|
||||||
|
onCloseToRight,
|
||||||
|
onCloseAll,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onShowHistory,
|
onShowHistory,
|
||||||
onRename,
|
onRename,
|
||||||
onClose,
|
|
||||||
onDelete,
|
|
||||||
onRemovePane,
|
onRemovePane,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function startRename(chatId: string, currentName: string | null) {
|
function startRename(chatId: string, currentName: string | null) {
|
||||||
setRenamingId(chatId);
|
setRenamingId(chatId);
|
||||||
@@ -61,9 +54,10 @@ export function ChatTabBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
|
<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) => {
|
{tabs.map((chat, tabIdx) => {
|
||||||
const isActive = tabIdx === pane.activeChatIdx;
|
const isActive = tabIdx === pane.activeChatIdx;
|
||||||
|
const isLast = tabIdx === tabs.length - 1;
|
||||||
|
const onlyTab = tabs.length === 1;
|
||||||
const label = chat.name ?? 'New chat';
|
const label = chat.name ?? 'New chat';
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={chat.id}>
|
||||||
@@ -103,7 +97,7 @@ export function ChatTabBar({
|
|||||||
onRemoveTab(chat.id);
|
onRemoveTab(chat.id);
|
||||||
}}
|
}}
|
||||||
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
|
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} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
@@ -114,21 +108,29 @@ export function ChatTabBar({
|
|||||||
Rename
|
Rename
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => void onClose(chat.id)}>
|
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
|
||||||
Close
|
Close
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
variant="destructive"
|
disabled={onlyTab}
|
||||||
onSelect={() => setDeleteConfirm(chat.id)}
|
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>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Empty state label */}
|
|
||||||
{tabs.length === 0 && (
|
{tabs.length === 0 && (
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
<History size={12} className="shrink-0" />
|
<History size={12} className="shrink-0" />
|
||||||
@@ -136,7 +138,6 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -171,31 +172,6 @@ export function ChatTabBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ export function MessageList({ messages, sessionChats }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{messages.map((m) => (
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||||
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
|
{messages.map((m) => (
|
||||||
))}
|
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
|
||||||
<div ref={endRef} />
|
))}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { useState } from 'react';
|
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 type { Chat } from '@/api/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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';
|
import { formatTokens } from '@/lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +27,9 @@ interface Props {
|
|||||||
onOpenChat: (chatId: string) => void;
|
onOpenChat: (chatId: string) => void;
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
onReopenChat: (chatId: string) => Promise<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 {
|
function relTime(iso: string): string {
|
||||||
@@ -28,17 +46,39 @@ function relTime(iso: string): string {
|
|||||||
return `${day}d ago`;
|
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({
|
function ChatRow({
|
||||||
chat,
|
chat,
|
||||||
onClick,
|
onClick,
|
||||||
dimmed,
|
dimmed,
|
||||||
trailing,
|
trailing,
|
||||||
}: {
|
actions,
|
||||||
chat: Chat;
|
renamingId,
|
||||||
onClick: () => void;
|
renameValue,
|
||||||
dimmed?: boolean;
|
setRenameValue,
|
||||||
trailing?: string;
|
onFinishRename,
|
||||||
}) {
|
onCancelRename,
|
||||||
|
onContextStartRename,
|
||||||
|
onContextArchive,
|
||||||
|
onContextDelete,
|
||||||
|
showContextMenu,
|
||||||
|
}: ChatRowProps) {
|
||||||
const meta: string[] = [relTime(chat.updated_at)];
|
const meta: string[] = [relTime(chat.updated_at)];
|
||||||
if (chat.message_count !== undefined && chat.message_count > 0) {
|
if (chat.message_count !== undefined && chat.message_count > 0) {
|
||||||
meta.push(`${chat.message_count} msg`);
|
meta.push(`${chat.message_count} msg`);
|
||||||
@@ -46,7 +86,9 @@ function ChatRow({
|
|||||||
const tokens = formatTokens(chat.effective_context_tokens);
|
const tokens = formatTokens(chat.effective_context_tokens);
|
||||||
if (tokens) meta.push(tokens);
|
if (tokens) meta.push(tokens);
|
||||||
const preview = chat.last_message_preview;
|
const preview = chat.last_message_preview;
|
||||||
return (
|
const isRenaming = renamingId === chat.id;
|
||||||
|
|
||||||
|
const inner = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -54,12 +96,30 @@ function ChatRow({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
|
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
|
||||||
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
|
{isRenaming ? (
|
||||||
{chat.name ?? 'New chat'}
|
<input
|
||||||
</span>
|
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 && (
|
{trailing && (
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
|
<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>
|
||||||
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
|
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
|
||||||
{meta.join(' · ')}
|
{meta.join(' · ')}
|
||||||
@@ -71,6 +131,23 @@ function ChatRow({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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({
|
export function SessionLandingPage({
|
||||||
@@ -78,15 +155,23 @@ export function SessionLandingPage({
|
|||||||
onOpenChat,
|
onOpenChat,
|
||||||
onSend,
|
onSend,
|
||||||
onReopenChat,
|
onReopenChat,
|
||||||
|
onArchiveChat,
|
||||||
|
onRenameChat,
|
||||||
|
onDeleteChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [composerValue, setComposerValue] = useState('');
|
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
|
const openChats = chats
|
||||||
.filter((c) => c.status === 'open')
|
.filter((c) => c.status === 'open')
|
||||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||||
const closedChats = chats
|
const archivedChats = chats
|
||||||
.filter((c) => c.status === 'closed')
|
.filter((c) => c.status === 'archived')
|
||||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
@@ -96,47 +181,109 @@ export function SessionLandingPage({
|
|||||||
setComposerValue('');
|
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
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
{/* Open chats */}
|
|
||||||
{openChats.length > 0 && (
|
{openChats.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
{openChats.map((chat) => (
|
{openChats.map((chat) => (
|
||||||
<li key={chat.id}>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Closed chats */}
|
{archivedChats.length > 0 && (
|
||||||
{closedChats.length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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} />}
|
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
Closed chats ({closedChats.length})
|
Archived chats ({archivedChats.length})
|
||||||
</button>
|
</button>
|
||||||
{showClosed && (
|
{showArchived && (
|
||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
{closedChats.map((chat) => (
|
{archivedChats.map((chat) => (
|
||||||
<li key={chat.id}>
|
<li key={chat.id}>
|
||||||
<ChatRow
|
<ChatRow
|
||||||
chat={chat}
|
chat={chat}
|
||||||
onClick={() => void onReopenChat(chat.id)}
|
onClick={() => void onReopenChat(chat.id)}
|
||||||
dimmed
|
dimmed
|
||||||
trailing="Reopen"
|
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
|
||||||
|
renamingId={null}
|
||||||
|
renameValue=""
|
||||||
|
setRenameValue={() => {}}
|
||||||
|
onFinishRename={() => {}}
|
||||||
|
onCancelRename={() => {}}
|
||||||
|
onContextStartRename={() => {}}
|
||||||
|
onContextArchive={() => {}}
|
||||||
|
onContextDelete={() => {}}
|
||||||
|
showContextMenu={false}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -145,14 +292,13 @@ export function SessionLandingPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openChats.length === 0 && closedChats.length === 0 && (
|
{openChats.length === 0 && archivedChats.length === 0 && (
|
||||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||||
No chats yet. Type below to start a conversation.
|
No chats yet. Type below to start a conversation.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Composer */}
|
|
||||||
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
|
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={composerValue}
|
value={composerValue}
|
||||||
@@ -181,6 +327,68 @@ export function SessionLandingPage({
|
|||||||
<Send />
|
<Send />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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) =>
|
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);
|
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]);
|
}, [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) => {
|
const createChat = useCallback(async (paneIdx: number) => {
|
||||||
try {
|
try {
|
||||||
const chat = await api.chats.create(sessionId);
|
const chat = await api.chats.create(sessionId);
|
||||||
@@ -194,15 +253,21 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
}
|
}
|
||||||
}, [sessionId, openChatInPane]);
|
}, [sessionId, openChatInPane]);
|
||||||
|
|
||||||
const closeChat = useCallback(async (chatId: string) => {
|
const archiveChat = useCallback(async (chatId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.chats.update(chatId, { status: 'closed' });
|
await api.chats.archive(chatId);
|
||||||
setChats((prev) => prev.map((c) =>
|
// Server publishes chat_archived; bus forwarder updates state.
|
||||||
c.id === chatId ? { ...c, status: 'closed' as const } : c
|
|
||||||
));
|
|
||||||
removeChatFromPanes(chatId);
|
|
||||||
} catch (err) {
|
} 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)}
|
tabs={chatsForPane(pane)}
|
||||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||||
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||||
|
onCloseAll={() => closeAllTabs(idx)}
|
||||||
onNewChat={() => void createChat(idx)}
|
onNewChat={() => void createChat(idx)}
|
||||||
onShowHistory={() => showLandingPage(idx)}
|
onShowHistory={() => showLandingPage(idx)}
|
||||||
onRename={renameChat}
|
onRename={renameChat}
|
||||||
onClose={closeChat}
|
|
||||||
onDelete={deleteChat}
|
|
||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,12 +480,12 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
onReopenChat={async (chatId) => {
|
onReopenChat={async (chatId) => {
|
||||||
await api.chats.update(chatId, { status: 'open' });
|
await unarchiveChat(chatId);
|
||||||
setChats((prev) => prev.map((c) =>
|
|
||||||
c.id === chatId ? { ...c, status: 'open' as const } : c
|
|
||||||
));
|
|
||||||
openChatInPane(idx, chatId);
|
openChatInPane(idx, chatId);
|
||||||
}}
|
}}
|
||||||
|
onArchiveChat={archiveChat}
|
||||||
|
onRenameChat={renameChat}
|
||||||
|
onDeleteChat={deleteChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
|
|
||||||
{/* Queued messages */}
|
{/* Queued messages */}
|
||||||
{queue.length > 0 && (
|
{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) => (
|
{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">
|
<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>
|
<span className="font-medium shrink-0">Queued:</span>
|
||||||
@@ -141,12 +142,14 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stop button when streaming */}
|
{/* Stop button when streaming */}
|
||||||
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleStop()}
|
onClick={() => void handleStop()}
|
||||||
@@ -155,6 +158,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
<Square size={10} className="fill-current" />
|
<Square size={10} className="fill-current" />
|
||||||
Stop generating
|
Stop generating
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,19 @@ export interface ChatUpdatedEvent {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatClosedEvent {
|
export interface ChatArchivedEvent {
|
||||||
type: 'chat_closed';
|
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;
|
chat_id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
}
|
}
|
||||||
@@ -112,7 +123,9 @@ export type SessionEvent =
|
|||||||
| SessionArchivedEvent
|
| SessionArchivedEvent
|
||||||
| ChatCreatedEvent
|
| ChatCreatedEvent
|
||||||
| ChatUpdatedEvent
|
| ChatUpdatedEvent
|
||||||
| ChatClosedEvent
|
| ChatArchivedEvent
|
||||||
|
| ChatUnarchivedEvent
|
||||||
|
| ChatDeletedEvent
|
||||||
| ProjectArchivedEvent
|
| ProjectArchivedEvent
|
||||||
| ProjectUnarchivedEvent
|
| ProjectUnarchivedEvent
|
||||||
| ProjectUpdatedEvent;
|
| ProjectUpdatedEvent;
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
}
|
}
|
||||||
case 'chat_created':
|
case 'chat_created':
|
||||||
case 'chat_updated':
|
case 'chat_updated':
|
||||||
case 'chat_closed':
|
case 'chat_archived':
|
||||||
|
case 'chat_unarchived':
|
||||||
|
case 'chat_deleted':
|
||||||
return prev;
|
return prev;
|
||||||
case 'project_archived': {
|
case 'project_archived': {
|
||||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||||
|
|||||||
Reference in New Issue
Block a user