v1.4-fork-header: fork from message + delete message + header polish + housekeeping
- Fork: POST /api/chats/:id/fork creates a new chat in the same session, copies messages up to target (status=complete) with row-offset clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane event; Workspace opens it in the active pane. No maybeAutoNameChat on forks. - Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is currently streaming. Cascading-forward delete (created_at >= target). MessageBubble Trash button + confirm Dialog. - Header: Projects -> Project -> Session breadcrumb, model badge pill, inline session rename, active file path via new useActivePane() hook. Server now publishes session_renamed on PATCH /api/sessions/:id; client-side dup emit removed from Session.tsx. - Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill INSERT removed (CREATE TABLE retained), Tailnet trust comment near app.listen(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,7 @@ async function main() {
|
|||||||
cancelInference: async (sessionId, chatId) => {
|
cancelInference: async (sessionId, chatId) => {
|
||||||
return inference.cancel(sessionId, chatId);
|
return inference.cancel(sessionId, chatId);
|
||||||
},
|
},
|
||||||
|
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||||
broker.publish(sessionId, {
|
broker.publish(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
@@ -144,6 +145,9 @@ async function main() {
|
|||||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
|
||||||
|
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
|
||||||
|
// the threat model treats Tailnet membership as the trust boundary.
|
||||||
await app.listen({ port: config.PORT, host: config.HOST });
|
await app.listen({ port: config.PORT, host: config.HOST });
|
||||||
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
|
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const PatchBody = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ForkBody = z.object({
|
||||||
|
message_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export function registerChatRoutes(
|
export function registerChatRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -181,6 +186,78 @@ export function registerChatRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/fork',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = ForkBody.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id, name, status, created_at, updated_at
|
||||||
|
FROM chats WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
if (sourceRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const source = sourceRows[0]!;
|
||||||
|
|
||||||
|
const targetRows = await sql<{ created_at: string; status: string }[]>`
|
||||||
|
SELECT created_at, status FROM messages
|
||||||
|
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
|
||||||
|
`;
|
||||||
|
if (targetRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'message not found in chat' };
|
||||||
|
}
|
||||||
|
const target = targetRows[0]!;
|
||||||
|
if (target.status !== 'complete') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'can only fork from completed messages' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
|
||||||
|
|
||||||
|
const newChat = await sql.begin(async (tx) => {
|
||||||
|
const [chat] = await tx<Chat[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${source.session_id}, ${newName}, 'open')
|
||||||
|
RETURNING id, session_id, name, status, created_at, updated_at
|
||||||
|
`;
|
||||||
|
await tx`
|
||||||
|
INSERT INTO messages (
|
||||||
|
session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||||
|
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||||
|
tool_calls, tool_results, status,
|
||||||
|
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
|
clock_timestamp() + (
|
||||||
|
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||||
|
)
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${source.id}
|
||||||
|
AND created_at <= ${target.created_at}::timestamptz
|
||||||
|
AND status = 'complete'
|
||||||
|
`;
|
||||||
|
return chat!;
|
||||||
|
});
|
||||||
|
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'chat_created',
|
||||||
|
chat: newChat,
|
||||||
|
session_id: source.session_id,
|
||||||
|
});
|
||||||
|
reply.code(201);
|
||||||
|
return newChat;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
'/api/chats/:id/messages',
|
'/api/chats/:id/messages',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface MessageHandlers {
|
|||||||
) => void;
|
) => void;
|
||||||
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
||||||
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActiveInference: (chatId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMessageRoutes(
|
export function registerMessageRoutes(
|
||||||
@@ -156,6 +157,53 @@ export function registerMessageRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; message_id: string } }>(
|
||||||
|
'/api/chats/:id/messages/:message_id',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id: chatId, message_id: messageId } = req.params;
|
||||||
|
|
||||||
|
const chatRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
|
||||||
|
if (handlers.hasActiveInference(chatId)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'chat is currently streaming; stop it first' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedIds = await sql.begin(async (tx) => {
|
||||||
|
const deletedRows = await tx<{ id: string }[]>`
|
||||||
|
DELETE FROM messages
|
||||||
|
WHERE chat_id = ${chatId}
|
||||||
|
AND created_at >= (
|
||||||
|
SELECT created_at FROM messages
|
||||||
|
WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
if (deletedRows.length > 0) {
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
}
|
||||||
|
return deletedRows.map((r) => r.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deletedIds.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'message not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
|
||||||
|
|
||||||
|
reply.code(204);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.post<{ Params: { id: string } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/chats/:id/compact',
|
'/api/chats/:id/compact',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
|
|||||||
@@ -134,7 +134,15 @@ export function registerSessionRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
return rows[0];
|
const session = rows[0]!;
|
||||||
|
if (name !== undefined) {
|
||||||
|
broker.publishUser('default', {
|
||||||
|
type: 'session_renamed',
|
||||||
|
session_id: session.id,
|
||||||
|
name: session.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
last_session_id UUID
|
last_session_id UUID
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
system_prompt TEXT NOT NULL DEFAULT '',
|
system_prompt TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
|
||||||
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
tool_results JSONB,
|
tool_results JSONB,
|
||||||
status TEXT NOT NULL DEFAULT 'complete',
|
status TEXT NOT NULL DEFAULT 'complete',
|
||||||
last_seq INT NOT NULL DEFAULT 0,
|
last_seq INT NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||||
@@ -60,14 +60,9 @@ CREATE TABLE IF NOT EXISTS session_panes (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
|
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
|
||||||
|
|
||||||
-- Backfill: ensure every session has at least one pane (default Chat).
|
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
|
||||||
-- Idempotent: skipped on subsequent runs because session_panes rows already exist.
|
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
|
||||||
INSERT INTO session_panes (session_id, position, kind, state)
|
-- future destructive migration.
|
||||||
SELECT s.id, 0, 'chat', '{}'::jsonb
|
|
||||||
FROM sessions s
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- v1.2: sessions.status (open | archived)
|
-- v1.2: sessions.status (open | archived)
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
|
|||||||
@@ -764,6 +764,10 @@ export function createInferenceRunner(
|
|||||||
await reg.completed.catch(() => {});
|
await reg.completed.catch(() => {});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hasActive(chatId: string): boolean {
|
||||||
|
return registry.has(chatId);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ export interface SessionUpdatedFrame {
|
|||||||
name: string;
|
name: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
export interface SessionRenamedFrame {
|
||||||
|
type: 'session_renamed';
|
||||||
|
session_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
export interface SessionArchivedFrame {
|
export interface SessionArchivedFrame {
|
||||||
type: 'session_archived';
|
type: 'session_archived';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -226,6 +231,7 @@ export type UserStreamFrame =
|
|||||||
| SessionCreatedFrame
|
| SessionCreatedFrame
|
||||||
| SessionDeletedFrame
|
| SessionDeletedFrame
|
||||||
| SessionUpdatedFrame
|
| SessionUpdatedFrame
|
||||||
|
| SessionRenamedFrame
|
||||||
| SessionArchivedFrame
|
| SessionArchivedFrame
|
||||||
| ChatCreatedFrame
|
| ChatCreatedFrame
|
||||||
| ChatUpdatedFrame
|
| ChatUpdatedFrame
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ export const api = {
|
|||||||
`/api/chats/${chatId}/force_send`,
|
`/api/chats/${chatId}/force_send`,
|
||||||
{ method: 'POST', body: JSON.stringify({ content }) }
|
{ method: 'POST', body: JSON.stringify({ content }) }
|
||||||
),
|
),
|
||||||
|
fork: (chatId: string, body: { messageId: string; name?: string }) =>
|
||||||
|
request<Chat>(`/api/chats/${chatId}/fork`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
@@ -166,6 +171,10 @@ export const api = {
|
|||||||
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
|
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
),
|
),
|
||||||
|
remove: (chatId: string, messageId: string) =>
|
||||||
|
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
models: () => request<ModelInfo[]>('/api/models'),
|
models: () => request<ModelInfo[]>('/api/models'),
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
|
|||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat, Message } from '@/api/types';
|
import type { Chat, Message } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { ToolCallCard } from './ToolCallCard';
|
import { ToolCallCard } from './ToolCallCard';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||||
@@ -198,6 +207,9 @@ function ActionRow({
|
|||||||
}) {
|
}) {
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
const [forking, setForking] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
try {
|
||||||
@@ -221,10 +233,39 @@ function ActionRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fork() {
|
||||||
|
if (forking || message.status !== 'complete') return;
|
||||||
|
setForking(true);
|
||||||
|
try {
|
||||||
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||||
|
} finally {
|
||||||
|
setForking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.messages.remove(message.chat_id, message.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAssistant = message.role === 'assistant';
|
const isAssistant = message.role === 'assistant';
|
||||||
const canRegen = isAssistant && message.status !== 'streaming';
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
|
const canFork = message.status === 'complete';
|
||||||
|
const canDelete = message.status !== 'streaming';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -247,7 +288,59 @@ function ActionRow({
|
|||||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void fork()}
|
||||||
|
disabled={!canFork || forking}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Fork from here"
|
||||||
|
title="Fork from here"
|
||||||
|
>
|
||||||
|
<GitFork className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={!canDelete}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Delete message"
|
||||||
|
title="Delete message"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!deleting) setDeleteOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This removes the selected message and every later message in this chat. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void confirmDelete()}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import type { DragEvent } from 'react';
|
|
||||||
import { FolderOpen, MessageSquare, X } from 'lucide-react';
|
|
||||||
import type { Pane, PaneKind } from '@/api/types';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from '@/components/ui/context-menu';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pane: Pane;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSplit: (kind: PaneKind) => void;
|
|
||||||
onCloseOthers: () => void;
|
|
||||||
onCloseToRight: () => void;
|
|
||||||
onCloseAll: () => void;
|
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
|
||||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
|
||||||
onDrop: (e: DragEvent<HTMLDivElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function basename(path: string): string {
|
|
||||||
if (!path) return '';
|
|
||||||
const parts = path.split('/');
|
|
||||||
return parts[parts.length - 1] ?? path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelFor(pane: Pane): string {
|
|
||||||
if (pane.kind === 'chat') return 'Chat';
|
|
||||||
const openFile = pane.state.open_file;
|
|
||||||
if (openFile) return basename(openFile);
|
|
||||||
return 'Files';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaneTab({
|
|
||||||
pane,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
onClose,
|
|
||||||
onSplit,
|
|
||||||
onCloseOthers,
|
|
||||||
onCloseToRight,
|
|
||||||
onCloseAll,
|
|
||||||
onDragStart,
|
|
||||||
onDragOver,
|
|
||||||
onDrop,
|
|
||||||
}: Props) {
|
|
||||||
const Icon = pane.kind === 'chat' ? MessageSquare : FolderOpen;
|
|
||||||
const label = labelFor(pane);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none',
|
|
||||||
isActive
|
|
||||||
? 'bg-background text-foreground'
|
|
||||||
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
|
||||||
)}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
>
|
|
||||||
<Icon size={12} className="shrink-0" />
|
|
||||||
<span className="truncate max-w-[160px]" title={label}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className="p-0.5 hover:bg-muted rounded opacity-60 hover:opacity-100 shrink-0"
|
|
||||||
aria-label="Close tab"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuSub>
|
|
||||||
<ContextMenuSubTrigger>Split</ContextMenuSubTrigger>
|
|
||||||
<ContextMenuSubContent>
|
|
||||||
<ContextMenuItem onSelect={() => onSplit('chat')}>
|
|
||||||
<MessageSquare /> Chat
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={() => onSplit('file_browser')}>
|
|
||||||
<FolderOpen /> File Browser
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuSubContent>
|
|
||||||
</ContextMenuSub>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem onSelect={onClose}>Close</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onCloseOthers}>Close others</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onCloseToRight}>
|
|
||||||
Close to the right
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={onCloseAll}>Close all</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
@@ -87,6 +88,29 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
savePanes(sessionId, panes);
|
savePanes(sessionId, panes);
|
||||||
}, [sessionId, panes]);
|
}, [sessionId, panes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = panes[activePaneIdx];
|
||||||
|
if (!active) {
|
||||||
|
clearActivePane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActivePaneInfo({
|
||||||
|
sessionId,
|
||||||
|
paneId: active.id,
|
||||||
|
kind: active.kind,
|
||||||
|
activeFile: null,
|
||||||
|
});
|
||||||
|
}, [sessionId, panes, activePaneIdx]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearActivePane();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activePaneIdxRef = useRef(activePaneIdx);
|
||||||
|
activePaneIdxRef.current = activePaneIdx;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return sessionEvents.subscribe((event) => {
|
return sessionEvents.subscribe((event) => {
|
||||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||||
@@ -118,6 +142,9 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
||||||
removeChatFromPanes(event.chat_id);
|
removeChatFromPanes(event.chat_id);
|
||||||
}
|
}
|
||||||
|
if (event.type === 'open_chat_in_active_pane') {
|
||||||
|
openChatInPane(activePaneIdxRef.current, event.chat_id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { Pane } from '@/api/types';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pane: Pane;
|
|
||||||
onClose: () => void;
|
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaneShell({ pane, onClose, className, children }: Props) {
|
|
||||||
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
|
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-0.5 hover:bg-muted rounded"
|
|
||||||
aria-label="Close pane"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -57,6 +57,11 @@ export interface AttachChatFileEvent {
|
|||||||
attachment: Omit<Attachment, 'id'>;
|
attachment: Omit<Attachment, 'id'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenChatInActivePaneEvent {
|
||||||
|
type: 'open_chat_in_active_pane';
|
||||||
|
chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionArchivedEvent {
|
export interface SessionArchivedEvent {
|
||||||
type: 'session_archived';
|
type: 'session_archived';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -120,6 +125,7 @@ export type SessionEvent =
|
|||||||
| SessionLoadedEvent
|
| SessionLoadedEvent
|
||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
|
| OpenChatInActivePaneEvent
|
||||||
| SessionArchivedEvent
|
| SessionArchivedEvent
|
||||||
| ChatCreatedEvent
|
| ChatCreatedEvent
|
||||||
| ChatUpdatedEvent
|
| ChatUpdatedEvent
|
||||||
|
|||||||
61
apps/web/src/hooks/useActivePane.ts
Normal file
61
apps/web/src/hooks/useActivePane.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { WorkspacePaneKind } from '@/api/types';
|
||||||
|
|
||||||
|
export interface ActivePaneSnapshot {
|
||||||
|
sessionId: string | null;
|
||||||
|
paneId: string | null;
|
||||||
|
kind: WorkspacePaneKind | null;
|
||||||
|
activeFile: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ActivePaneSnapshot = {
|
||||||
|
sessionId: null,
|
||||||
|
paneId: null,
|
||||||
|
kind: null,
|
||||||
|
activeFile: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let current: ActivePaneSnapshot = EMPTY;
|
||||||
|
const subs = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
sub();
|
||||||
|
} catch {
|
||||||
|
// swallow — one bad listener shouldn't break others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSame(a: ActivePaneSnapshot, b: ActivePaneSnapshot): boolean {
|
||||||
|
return (
|
||||||
|
a.sessionId === b.sessionId &&
|
||||||
|
a.paneId === b.paneId &&
|
||||||
|
a.kind === b.kind &&
|
||||||
|
a.activeFile === b.activeFile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActivePaneInfo(next: ActivePaneSnapshot): void {
|
||||||
|
if (isSame(current, next)) return;
|
||||||
|
current = next;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActivePane(): void {
|
||||||
|
setActivePaneInfo(EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivePane(): ActivePaneSnapshot {
|
||||||
|
const [snap, setSnap] = useState<ActivePaneSnapshot>(current);
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = () => setSnap(current);
|
||||||
|
subs.add(sub);
|
||||||
|
sub();
|
||||||
|
return () => {
|
||||||
|
subs.delete(sub);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
@@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
return prev;
|
return prev;
|
||||||
case 'attach_chat_file':
|
case 'attach_chat_file':
|
||||||
return prev;
|
return prev;
|
||||||
|
case 'open_chat_in_active_pane':
|
||||||
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
|
return prev;
|
||||||
case 'session_archived': {
|
case 'session_archived': {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const projects = prev.projects.map((p) => {
|
const projects = prev.projects.map((p) => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { useActivePane } from '@/hooks/useActivePane';
|
||||||
import { Workspace } from '@/components/Workspace';
|
import { Workspace } from '@/components/Workspace';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
|
|
||||||
@@ -11,12 +12,15 @@ export function Session() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [session, setSession] = useState<SessionType | null>(null);
|
const [session, setSession] = useState<SessionType | null>(null);
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const active = useActivePane();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
setProject(null);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.sessions
|
api.sessions
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -24,16 +28,17 @@ export function Session() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSession(s);
|
setSession(s);
|
||||||
setName(s.name);
|
setName(s.name);
|
||||||
// Emit unconditionally — the sidebar's session_loaded handler
|
|
||||||
// updates activeSession; redundant when the session is already in
|
|
||||||
// the recent_sessions cache but harmless. This lets the sidebar
|
|
||||||
// highlight the parent project for deep-linked sessions that
|
|
||||||
// aren't in the cache.
|
|
||||||
sessionEvents.emit({
|
sessionEvents.emit({
|
||||||
type: 'session_loaded',
|
type: 'session_loaded',
|
||||||
session_id: id,
|
session_id: id,
|
||||||
project_id: s.project_id,
|
project_id: s.project_id,
|
||||||
});
|
});
|
||||||
|
// Load project for breadcrumb. Listing is fine — small N, cached by client.
|
||||||
|
api.projects.list().then((projects) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const p = projects.find((x) => x.id === s.project_id);
|
||||||
|
if (p) setProject(p);
|
||||||
|
}).catch(() => {});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -68,26 +73,33 @@ export function Session() {
|
|||||||
}
|
}
|
||||||
const updated = await api.sessions.update(id, { name: trimmed });
|
const updated = await api.sessions.update(id, { name: trimmed });
|
||||||
setSession(updated);
|
setSession(updated);
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'session_renamed',
|
|
||||||
session_id: id,
|
|
||||||
name: trimmed,
|
|
||||||
});
|
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
|
// Server publishes session_renamed via broker.publishUser; no local emit needed.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workspace only sets activeFile for file-browser panes; checking it alone
|
||||||
|
// suffices and is forward-compatible with future pane kinds.
|
||||||
|
const showActiveFile = active.sessionId === id && !!active.activeFile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<header className="border-b px-4 py-2 flex items-center gap-2 shrink-0">
|
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
|
||||||
{session && (
|
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||||
<Link
|
Projects
|
||||||
to={`/project/${session.project_id}`}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label="Back to project"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||||
|
{project ? (
|
||||||
|
<Link
|
||||||
|
to={`/project/${project.id}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
||||||
|
title={project.name}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60">…</span>
|
||||||
)}
|
)}
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -106,14 +118,27 @@ export function Session() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-medium hover:underline"
|
className="text-sm font-medium hover:underline truncate max-w-[280px]"
|
||||||
onClick={() => setEditingName(true)}
|
onClick={() => setEditingName(true)}
|
||||||
|
title={session?.name ?? ''}
|
||||||
>
|
>
|
||||||
{session?.name ?? '…'}
|
{session?.name ?? '…'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{showActiveFile && active.activeFile && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40 mx-1">·</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
|
||||||
|
title={active.activeFile}
|
||||||
|
>
|
||||||
|
{active.activeFile}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{session && (
|
{session && (
|
||||||
|
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
value={session.model}
|
value={session.model}
|
||||||
onChange={async (model) => {
|
onChange={async (model) => {
|
||||||
@@ -121,6 +146,7 @@ export function Session() {
|
|||||||
setSession(updated);
|
setSession(updated);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user