Compare commits
1 Commits
v1.4.0-for
...
v1.5.0-ref
| Author | SHA1 | Date | |
|---|---|---|---|
| 9436a81b5f |
@@ -348,39 +348,27 @@ async function executeToolCall(
|
||||
}
|
||||
}
|
||||
|
||||
async function runAssistantTurn(
|
||||
ctx: InferenceContext,
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantMessageId: string,
|
||||
depth: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (depth > MAX_TOOL_LOOP_DEPTH) {
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET status = 'failed',
|
||||
content = ${'tool loop depth exceeded'},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantMessageId}
|
||||
`;
|
||||
ctx.publish(sessionId, {
|
||||
type: 'error',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
error: 'tool loop depth exceeded',
|
||||
});
|
||||
return;
|
||||
}
|
||||
interface TurnArgs {
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
assistantMessageId: string;
|
||||
depth: number;
|
||||
signal: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!loaded) {
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||
return;
|
||||
}
|
||||
const { session, project, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(project.path);
|
||||
const messages = buildMessagesPayload(session, project, history);
|
||||
interface StreamPhaseState {
|
||||
accumulated: string;
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
async function executeStreamPhase(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
session: Session,
|
||||
messages: OpenAiMessage[],
|
||||
state: StreamPhaseState
|
||||
): Promise<StreamResult> {
|
||||
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||
|
||||
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
||||
UPDATE messages
|
||||
@@ -388,7 +376,7 @@ async function runAssistantTurn(
|
||||
WHERE id = ${assistantMessageId}
|
||||
RETURNING started_at
|
||||
`;
|
||||
const startedAt = startedRow[0]?.started_at ?? null;
|
||||
state.startedAt = startedRow[0]?.started_at ?? null;
|
||||
|
||||
ctx.publish(sessionId, {
|
||||
type: 'message_started',
|
||||
@@ -397,7 +385,6 @@ async function runAssistantTurn(
|
||||
role: 'assistant',
|
||||
});
|
||||
|
||||
let accumulated = '';
|
||||
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
||||
let flushPromise: Promise<unknown> = Promise.resolve();
|
||||
|
||||
@@ -406,7 +393,7 @@ async function runAssistantTurn(
|
||||
clearTimeout(pendingFlushTimer);
|
||||
pendingFlushTimer = null;
|
||||
}
|
||||
const snapshot = accumulated;
|
||||
const snapshot = state.accumulated;
|
||||
flushPromise = flushPromise.then(() =>
|
||||
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
||||
);
|
||||
@@ -420,15 +407,14 @@ async function runAssistantTurn(
|
||||
}, DB_FLUSH_INTERVAL_MS);
|
||||
};
|
||||
|
||||
let result: StreamResult;
|
||||
try {
|
||||
result = await streamCompletion(
|
||||
return await streamCompletion(
|
||||
ctx,
|
||||
session.model,
|
||||
messages,
|
||||
true,
|
||||
(delta) => {
|
||||
accumulated += delta;
|
||||
state.accumulated += delta;
|
||||
ctx.publish(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: assistantMessageId,
|
||||
@@ -440,136 +426,162 @@ async function runAssistantTurn(
|
||||
},
|
||||
signal
|
||||
);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
if (pendingFlushTimer) {
|
||||
clearTimeout(pendingFlushTimer);
|
||||
pendingFlushTimer = null;
|
||||
}
|
||||
await flushPromise;
|
||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET status = ${finalStatus},
|
||||
content = ${accumulated},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantMessageId}
|
||||
`;
|
||||
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||
UPDATE sessions SET updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING project_id, name, updated_at
|
||||
`;
|
||||
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
|
||||
if (isAbort) {
|
||||
ctx.publish(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
|
||||
} else {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
ctx.publish(sessionId, {
|
||||
type: 'error',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
error: errMsg,
|
||||
});
|
||||
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingFlushTimer) {
|
||||
clearTimeout(pendingFlushTimer);
|
||||
pendingFlushTimer = null;
|
||||
}
|
||||
await flushPromise;
|
||||
|
||||
const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result;
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
const [updated] = await ctx.sql<
|
||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||
>`
|
||||
UPDATE messages
|
||||
SET content = ${content},
|
||||
status = 'complete',
|
||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
||||
tokens_used = ${completionTokens},
|
||||
ctx_used = ${promptTokens},
|
||||
ctx_max = ${nCtx},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantMessageId}
|
||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||
`;
|
||||
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||
UPDATE sessions SET updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING project_id, name, updated_at
|
||||
`;
|
||||
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at });
|
||||
for (const tc of toolCalls) {
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
tool_call: tc,
|
||||
});
|
||||
}
|
||||
async function handleAbortOrError(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
accumulated: string,
|
||||
err: unknown
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId } = args;
|
||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET status = ${finalStatus},
|
||||
content = ${accumulated},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantMessageId}
|
||||
`;
|
||||
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||
UPDATE sessions SET updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING project_id, name, updated_at
|
||||
`;
|
||||
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
|
||||
if (isAbort) {
|
||||
ctx.publish(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
tokens_used: updated?.tokens_used ?? null,
|
||||
ctx_used: updated?.ctx_used ?? null,
|
||||
ctx_max: updated?.ctx_max ?? null,
|
||||
started_at: startedAt,
|
||||
finished_at: updated?.finished_at ?? null,
|
||||
model: session.model,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
toolCalls.map(async (tc) => {
|
||||
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const toolMessageId = toolRow!.id;
|
||||
const tres = await executeToolCall(projectRoot, tc);
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: tres.output,
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: toolMessageId,
|
||||
chat_id: chatId,
|
||||
tool_call_id: tc.id,
|
||||
output: tres.output,
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
|
||||
return;
|
||||
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
|
||||
} else {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
ctx.publish(sessionId, {
|
||||
type: 'error',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
error: errMsg,
|
||||
});
|
||||
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeToolPhase(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
result: StreamResult,
|
||||
startedAt: string | null,
|
||||
session: Session,
|
||||
projectRoot: string
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId, depth, signal } = args;
|
||||
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
|
||||
|
||||
const [updated] = await ctx.sql<
|
||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||
>`
|
||||
UPDATE messages
|
||||
SET content = ${content},
|
||||
status = 'complete',
|
||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
||||
tokens_used = ${completionTokens},
|
||||
ctx_used = ${promptTokens},
|
||||
ctx_max = ${nCtx},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantMessageId}
|
||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||
`;
|
||||
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||
UPDATE sessions SET updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING project_id, name, updated_at
|
||||
`;
|
||||
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at });
|
||||
for (const tc of toolCalls) {
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
tool_call: tc,
|
||||
});
|
||||
}
|
||||
ctx.publish(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
tokens_used: updated?.tokens_used ?? null,
|
||||
ctx_used: updated?.ctx_used ?? null,
|
||||
ctx_max: updated?.ctx_max ?? null,
|
||||
started_at: startedAt,
|
||||
finished_at: updated?.finished_at ?? null,
|
||||
model: session.model,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
toolCalls.map(async (tc) => {
|
||||
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const toolMessageId = toolRow!.id;
|
||||
const tres = await executeToolCall(projectRoot, tc);
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: tres.output,
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: toolMessageId,
|
||||
chat_id: chatId,
|
||||
tool_call_id: tc.id,
|
||||
output: tres.output,
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await runAssistantTurn(ctx, {
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantMessageId: nextAssistant!.id,
|
||||
depth: depth + 1,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function finalizeCompletion(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
result: StreamResult,
|
||||
startedAt: string | null,
|
||||
session: Session
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId } = args;
|
||||
const { content, finishReason, promptTokens, completionTokens, nCtx } = result;
|
||||
|
||||
const [updated] = await ctx.sql<
|
||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
||||
@@ -615,6 +627,55 @@ async function runAssistantTurn(
|
||||
);
|
||||
}
|
||||
|
||||
async function runAssistantTurn(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId, depth } = args;
|
||||
|
||||
if (depth > MAX_TOOL_LOOP_DEPTH) {
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET status = 'failed',
|
||||
content = ${'tool loop depth exceeded'},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantMessageId}
|
||||
`;
|
||||
ctx.publish(sessionId, {
|
||||
type: 'error',
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
error: 'tool loop depth exceeded',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!loaded) {
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||
return;
|
||||
}
|
||||
const { session, project, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(project.path);
|
||||
const messages = buildMessagesPayload(session, project, history);
|
||||
|
||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||
let result: StreamResult;
|
||||
try {
|
||||
result = await executeStreamPhase(ctx, args, session, messages, state);
|
||||
} catch (err) {
|
||||
await handleAbortOrError(ctx, args, state.accumulated, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.toolCalls.length > 0) {
|
||||
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
||||
}
|
||||
|
||||
export async function runInference(
|
||||
ctx: InferenceContext,
|
||||
sessionId: string,
|
||||
@@ -622,7 +683,7 @@ export async function runInference(
|
||||
assistantMessageId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
|
||||
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal });
|
||||
}
|
||||
|
||||
const COMPACT_SYSTEM_PROMPT =
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
||||
import { useSessionChats } from '@/hooks/useSessionChats';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
@@ -22,402 +19,53 @@ interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const MAX_PANES = 5;
|
||||
const STORAGE_KEY = 'boocode.workspace.panes';
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function emptyPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as WorkspacePane[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
|
||||
} catch { /* quota or disabled */ }
|
||||
}
|
||||
|
||||
export function Workspace({ sessionId, projectId }: Props) {
|
||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
||||
return loadPanes(sessionId) ?? [emptyPane()];
|
||||
const {
|
||||
panes,
|
||||
activePaneIdx,
|
||||
setActivePaneIdx,
|
||||
activePaneIdxRef,
|
||||
openChatInPane,
|
||||
switchTab,
|
||||
removeTab,
|
||||
closeOtherTabs,
|
||||
closeTabsToRight,
|
||||
closeAllTabs,
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
removePane,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
handlePaneDrop,
|
||||
handlePaneDragEnd,
|
||||
dragOverIdx,
|
||||
draggingIdxRef,
|
||||
} = useWorkspacePanes(sessionId);
|
||||
|
||||
// Thin wrapper so useSessionChats can route open_chat_in_active_pane events
|
||||
// without knowing about pane indexing.
|
||||
const openChatInActivePane = useCallback(
|
||||
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
||||
[openChatInPane, activePaneIdxRef],
|
||||
);
|
||||
|
||||
const {
|
||||
chats,
|
||||
createChat,
|
||||
archiveChat,
|
||||
unarchiveChat,
|
||||
deleteChat,
|
||||
renameChat,
|
||||
handleLandingSend,
|
||||
} = useSessionChats(sessionId, {
|
||||
removeChatFromPanes,
|
||||
openChatInPane,
|
||||
openChatInActivePane,
|
||||
initializeFirstChatIfEmpty,
|
||||
});
|
||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const chatsRef = useRef<Chat[]>([]);
|
||||
chatsRef.current = chats;
|
||||
const draggingIdxRef = useRef<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.chats.listForSession(sessionId).then((list) => {
|
||||
if (cancelled) return;
|
||||
setChats(list);
|
||||
const openChat = list.find((c) => c.status === 'open');
|
||||
if (openChat) {
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 1 && prev[0]!.kind === 'empty') {
|
||||
return [chatPane(openChat.id)];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
savePanes(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(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === event.chat.id)) return prev;
|
||||
return [event.chat, ...prev];
|
||||
});
|
||||
}
|
||||
if (event.type === 'chat_updated') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
|
||||
));
|
||||
}
|
||||
if (event.type === 'chat_archived') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
|
||||
));
|
||||
removeChatFromPanes(event.chat_id);
|
||||
}
|
||||
if (event.type === 'chat_unarchived') {
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === event.chat.id)) {
|
||||
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
|
||||
}
|
||||
return [event.chat, ...prev];
|
||||
});
|
||||
}
|
||||
if (event.type === 'chat_deleted') {
|
||||
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
||||
removeChatFromPanes(event.chat_id);
|
||||
}
|
||||
if (event.type === 'open_chat_in_active_pane') {
|
||||
openChatInPane(activePaneIdxRef.current, event.chat_id);
|
||||
}
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
function removeChatFromPanes(chatId: string) {
|
||||
setPanes((prev) => prev.map((p) => {
|
||||
const idx = p.chatIds.indexOf(chatId);
|
||||
if (idx < 0) return p;
|
||||
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||
return {
|
||||
...p,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const existing = pane.chatIds.indexOf(chatId);
|
||||
if (existing >= 0) {
|
||||
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
||||
} else {
|
||||
const newIds = [...pane.chatIds, chatId];
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
kind: 'chat',
|
||||
chatId,
|
||||
chatIds: newIds,
|
||||
activeChatIdx: newIds.length - 1,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setActivePaneIdx(paneIdx);
|
||||
}, []);
|
||||
|
||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const chatId = pane.chatIds[tabIdx];
|
||||
if (!chatId) return prev;
|
||||
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeTab = useCallback((paneIdx: number, chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
} else {
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep only the right-clicked tab open in this pane.
|
||||
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const keepIdx = pane.chatIds.indexOf(keepChatId);
|
||||
if (keepIdx < 0) return prev;
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
kind: 'chat',
|
||||
chatId: keepChatId,
|
||||
chatIds: [keepChatId],
|
||||
activeChatIdx: 0,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close every tab to the right of the right-clicked one.
|
||||
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
|
||||
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
|
||||
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close every tab in this pane; land on landing page.
|
||||
const closeAllTabs = useCallback((paneIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createChat = useCallback(async (paneIdx: number) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||
return [chat, ...prev];
|
||||
});
|
||||
openChatInPane(paneIdx, chat.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
||||
}
|
||||
}, [sessionId, openChatInPane]);
|
||||
|
||||
const archiveChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
await api.chats.archive(chatId);
|
||||
// Server publishes chat_archived; bus forwarder updates state.
|
||||
} catch (err) {
|
||||
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');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
await api.chats.remove(chatId);
|
||||
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
||||
removeChatFromPanes(chatId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameChat = useCallback(async (chatId: string, name: string) => {
|
||||
try {
|
||||
await api.chats.update(chatId, { name });
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === chatId ? { ...c, name } : c
|
||||
));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showLandingPage = useCallback((paneIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
||||
if (kind === 'terminal') {
|
||||
toast('Terminal panes coming in BooTerm');
|
||||
return;
|
||||
}
|
||||
if (kind === 'agent') {
|
||||
toast('Agent panes coming in BooCoder');
|
||||
return;
|
||||
}
|
||||
setPanes((prev) => {
|
||||
if (prev.length >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, emptyPane()];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removePane = useCallback((idx: number) => {
|
||||
setPanes((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePaneDragStart = useCallback(
|
||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
draggingIdxRef.current = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaneDragOver = useCallback(
|
||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
if (draggingIdxRef.current === null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverIdx !== idx) setDragOverIdx(idx);
|
||||
},
|
||||
[dragOverIdx]
|
||||
);
|
||||
|
||||
const handlePaneDragLeave = useCallback(() => {
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handlePaneDrop = useCallback(
|
||||
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const fromIdx = draggingIdxRef.current;
|
||||
draggingIdxRef.current = null;
|
||||
setDragOverIdx(null);
|
||||
if (fromIdx === null || fromIdx === targetIdx) return;
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
if (!moved) return prev;
|
||||
next.splice(targetIdx, 0, moved);
|
||||
// Keep active selection on the same logical pane (the one being dragged).
|
||||
setActivePaneIdx(targetIdx);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaneDragEnd = useCallback(() => {
|
||||
draggingIdxRef.current = null;
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||
return [chat, ...prev];
|
||||
});
|
||||
openChatInPane(paneIdx, chat.id);
|
||||
await api.messages.send(chat.id, content);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
||||
}
|
||||
}, [sessionId, openChatInPane]);
|
||||
|
||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||
return pane.chatIds
|
||||
|
||||
@@ -1,865 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import type {
|
||||
FileBrowserPaneState,
|
||||
FileEntry,
|
||||
Pane,
|
||||
ViewFileResult,
|
||||
} from '@/api/types';
|
||||
import { inferLanguage } from '@/lib/attachments';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
pane: Pane & { kind: 'file_browser' };
|
||||
projectId: string;
|
||||
onStateChange: (state: FileBrowserPaneState) => void;
|
||||
}
|
||||
|
||||
const SHIKI_THEME = 'github-dark';
|
||||
|
||||
function splitShikiLines(html: string): string[] {
|
||||
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
|
||||
if (!match) return [];
|
||||
const inner = match[1]!;
|
||||
const lines = inner.split(/(?=<span class="line">)/);
|
||||
return lines.filter(l => l.trim().length > 0);
|
||||
}
|
||||
|
||||
interface FileViewerProps {
|
||||
code: string;
|
||||
lang: string | null;
|
||||
selectedLines: Set<number>;
|
||||
onLineClick: (lineNo: number, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
function FileViewer({ code, lang, selectedLines, onLineClick }: FileViewerProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!lang) {
|
||||
setLineHtmls(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const result = await codeToHtml(code, { lang, theme: SHIKI_THEME });
|
||||
if (cancelled) return;
|
||||
const lines = splitShikiLines(result);
|
||||
setLineHtmls(lines.length > 0 ? lines : null);
|
||||
} catch (err) {
|
||||
console.warn('shiki failed', err);
|
||||
if (!cancelled) setLineHtmls(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const plainLines = code.split('\n');
|
||||
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
|
||||
|
||||
return (
|
||||
<div className="text-sm font-mono">
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
|
||||
<span className="font-mono">{lang || 'code'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{Array.from({ length: totalLines }, (_, i) => {
|
||||
const lineNo = i + 1;
|
||||
const isSelected = selectedLines.has(lineNo);
|
||||
return (
|
||||
<div
|
||||
key={lineNo}
|
||||
className={cn(
|
||||
'flex',
|
||||
isSelected && 'bg-blue-500/10'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 w-[3ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
onClick={(e) => onLineClick(lineNo, e.shiftKey)}
|
||||
>
|
||||
{lineNo}
|
||||
</button>
|
||||
{lineHtmls ? (
|
||||
<div
|
||||
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
|
||||
// eslint-disable-next-line react/no-danger -- Shiki generates sanitized HTML spans, not user content
|
||||
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
|
||||
{plainLines[i] ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
if (!path) return '';
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
function joinPath(parent: string, name: string): string {
|
||||
if (!parent || parent === '.' || parent === '') return name;
|
||||
return `${parent}/${name}`;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
parentPath: string; // '' for root children
|
||||
entries: FileEntry[];
|
||||
cache: Map<string, FileEntry[]>;
|
||||
expanded: Set<string>;
|
||||
openFile: string | null;
|
||||
highlightedPath: string | null;
|
||||
depth: number;
|
||||
onToggleDir: (dirPath: string) => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
setHighlightedPath: (p: string) => void;
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
parentPath,
|
||||
entries,
|
||||
cache,
|
||||
expanded,
|
||||
openFile,
|
||||
highlightedPath,
|
||||
depth,
|
||||
onToggleDir,
|
||||
onSelectFile,
|
||||
setHighlightedPath,
|
||||
}: TreeNodeProps) {
|
||||
// Sort: dirs first, then files; alphabetical within each.
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...entries];
|
||||
copy.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return copy;
|
||||
}, [entries]);
|
||||
|
||||
return (
|
||||
<ul className="list-none">
|
||||
{sorted.map((entry) => {
|
||||
const fullPath = joinPath(parentPath, entry.name);
|
||||
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
|
||||
const isActive = entry.kind === 'file' && openFile === fullPath;
|
||||
const isHighlight = highlightedPath === fullPath;
|
||||
return (
|
||||
<li key={fullPath}>
|
||||
<div
|
||||
data-path={fullPath}
|
||||
data-kind={entry.kind}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
|
||||
isActive && 'bg-muted',
|
||||
isHighlight && 'ring-1 ring-ring/40'
|
||||
)}
|
||||
style={{ paddingLeft: 4 + depth * 12 }}
|
||||
onClick={() => {
|
||||
setHighlightedPath(fullPath);
|
||||
if (entry.kind === 'dir') {
|
||||
onToggleDir(fullPath);
|
||||
} else {
|
||||
onSelectFile(fullPath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entry.kind === 'dir' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHighlightedPath(fullPath);
|
||||
onToggleDir(fullPath);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={10} />
|
||||
) : (
|
||||
<ChevronRight size={10} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[16px] shrink-0" />
|
||||
)}
|
||||
{entry.kind === 'dir' ? (
|
||||
<Folder size={12} className="text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</div>
|
||||
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
|
||||
<TreeNode
|
||||
parentPath={fullPath}
|
||||
entries={cache.get(fullPath) ?? []}
|
||||
cache={cache}
|
||||
expanded={expanded}
|
||||
openFile={openFile}
|
||||
highlightedPath={highlightedPath}
|
||||
depth={depth + 1}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectFile={onSelectFile}
|
||||
setHighlightedPath={setHighlightedPath}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
|
||||
const openFile = pane.state.open_file ?? null;
|
||||
const filter = pane.state.filter ?? '';
|
||||
const expandedDirs = useMemo(
|
||||
() => pane.state.expanded_dirs ?? [],
|
||||
[pane.state.expanded_dirs]
|
||||
);
|
||||
|
||||
// Local filter (debounced 100ms before pushing to onStateChange)
|
||||
const [filterDraft, setFilterDraft] = useState(filter);
|
||||
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Track previous external filter so we can sync local draft when the
|
||||
// canonical state changes from outside (e.g. server snapshot, other tab).
|
||||
const lastExternalFilter = useRef(filter);
|
||||
useEffect(() => {
|
||||
if (filter !== lastExternalFilter.current) {
|
||||
lastExternalFilter.current = filter;
|
||||
setFilterDraft(filter);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
function onFilterInput(value: string) {
|
||||
setFilterDraft(value);
|
||||
if (filterDebounceRef.current !== null) {
|
||||
clearTimeout(filterDebounceRef.current);
|
||||
}
|
||||
filterDebounceRef.current = setTimeout(() => {
|
||||
filterDebounceRef.current = null;
|
||||
lastExternalFilter.current = value;
|
||||
onStateChange({
|
||||
...pane.state,
|
||||
filter: value,
|
||||
open_file: openFile,
|
||||
expanded_dirs: expandedDirs,
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (filterDebounceRef.current !== null) {
|
||||
clearTimeout(filterDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Full file list fetched once on mount for filter mode (covers unexpanded dirs)
|
||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await api.projects.files(projectId);
|
||||
if (!cancelled) setFullFileList(result.files);
|
||||
} catch {
|
||||
// Silently ignore; filter will fall back to cache-based list
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Intentionally run once per mount (projectId is stable per pane)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
// Directory cache: dirPath -> entries
|
||||
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
|
||||
const [dirErrors, setDirErrors] = useState<Map<string, string>>(new Map());
|
||||
|
||||
const loadDir = useCallback(
|
||||
async (dirPath: string) => {
|
||||
// dirPath '' is root; server expects '.'
|
||||
const apiPath = dirPath === '' ? '.' : dirPath;
|
||||
setLoadingDirs((prev) => {
|
||||
if (prev.has(dirPath)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(dirPath);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.projects.listDir(projectId, apiPath);
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(dirPath, result.entries);
|
||||
return next;
|
||||
});
|
||||
setDirErrors((prev) => {
|
||||
if (!prev.has(dirPath)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(dirPath);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'failed to list directory';
|
||||
setDirErrors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(dirPath, msg);
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setLoadingDirs((prev) => {
|
||||
if (!prev.has(dirPath)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(dirPath);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
// Load root on mount + any expanded dirs from server state.
|
||||
useEffect(() => {
|
||||
if (!cache.has('')) {
|
||||
void loadDir('');
|
||||
}
|
||||
for (const dir of expandedDirs) {
|
||||
if (!cache.has(dir)) {
|
||||
void loadDir(dir);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
// When expandedDirs grows (e.g. user expands), ensure new dir is loaded.
|
||||
useEffect(() => {
|
||||
for (const dir of expandedDirs) {
|
||||
if (!cache.has(dir) && !loadingDirs.has(dir)) {
|
||||
void loadDir(dir);
|
||||
}
|
||||
}
|
||||
}, [expandedDirs, cache, loadingDirs, loadDir]);
|
||||
|
||||
const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]);
|
||||
|
||||
function toggleDir(dirPath: string) {
|
||||
let nextDirs: string[];
|
||||
if (expandedSet.has(dirPath)) {
|
||||
nextDirs = expandedDirs.filter((d) => d !== dirPath);
|
||||
} else {
|
||||
nextDirs = [...expandedDirs, dirPath];
|
||||
}
|
||||
onStateChange({
|
||||
...pane.state,
|
||||
open_file: openFile,
|
||||
filter: filterDraft,
|
||||
expanded_dirs: nextDirs,
|
||||
});
|
||||
}
|
||||
|
||||
function selectFile(path: string) {
|
||||
onStateChange({
|
||||
...pane.state,
|
||||
open_file: path,
|
||||
filter: filterDraft,
|
||||
expanded_dirs: expandedDirs,
|
||||
});
|
||||
}
|
||||
|
||||
function closeOpenFile() {
|
||||
onStateChange({
|
||||
...pane.state,
|
||||
open_file: null,
|
||||
filter: filterDraft,
|
||||
expanded_dirs: expandedDirs,
|
||||
});
|
||||
}
|
||||
|
||||
// Build a flat list of all entries reachable through the loaded cache,
|
||||
// for filter results and keyboard navigation.
|
||||
interface FlatEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: 'file' | 'dir';
|
||||
}
|
||||
|
||||
const flattenedVisible = useMemo<FlatEntry[]>(() => {
|
||||
const result: FlatEntry[] = [];
|
||||
function walk(dirPath: string) {
|
||||
const entries = cache.get(dirPath);
|
||||
if (!entries) return;
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const e of sorted) {
|
||||
const full = joinPath(dirPath, e.name);
|
||||
result.push({ path: full, name: e.name, kind: e.kind });
|
||||
if (e.kind === 'dir' && expandedSet.has(full)) {
|
||||
walk(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk('');
|
||||
return result;
|
||||
}, [cache, expandedSet]);
|
||||
|
||||
const flattenedAll = useMemo<FlatEntry[]>(() => {
|
||||
const result: FlatEntry[] = [];
|
||||
function walk(dirPath: string) {
|
||||
const entries = cache.get(dirPath);
|
||||
if (!entries) return;
|
||||
for (const e of entries) {
|
||||
const full = joinPath(dirPath, e.name);
|
||||
result.push({ path: full, name: e.name, kind: e.kind });
|
||||
if (e.kind === 'dir') walk(full);
|
||||
}
|
||||
}
|
||||
walk('');
|
||||
return result;
|
||||
}, [cache]);
|
||||
|
||||
const trimmedFilter = filterDraft.trim();
|
||||
const filterActive = trimmedFilter.length > 0;
|
||||
|
||||
interface FilterResult {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const filterResults = useMemo<FilterResult[]>(() => {
|
||||
if (!filterActive) return [];
|
||||
const needle = trimmedFilter.toLowerCase();
|
||||
|
||||
if (fullFileList !== null) {
|
||||
// Use complete file list from API; rank filename matches above path-only matches
|
||||
const filenameMatches: string[] = [];
|
||||
const pathOnlyMatches: string[] = [];
|
||||
for (const p of fullFileList) {
|
||||
const lp = p.toLowerCase();
|
||||
if (!lp.includes(needle)) continue;
|
||||
const bn = basename(p).toLowerCase();
|
||||
if (bn.includes(needle)) {
|
||||
filenameMatches.push(p);
|
||||
} else {
|
||||
pathOnlyMatches.push(p);
|
||||
}
|
||||
}
|
||||
filenameMatches.sort((a, b) => a.localeCompare(b));
|
||||
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
|
||||
return [...filenameMatches, ...pathOnlyMatches]
|
||||
.slice(0, 50)
|
||||
.map((p) => ({ path: p, name: basename(p) }));
|
||||
}
|
||||
|
||||
// Fallback: use cache-based flat list (only loaded directories, files only)
|
||||
return flattenedAll
|
||||
.filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle))
|
||||
.slice(0, 50)
|
||||
.map((e) => ({ path: e.path, name: e.name }));
|
||||
}, [filterActive, trimmedFilter, fullFileList, flattenedAll]);
|
||||
|
||||
// Keyboard navigation
|
||||
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
|
||||
const treeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Reset highlight if it falls out of the current list (e.g. when filter
|
||||
// changes or dirs collapse).
|
||||
useEffect(() => {
|
||||
if (!highlightedPath) return;
|
||||
const list = filterActive ? filterResults : flattenedVisible;
|
||||
if (!list.some((e) => e.path === highlightedPath)) {
|
||||
setHighlightedPath(null);
|
||||
}
|
||||
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
|
||||
|
||||
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (filterActive) {
|
||||
if (filterResults.length === 0) return;
|
||||
const idx = highlightedPath
|
||||
? filterResults.findIndex((entry) => entry.path === highlightedPath)
|
||||
: -1;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1);
|
||||
const target = filterResults[next];
|
||||
if (target) setHighlightedPath(target.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = idx <= 0 ? 0 : idx - 1;
|
||||
const target = filterResults[next];
|
||||
if (target) setHighlightedPath(target.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (idx < 0) return;
|
||||
const target = filterResults[idx];
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
// Filter results are always files (API returns only files)
|
||||
selectFile(target.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree mode: use flattenedVisible which has kind info
|
||||
const list = flattenedVisible;
|
||||
if (list.length === 0) return;
|
||||
const idx = highlightedPath
|
||||
? list.findIndex((entry) => entry.path === highlightedPath)
|
||||
: -1;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1);
|
||||
const target = list[next];
|
||||
if (target) setHighlightedPath(target.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = idx <= 0 ? 0 : idx - 1;
|
||||
const target = list[next];
|
||||
if (target) setHighlightedPath(target.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (idx < 0) return;
|
||||
const target = list[idx];
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
if (target.kind === 'dir') {
|
||||
toggleDir(target.path);
|
||||
} else {
|
||||
selectFile(target.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Line selection state
|
||||
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
|
||||
|
||||
function handleLineClick(lineNo: number, shiftKey: boolean) {
|
||||
if (shiftKey && selectionAnchor !== null) {
|
||||
const start = Math.min(selectionAnchor, lineNo);
|
||||
const end = Math.max(selectionAnchor, lineNo);
|
||||
const range = new Set<number>();
|
||||
for (let i = start; i <= end; i++) range.add(i);
|
||||
setSelectedLines(range);
|
||||
} else {
|
||||
setSelectedLines(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lineNo)) {
|
||||
next.delete(lineNo);
|
||||
} else {
|
||||
next.add(lineNo);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectionAnchor(lineNo);
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer state
|
||||
const [viewer, setViewer] = useState<{
|
||||
path: string;
|
||||
state: 'loading' | 'ready' | 'error';
|
||||
result?: ViewFileResult;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFile) {
|
||||
setViewer(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setViewer({ path: openFile, state: 'loading' });
|
||||
(async () => {
|
||||
try {
|
||||
const result = await api.projects.viewFile(projectId, openFile);
|
||||
if (cancelled) return;
|
||||
setViewer({ path: openFile, state: 'ready', result });
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
let message: string;
|
||||
if (err instanceof ApiError) {
|
||||
const apiMsg =
|
||||
typeof err.body === 'object' &&
|
||||
err.body !== null &&
|
||||
'error' in err.body
|
||||
? String((err.body as { error: unknown }).error)
|
||||
: err.message;
|
||||
if (err.status === 404) {
|
||||
message = 'File not found';
|
||||
} else if (apiMsg.toLowerCase().includes('too large')) {
|
||||
message = 'File too large to view';
|
||||
} else if (
|
||||
apiMsg.toLowerCase().includes('outside') ||
|
||||
apiMsg.toLowerCase().includes('not a file') ||
|
||||
apiMsg.toLowerCase().includes('path')
|
||||
) {
|
||||
message = 'Cannot view files outside project';
|
||||
} else {
|
||||
message = apiMsg;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message;
|
||||
} else {
|
||||
message = 'Failed to load file';
|
||||
}
|
||||
setViewer({ path: openFile, state: 'error', error: message });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [openFile, projectId]);
|
||||
|
||||
// Clear line selection when open file changes
|
||||
useEffect(() => {
|
||||
setSelectedLines(new Set());
|
||||
setSelectionAnchor(null);
|
||||
}, [openFile]);
|
||||
|
||||
// Compute selection range for the floating action bar (loop avoids call-stack limit on spread)
|
||||
let selectionMin = 0;
|
||||
let selectionMax = 0;
|
||||
if (selectedLines.size > 0) {
|
||||
for (const n of selectedLines) {
|
||||
if (selectionMin === 0 || n < selectionMin) selectionMin = n;
|
||||
if (n > selectionMax) selectionMax = n;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAttachLines() {
|
||||
if (!openFile || !viewer?.result || selectedLines.size === 0) return;
|
||||
const min = selectionMin;
|
||||
const max = selectionMax;
|
||||
const selectedContent = viewer.result.content
|
||||
.split('\n')
|
||||
.slice(min - 1, max)
|
||||
.join('\n');
|
||||
sessionEvents.emit({
|
||||
type: 'attach_chat_file',
|
||||
attachment: {
|
||||
kind: 'lines',
|
||||
filename: openFile,
|
||||
language: inferLanguage(openFile) ?? null,
|
||||
content: selectedContent,
|
||||
range: [min, max],
|
||||
source: 'line-select',
|
||||
},
|
||||
});
|
||||
setSelectedLines(new Set());
|
||||
setSelectionAnchor(null);
|
||||
}
|
||||
|
||||
// Root errors / loading
|
||||
const rootEntries = cache.get('');
|
||||
const rootLoading = loadingDirs.has('') && !rootEntries;
|
||||
const rootError = dirErrors.get('');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-2 py-1.5 border-b border-border bg-muted/20">
|
||||
<input
|
||||
type="text"
|
||||
value={filterDraft}
|
||||
onChange={(e) => onFilterInput(e.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring"
|
||||
aria-label="Filter files"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 grid grid-cols-[minmax(0,260px)_1fr]">
|
||||
<div
|
||||
ref={treeRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
className="overflow-y-auto border-r border-border outline-none focus:ring-1 focus:ring-inset focus:ring-ring/40"
|
||||
role="tree"
|
||||
aria-label="Project files"
|
||||
>
|
||||
{rootLoading && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1.5">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{rootError && (
|
||||
<div className="text-xs text-destructive px-2 py-1.5">
|
||||
{rootError}
|
||||
</div>
|
||||
)}
|
||||
{!rootLoading && !rootError && filterActive && (
|
||||
<ul className="list-none">
|
||||
{filterResults.length === 0 ? (
|
||||
<li className="text-xs text-muted-foreground px-2 py-1.5">
|
||||
No matches
|
||||
</li>
|
||||
) : (
|
||||
filterResults.map((entry) => {
|
||||
const isActive = openFile === entry.path;
|
||||
const isHighlight = highlightedPath === entry.path;
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
|
||||
isActive && 'bg-muted',
|
||||
isHighlight && 'ring-1 ring-ring/40'
|
||||
)}
|
||||
onClick={() => {
|
||||
setHighlightedPath(entry.path);
|
||||
selectFile(entry.path);
|
||||
}}
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate">
|
||||
<span className="font-bold">{entry.name}</span>
|
||||
<span className="text-muted-foreground ml-1">{entry.path}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{!rootLoading && !rootError && !filterActive && rootEntries && (
|
||||
<TreeNode
|
||||
parentPath=""
|
||||
entries={rootEntries}
|
||||
cache={cache}
|
||||
expanded={expandedSet}
|
||||
openFile={openFile}
|
||||
highlightedPath={highlightedPath}
|
||||
depth={0}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={selectFile}
|
||||
setHighlightedPath={setHighlightedPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-h-0">
|
||||
{!openFile && (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Select a file to view
|
||||
</div>
|
||||
)}
|
||||
{openFile && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<span
|
||||
className="text-xs font-mono truncate"
|
||||
title={openFile}
|
||||
>
|
||||
{basename(openFile)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOpenFile}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
aria-label="Close file"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto relative">
|
||||
{viewer?.state === 'loading' && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1.5">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{viewer?.state === 'error' && (
|
||||
<div className="text-xs text-destructive px-2 py-1.5">
|
||||
{viewer.error}
|
||||
</div>
|
||||
)}
|
||||
{viewer?.state === 'ready' && viewer.result && (
|
||||
<div className="p-2">
|
||||
{selectedLines.size > 0 && (
|
||||
<div className="sticky top-0 z-10 bg-muted border-b border-border flex items-center justify-between px-2 py-1 mb-2 rounded-t">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedLines.size === 1
|
||||
? `Attach line ${selectionMin} to chat`
|
||||
: `Attach lines ${selectionMin}–${selectionMax} to chat`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
onClick={handleAttachLines}
|
||||
>
|
||||
Attach
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{viewer.result.truncated && (
|
||||
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border">
|
||||
Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
|
||||
</div>
|
||||
)}
|
||||
<FileViewer
|
||||
code={viewer.result.content}
|
||||
lang={inferLanguage(openFile)}
|
||||
selectedLines={selectedLines}
|
||||
onLineClick={handleLineClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
apps/web/src/hooks/useSessionChats.ts
Normal file
175
apps/web/src/hooks/useSessionChats.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Chat } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
export interface UseSessionChatsOpts {
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
openChatInPane: (paneIdx: number, chatId: string) => void;
|
||||
// Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId);
|
||||
// built by Workspace and passed in so this hook doesn't need to know
|
||||
// about pane indexing.
|
||||
openChatInActivePane: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export interface UseSessionChatsResult {
|
||||
chats: Chat[];
|
||||
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
|
||||
createChat: (paneIdx: number) => Promise<void>;
|
||||
archiveChat: (chatId: string) => Promise<void>;
|
||||
unarchiveChat: (chatId: string) => Promise<void>;
|
||||
deleteChat: (chatId: string) => Promise<void>;
|
||||
renameChat: (chatId: string, name: string) => Promise<void>;
|
||||
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSessionChats(
|
||||
sessionId: string,
|
||||
opts: UseSessionChatsOpts,
|
||||
): UseSessionChatsResult {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const chatsRef = useRef<Chat[]>([]);
|
||||
chatsRef.current = chats;
|
||||
|
||||
// Stable refs to opts callbacks so the subscription effect — which only
|
||||
// re-runs on sessionId change — always sees the latest closures without
|
||||
// unsubscribe/resubscribe churn.
|
||||
const removeChatFromPanesRef = useRef(opts.removeChatFromPanes);
|
||||
removeChatFromPanesRef.current = opts.removeChatFromPanes;
|
||||
const openChatInPaneRef = useRef(opts.openChatInPane);
|
||||
openChatInPaneRef.current = opts.openChatInPane;
|
||||
const openChatInActivePaneRef = useRef(opts.openChatInActivePane);
|
||||
openChatInActivePaneRef.current = opts.openChatInActivePane;
|
||||
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
|
||||
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.chats.listForSession(sessionId).then((list) => {
|
||||
if (cancelled) return;
|
||||
setChats(list);
|
||||
const openChat = list.find((c) => c.status === 'open');
|
||||
if (openChat) {
|
||||
initializeFirstChatIfEmptyRef.current(openChat.id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === event.chat.id)) return prev;
|
||||
return [event.chat, ...prev];
|
||||
});
|
||||
}
|
||||
if (event.type === 'chat_updated') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
|
||||
));
|
||||
}
|
||||
if (event.type === 'chat_archived') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
|
||||
));
|
||||
removeChatFromPanesRef.current(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));
|
||||
removeChatFromPanesRef.current(event.chat_id);
|
||||
}
|
||||
if (event.type === 'open_chat_in_active_pane') {
|
||||
openChatInActivePaneRef.current(event.chat_id);
|
||||
}
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const createChat = useCallback(async (paneIdx: number) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||
return [chat, ...prev];
|
||||
});
|
||||
openChatInPaneRef.current(paneIdx, chat.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const archiveChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
await api.chats.archive(chatId);
|
||||
// Server publishes chat_archived; bus forwarder updates state.
|
||||
} catch (err) {
|
||||
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');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
await api.chats.remove(chatId);
|
||||
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
||||
removeChatFromPanesRef.current(chatId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameChat = useCallback(async (chatId: string, name: string) => {
|
||||
try {
|
||||
await api.chats.update(chatId, { name });
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === chatId ? { ...c, name } : c
|
||||
));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||
return [chat, ...prev];
|
||||
});
|
||||
openChatInPaneRef.current(paneIdx, chat.id);
|
||||
await api.messages.send(chat.id, content);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return {
|
||||
chats,
|
||||
setChats,
|
||||
createChat,
|
||||
archiveChat,
|
||||
unarchiveChat,
|
||||
deleteChat,
|
||||
renameChat,
|
||||
handleLandingSend,
|
||||
};
|
||||
}
|
||||
339
apps/web/src/hooks/useWorkspacePanes.ts
Normal file
339
apps/web/src/hooks/useWorkspacePanes.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { WorkspacePane } from '@/api/types';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
|
||||
export const MAX_PANES = 5;
|
||||
const STORAGE_KEY = 'boocode.workspace.panes';
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function emptyPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as WorkspacePane[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
|
||||
} catch { /* quota or disabled */ }
|
||||
}
|
||||
|
||||
export interface UseWorkspacePanesResult {
|
||||
panes: WorkspacePane[];
|
||||
activePaneIdx: number;
|
||||
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||
activePaneIdxRef: React.MutableRefObject<number>;
|
||||
openChatInPane: (paneIdx: number, chatId: string) => void;
|
||||
switchTab: (paneIdx: number, tabIdx: number) => void;
|
||||
removeTab: (paneIdx: number, chatId: string) => void;
|
||||
closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
|
||||
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
|
||||
closeAllTabs: (paneIdx: number) => void;
|
||||
showLandingPage: (paneIdx: number) => void;
|
||||
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
||||
removePane: (idx: number) => void;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragLeave: () => void;
|
||||
handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
|
||||
handlePaneDragEnd: () => void;
|
||||
dragOverIdx: number | null;
|
||||
draggingIdxRef: React.MutableRefObject<number | null>;
|
||||
}
|
||||
|
||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
||||
return loadPanes(sessionId) ?? [emptyPane()];
|
||||
});
|
||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||
const draggingIdxRef = useRef<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
savePanes(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;
|
||||
|
||||
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const existing = pane.chatIds.indexOf(chatId);
|
||||
if (existing >= 0) {
|
||||
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
||||
} else {
|
||||
const newIds = [...pane.chatIds, chatId];
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
kind: 'chat',
|
||||
chatId,
|
||||
chatIds: newIds,
|
||||
activeChatIdx: newIds.length - 1,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setActivePaneIdx(paneIdx);
|
||||
}, []);
|
||||
|
||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const chatId = pane.chatIds[tabIdx];
|
||||
if (!chatId) return prev;
|
||||
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeTab = useCallback((paneIdx: number, chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
} else {
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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 showLandingPage = useCallback((paneIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
||||
if (kind === 'terminal') {
|
||||
toast('Terminal panes coming in BooTerm');
|
||||
return;
|
||||
}
|
||||
if (kind === 'agent') {
|
||||
toast('Agent panes coming in BooCoder');
|
||||
return;
|
||||
}
|
||||
setPanes((prev) => {
|
||||
if (prev.length >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, emptyPane()];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removePane = useCallback((idx: number) => {
|
||||
setPanes((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 1 && prev[0]!.kind === 'empty') {
|
||||
return [chatPane(chatId)];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeChatFromPanes = useCallback((chatId: string) => {
|
||||
setPanes((prev) => prev.map((p) => {
|
||||
const idx = p.chatIds.indexOf(chatId);
|
||||
if (idx < 0) return p;
|
||||
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||
return {
|
||||
...p,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handlePaneDragStart = useCallback(
|
||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
draggingIdxRef.current = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaneDragOver = useCallback(
|
||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
if (draggingIdxRef.current === null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverIdx !== idx) setDragOverIdx(idx);
|
||||
},
|
||||
[dragOverIdx]
|
||||
);
|
||||
|
||||
const handlePaneDragLeave = useCallback(() => {
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handlePaneDrop = useCallback(
|
||||
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const fromIdx = draggingIdxRef.current;
|
||||
draggingIdxRef.current = null;
|
||||
setDragOverIdx(null);
|
||||
if (fromIdx === null || fromIdx === targetIdx) return;
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
if (!moved) return prev;
|
||||
next.splice(targetIdx, 0, moved);
|
||||
// Keep active selection on the same logical pane (the one being dragged).
|
||||
setActivePaneIdx(targetIdx);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaneDragEnd = useCallback(() => {
|
||||
draggingIdxRef.current = null;
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
panes,
|
||||
activePaneIdx,
|
||||
setActivePaneIdx,
|
||||
activePaneIdxRef,
|
||||
openChatInPane,
|
||||
switchTab,
|
||||
removeTab,
|
||||
closeOtherTabs,
|
||||
closeTabsToRight,
|
||||
closeAllTabs,
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
removePane,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
handlePaneDrop,
|
||||
handlePaneDragEnd,
|
||||
dragOverIdx,
|
||||
draggingIdxRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user