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(
|
interface TurnArgs {
|
||||||
ctx: InferenceContext,
|
sessionId: string;
|
||||||
sessionId: string,
|
chatId: string;
|
||||||
chatId: string,
|
assistantMessageId: string;
|
||||||
assistantMessageId: string,
|
depth: number;
|
||||||
depth: number,
|
signal: AbortSignal | undefined;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
interface StreamPhaseState {
|
||||||
if (!loaded) {
|
accumulated: string;
|
||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
startedAt: string | null;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
const { session, project, history } = loaded;
|
async function executeStreamPhase(
|
||||||
const projectRoot = await resolveProjectRoot(project.path);
|
ctx: InferenceContext,
|
||||||
const messages = buildMessagesPayload(session, project, history);
|
args: TurnArgs,
|
||||||
|
session: Session,
|
||||||
|
messages: OpenAiMessage[],
|
||||||
|
state: StreamPhaseState
|
||||||
|
): Promise<StreamResult> {
|
||||||
|
const { sessionId, chatId, assistantMessageId, signal } = args;
|
||||||
|
|
||||||
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
const startedRow = await ctx.sql<{ started_at: string }[]>`
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
@@ -388,7 +376,7 @@ async function runAssistantTurn(
|
|||||||
WHERE id = ${assistantMessageId}
|
WHERE id = ${assistantMessageId}
|
||||||
RETURNING started_at
|
RETURNING started_at
|
||||||
`;
|
`;
|
||||||
const startedAt = startedRow[0]?.started_at ?? null;
|
state.startedAt = startedRow[0]?.started_at ?? null;
|
||||||
|
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
@@ -397,7 +385,6 @@ async function runAssistantTurn(
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
});
|
});
|
||||||
|
|
||||||
let accumulated = '';
|
|
||||||
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
let pendingFlushTimer: NodeJS.Timeout | null = null;
|
||||||
let flushPromise: Promise<unknown> = Promise.resolve();
|
let flushPromise: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
@@ -406,7 +393,7 @@ async function runAssistantTurn(
|
|||||||
clearTimeout(pendingFlushTimer);
|
clearTimeout(pendingFlushTimer);
|
||||||
pendingFlushTimer = null;
|
pendingFlushTimer = null;
|
||||||
}
|
}
|
||||||
const snapshot = accumulated;
|
const snapshot = state.accumulated;
|
||||||
flushPromise = flushPromise.then(() =>
|
flushPromise = flushPromise.then(() =>
|
||||||
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
|
||||||
);
|
);
|
||||||
@@ -420,15 +407,14 @@ async function runAssistantTurn(
|
|||||||
}, DB_FLUSH_INTERVAL_MS);
|
}, DB_FLUSH_INTERVAL_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
let result: StreamResult;
|
|
||||||
try {
|
try {
|
||||||
result = await streamCompletion(
|
return await streamCompletion(
|
||||||
ctx,
|
ctx,
|
||||||
session.model,
|
session.model,
|
||||||
messages,
|
messages,
|
||||||
true,
|
true,
|
||||||
(delta) => {
|
(delta) => {
|
||||||
accumulated += delta;
|
state.accumulated += delta;
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
@@ -440,136 +426,162 @@ async function runAssistantTurn(
|
|||||||
},
|
},
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} finally {
|
||||||
if (pendingFlushTimer) {
|
if (pendingFlushTimer) {
|
||||||
clearTimeout(pendingFlushTimer);
|
clearTimeout(pendingFlushTimer);
|
||||||
pendingFlushTimer = null;
|
pendingFlushTimer = null;
|
||||||
}
|
}
|
||||||
await flushPromise;
|
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) {
|
async function handleAbortOrError(
|
||||||
clearTimeout(pendingFlushTimer);
|
ctx: InferenceContext,
|
||||||
pendingFlushTimer = null;
|
args: TurnArgs,
|
||||||
}
|
accumulated: string,
|
||||||
await flushPromise;
|
err: unknown
|
||||||
|
): Promise<void> {
|
||||||
const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
|
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||||
if (toolCalls.length > 0) {
|
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||||
const [updated] = await ctx.sql<
|
await ctx.sql`
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
UPDATE messages
|
||||||
>`
|
SET status = ${finalStatus},
|
||||||
UPDATE messages
|
content = ${accumulated},
|
||||||
SET content = ${content},
|
finished_at = clock_timestamp()
|
||||||
status = 'complete',
|
WHERE id = ${assistantMessageId}
|
||||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
`;
|
||||||
tokens_used = ${completionTokens},
|
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
|
||||||
ctx_used = ${promptTokens},
|
UPDATE sessions SET updated_at = clock_timestamp()
|
||||||
ctx_max = ${nCtx},
|
WHERE id = ${sessionId}
|
||||||
finished_at = clock_timestamp()
|
RETURNING project_id, name, updated_at
|
||||||
WHERE id = ${assistantMessageId}
|
`;
|
||||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
|
||||||
`;
|
if (isAbort) {
|
||||||
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, {
|
ctx.publish(sessionId, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
chat_id: chatId,
|
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,
|
|
||||||
});
|
});
|
||||||
|
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
|
||||||
await Promise.all(
|
} else {
|
||||||
toolCalls.map(async (tc) => {
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
ctx.publish(sessionId, {
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
type: 'error',
|
||||||
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
|
message_id: assistantMessageId,
|
||||||
RETURNING id
|
chat_id: chatId,
|
||||||
`;
|
error: errMsg,
|
||||||
const toolMessageId = toolRow!.id;
|
});
|
||||||
const tres = await executeToolCall(projectRoot, tc);
|
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<
|
const [updated] = await ctx.sql<
|
||||||
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
|
{ 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(
|
export async function runInference(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -622,7 +683,7 @@ export async function runInference(
|
|||||||
assistantMessageId: string,
|
assistantMessageId: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
|
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPACT_SYSTEM_PROMPT =
|
const COMPACT_SYSTEM_PROMPT =
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { DragEvent } from 'react';
|
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-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 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 { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
@@ -22,402 +19,53 @@ interface Props {
|
|||||||
projectId: string;
|
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) {
|
export function Workspace({ sessionId, projectId }: Props) {
|
||||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
const {
|
||||||
return loadPanes(sessionId) ?? [emptyPane()];
|
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[] {
|
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||||
return pane.chatIds
|
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