Compare commits
2 Commits
v2.8.1-ope
...
v2.8.3-ana
| Author | SHA1 | Date | |
|---|---|---|---|
| a72f7954b4 | |||
| 31d8efe66a |
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
|
||||||
|
// v1 — global view only (no per-project or per-user filtering).
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
session_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionAnalyticsRow {
|
||||||
|
session_id: string;
|
||||||
|
session_name: string;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
last_active_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBreakdownAgg {
|
||||||
|
category: string;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
|
||||||
|
app.get('/api/analytics/summary', async () => {
|
||||||
|
const [row] = await sql<AnalyticsSummary[]>`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||||
|
COUNT(DISTINCT c.session_id)::INT AS session_count
|
||||||
|
FROM agent_sessions a
|
||||||
|
JOIN chats c ON c.id = a.chat_id
|
||||||
|
`;
|
||||||
|
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/sessions — per-session token/cost breakdown.
|
||||||
|
app.get('/api/analytics/sessions', async () => {
|
||||||
|
const rows = await sql<SessionAnalyticsRow[]>`
|
||||||
|
SELECT
|
||||||
|
c.session_id AS session_id,
|
||||||
|
s.name AS session_name,
|
||||||
|
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||||
|
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||||
|
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||||
|
MAX(a.last_active_at) AS last_active_at
|
||||||
|
FROM agent_sessions a
|
||||||
|
JOIN chats c ON c.id = a.chat_id
|
||||||
|
JOIN sessions s ON s.id = c.session_id
|
||||||
|
GROUP BY c.session_id, s.name
|
||||||
|
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
|
||||||
|
`;
|
||||||
|
return { sessions: rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
|
||||||
|
// across all tasks that carry the JSONB field.
|
||||||
|
app.get('/api/analytics/token-breakdown', async () => {
|
||||||
|
const rows = await sql<{ category: string; total_tokens: number }[]>`
|
||||||
|
SELECT
|
||||||
|
key AS category,
|
||||||
|
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
|
||||||
|
FROM tasks,
|
||||||
|
LATERAL jsonb_each(token_breakdown)
|
||||||
|
WHERE token_breakdown IS NOT NULL
|
||||||
|
AND jsonb_typeof(token_breakdown) = 'object'
|
||||||
|
GROUP BY key
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
`;
|
||||||
|
return { categories: rows };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { randomBytes } from 'node:crypto';
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import { resolveWritePath } from './write_guard.js';
|
import { resolveWritePath } from './write_guard.js';
|
||||||
import { locateMatch } from './fuzzy-match.js';
|
import { locateMatch } from './fuzzy-match.js';
|
||||||
import { validateEditResult, formatGuardError } from './edit-guards.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a file atomically: stage to a sibling temp file, then rename over the
|
* Write a file atomically: stage to a sibling temp file, then rename over the
|
||||||
@@ -286,10 +285,6 @@ export async function applyOne(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (plan.kind === 'apply') {
|
if (plan.kind === 'apply') {
|
||||||
const guard = validateEditResult(toLf(raw), plan.updated, change.file_path);
|
|
||||||
if (!guard.ok) {
|
|
||||||
throw new Error(formatGuardError(guard, change.file_path));
|
|
||||||
}
|
|
||||||
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
||||||
await writeFileAtomic(change.file_path, out);
|
await writeFileAtomic(change.file_path, out);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
33
apps/server/src/routes/analytics.ts
Normal file
33
apps/server/src/routes/analytics.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
|
||||||
|
// token-analyzer-ui: context window utilization and token breakdown data.
|
||||||
|
// v1 — global aggregates only.
|
||||||
|
|
||||||
|
export interface ContextWindowStats {
|
||||||
|
avg_ctx_used: number | null;
|
||||||
|
avg_ctx_max: number | null;
|
||||||
|
avg_utilization_pct: number | null;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET /api/analytics/context — average context window utilization across
|
||||||
|
// completed assistant messages that carry ctx_used/ctx_max.
|
||||||
|
app.get('/api/analytics/context', async () => {
|
||||||
|
const [row] = await sql<ContextWindowStats[]>`
|
||||||
|
SELECT
|
||||||
|
AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used,
|
||||||
|
AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max,
|
||||||
|
AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct,
|
||||||
|
COUNT(*)::INT AS message_count
|
||||||
|
FROM messages
|
||||||
|
WHERE role = 'assistant'
|
||||||
|
AND status = 'complete'
|
||||||
|
AND ctx_used IS NOT NULL
|
||||||
|
AND ctx_max IS NOT NULL
|
||||||
|
AND ctx_max > 0
|
||||||
|
`;
|
||||||
|
return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { realpath, stat, readdir, access } from 'node:fs/promises';
|
import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
|
||||||
import { basename, resolve, sep } from 'node:path';
|
import { basename, resolve, sep } from 'node:path';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
|
|||||||
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
||||||
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
||||||
// Returns { git_repo: false } when the path is not a git repository.
|
// Returns { git_repo: false } when the path is not a git repository.
|
||||||
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
|
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
|
||||||
'/api/projects/:id/git/diff',
|
'/api/projects/:id/git/diff',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
|
|||||||
rawMode === 'uncommitted' ? 'uncommitted' :
|
rawMode === 'uncommitted' ? 'uncommitted' :
|
||||||
auto_mode; // no mode param → auto-select (FIX 1)
|
auto_mode; // no mode param → auto-select (FIX 1)
|
||||||
|
|
||||||
const result = await getGitDiff(projectRoot, mode);
|
const ignoreWhitespace = req.query.whitespace === '1';
|
||||||
|
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
||||||
}
|
}
|
||||||
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
|
|||||||
).min(1),
|
).min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WriteFileBody = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/projects/:id/git/stage — stage whole files
|
// POST /api/projects/:id/git/stage — stage whole files
|
||||||
app.post<{ Params: { id: string } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/projects/:id/git/stage',
|
'/api/projects/:id/git/stage',
|
||||||
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /api/projects/:id/write_file — write a file atomically
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/projects/:id/write_file',
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = WriteFileBody.safeParse(req.body);
|
||||||
|
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||||
|
const { id } = req.params;
|
||||||
|
const projectPath = await selectProjectPath(sql, id);
|
||||||
|
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||||
|
let root: string;
|
||||||
|
try { root = await resolveProjectRoot(projectPath); }
|
||||||
|
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||||
|
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
|
||||||
|
// Validate path stays within project root
|
||||||
|
const realTarget = await realpath(target).catch(() => target);
|
||||||
|
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
|
||||||
|
reply.code(403);
|
||||||
|
return { error: 'path escapes project root' };
|
||||||
|
}
|
||||||
|
const tmp = target + '.tmp';
|
||||||
|
try {
|
||||||
|
await writeFile(tmp, body.data.content, 'utf-8');
|
||||||
|
await rename(tmp, target);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
// Clean up tmp on failure
|
||||||
|
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/projects/:id/files
|
// GET /api/projects/:id/files
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
'/api/projects/:id/files',
|
'/api/projects/:id/files',
|
||||||
|
|||||||
@@ -271,7 +271,9 @@ function buildNumstatMap(
|
|||||||
async function getUncommittedDiff(
|
async function getUncommittedDiff(
|
||||||
gitRoot: string,
|
gitRoot: string,
|
||||||
inProgress: string | null,
|
inProgress: string | null,
|
||||||
|
ignoreWhitespace = false,
|
||||||
): Promise<GitDiffResult> {
|
): Promise<GitDiffResult> {
|
||||||
|
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||||
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
||||||
|
|
||||||
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
||||||
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
|
|||||||
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
||||||
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
||||||
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||||
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
|
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||||
hasCommits
|
hasCommits
|
||||||
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
|
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
|
||||||
: runGit(['diff', '--cached'], gitRoot),
|
: runGit(['diff', ...ws, '--cached'], gitRoot),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||||
@@ -347,11 +349,13 @@ async function getCommittedDiff(
|
|||||||
base: string,
|
base: string,
|
||||||
label: string,
|
label: string,
|
||||||
inProgress: string | null,
|
inProgress: string | null,
|
||||||
|
ignoreWhitespace = false,
|
||||||
): Promise<GitDiffResult> {
|
): Promise<GitDiffResult> {
|
||||||
|
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||||
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
||||||
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
||||||
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
||||||
runGit(['diff', base, 'HEAD'], gitRoot),
|
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||||
@@ -383,23 +387,23 @@ async function getCommittedDiff(
|
|||||||
* the directory is not a git repository. On a null committed-mode base, falls
|
* the directory is not a git repository. On a null committed-mode base, falls
|
||||||
* back to uncommitted and labels the result accordingly.
|
* back to uncommitted and labels the result accordingly.
|
||||||
*/
|
*/
|
||||||
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> {
|
export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
|
||||||
const gitRoot = await resolveGitRoot(cwd);
|
const gitRoot = await resolveGitRoot(cwd);
|
||||||
if (!gitRoot) return null;
|
if (!gitRoot) return null;
|
||||||
|
|
||||||
const inProgress = await detectInProgress(gitRoot);
|
const inProgress = await detectInProgress(gitRoot);
|
||||||
|
|
||||||
if (mode === 'uncommitted') {
|
if (mode === 'uncommitted') {
|
||||||
return getUncommittedDiff(gitRoot, inProgress);
|
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { base, label } = await resolveCommittedBase(gitRoot);
|
const { base, label } = await resolveCommittedBase(gitRoot);
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// Fall back to uncommitted with a descriptive label
|
// Fall back to uncommitted with a descriptive label
|
||||||
const result = await getUncommittedDiff(gitRoot, inProgress);
|
const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||||
return { ...result, base_label: label };
|
return { ...result, base_label: label };
|
||||||
}
|
}
|
||||||
return getCommittedDiff(gitRoot, base, label, inProgress);
|
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Home } from '@/pages/Home';
|
|||||||
import { Project } from '@/pages/Project';
|
import { Project } from '@/pages/Project';
|
||||||
import { Session } from '@/pages/Session';
|
import { Session } from '@/pages/Session';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { Analytics } from '@/pages/Analytics';
|
||||||
|
import { Results } from '@/pages/Results';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||||
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
||||||
@@ -95,6 +97,8 @@ function AppShell() {
|
|||||||
<Route path="/project/:id" element={<Project />} />
|
<Route path="/project/:id" element={<Project />} />
|
||||||
<Route path="/session/:id" element={<Session />} />
|
<Route path="/session/:id" element={<Session />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
<Route path="/results" element={<Results />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<MobileRightRailBackdrop />
|
<MobileRightRailBackdrop />
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import type {
|
|||||||
BattleShape,
|
BattleShape,
|
||||||
ContestantShape,
|
ContestantShape,
|
||||||
CrossExaminationShape,
|
CrossExaminationShape,
|
||||||
|
AnalyticsSummary,
|
||||||
|
SessionAnalyticsRow,
|
||||||
|
ContextWindowStats,
|
||||||
|
TokenBreakdownAgg,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
|
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
|
||||||
@@ -159,12 +163,13 @@ export const api = {
|
|||||||
request<{ files: string[] }>(`/api/projects/${id}/files`),
|
request<{ files: string[] }>(`/api/projects/${id}/files`),
|
||||||
git: (id: string) =>
|
git: (id: string) =>
|
||||||
request<GitMeta>(`/api/projects/${id}/git`),
|
request<GitMeta>(`/api/projects/${id}/git`),
|
||||||
gitDiff: (id: string, mode: GitDiffMode | null) =>
|
gitDiff: (id: string, mode: GitDiffMode | null, hideWhitespace?: boolean) => {
|
||||||
request<GitDiffResult>(
|
const params: string[] = [];
|
||||||
mode !== null
|
if (mode !== null) params.push(`mode=${mode}`);
|
||||||
? `/api/projects/${id}/git/diff?mode=${mode}`
|
if (hideWhitespace) params.push('whitespace=1');
|
||||||
: `/api/projects/${id}/git/diff`,
|
const qs = params.length > 0 ? `?${params.join('&')}` : '';
|
||||||
),
|
return request<GitDiffResult>(`/api/projects/${id}/git/diff${qs}`);
|
||||||
|
},
|
||||||
gitStage: (id: string, files: string[]) =>
|
gitStage: (id: string, files: string[]) =>
|
||||||
request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
|
request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -185,6 +190,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ files }),
|
body: JSON.stringify({ files }),
|
||||||
}),
|
}),
|
||||||
|
writeFile: (id: string, filePath: string, content: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/projects/${id}/write_file`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: filePath, content }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -590,6 +600,14 @@ export const api = {
|
|||||||
costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'),
|
costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// token-analyzer-ui: analytics aggregate endpoints.
|
||||||
|
analytics: {
|
||||||
|
summary: () => request<AnalyticsSummary>('/api/coder/analytics/summary'),
|
||||||
|
sessions: () => request<{ sessions: SessionAnalyticsRow[] }>('/api/coder/analytics/sessions'),
|
||||||
|
context: () => request<ContextWindowStats>('/api/analytics/context'),
|
||||||
|
tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'),
|
||||||
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
get: () => request<Record<string, unknown>>('/api/settings'),
|
get: () => request<Record<string, unknown>>('/api/settings'),
|
||||||
patch: (body: Record<string, unknown>) =>
|
patch: (body: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -627,3 +627,32 @@ export type WsFrame =
|
|||||||
analysis_ready?: boolean;
|
analysis_ready?: boolean;
|
||||||
cross_exam_id?: string;
|
cross_exam_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// token-analyzer-ui: aggregate token/cost analytics types.
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
session_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionAnalyticsRow {
|
||||||
|
session_id: string;
|
||||||
|
session_name: string;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_cost: number;
|
||||||
|
last_active_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextWindowStats {
|
||||||
|
avg_ctx_used: number | null;
|
||||||
|
avg_ctx_max: number | null;
|
||||||
|
avg_utilization_pct: number | null;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBreakdownAgg {
|
||||||
|
category: string;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|||||||
206
apps/web/src/components/DiffSplitView.tsx
Normal file
206
apps/web/src/components/DiffSplitView.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||||
|
import { codeToHtml } from 'shiki';
|
||||||
|
import type { GitDiffFile } from '@/api/types';
|
||||||
|
import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout';
|
||||||
|
import { inferLanguage } from '@/lib/attachments';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DiffSplitViewProps {
|
||||||
|
file: GitDiffFile;
|
||||||
|
wrapLines?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Side-by-side split diff renderer. Left = deletions, right = additions. */
|
||||||
|
export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) {
|
||||||
|
// ── Edge cases (rendered before hooks) ──────────────────────────────────
|
||||||
|
if (file.is_binary) {
|
||||||
|
return <p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>;
|
||||||
|
}
|
||||||
|
if (file.is_too_large) {
|
||||||
|
return <p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>;
|
||||||
|
}
|
||||||
|
if (file.change_type === 'untracked' && !file.diff_body) {
|
||||||
|
return <p className="text-xs text-muted-foreground italic px-2 py-1">Untracked file</p>;
|
||||||
|
}
|
||||||
|
if (!file.diff_body) {
|
||||||
|
return <p className="text-xs text-muted-foreground italic px-2 py-1">No diff content</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DiffSplitViewInner file={file} wrapLines={wrapLines} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner component — assumes file.diff_body is non-null.
|
||||||
|
* Separated so the early-return edge cases above don't violate rules of hooks.
|
||||||
|
*/
|
||||||
|
function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) {
|
||||||
|
// ── Parse diff ───────────────────────────────────────────────────────────
|
||||||
|
const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]);
|
||||||
|
const parsedFile = parsed[0];
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!parsedFile) return [] as SplitRow[];
|
||||||
|
return buildSplitRows(parsedFile);
|
||||||
|
}, [parsedFile]);
|
||||||
|
|
||||||
|
const newContent = useMemo(() => {
|
||||||
|
if (!parsedFile) return '';
|
||||||
|
return reconstructNewContent(parsedFile.hunks);
|
||||||
|
}, [parsedFile]);
|
||||||
|
|
||||||
|
// ── Syntax highlighting ──────────────────────────────────────────────────
|
||||||
|
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
|
||||||
|
const [highlighting, setHighlighting] = useState(false);
|
||||||
|
const highlightKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!newContent) return;
|
||||||
|
if (highlightKeyRef.current === newContent) return;
|
||||||
|
highlightKeyRef.current = newContent;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setHighlighting(true);
|
||||||
|
setHighlightedLines(null);
|
||||||
|
|
||||||
|
const lang = inferLanguage(file.path) ?? 'plaintext';
|
||||||
|
|
||||||
|
void codeToHtml(newContent, { lang, theme: 'github-dark' })
|
||||||
|
.then((html) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// eslint-disable-next-line no-unsanitized/property
|
||||||
|
container.innerHTML = html;
|
||||||
|
const codeEl = container.querySelector('code');
|
||||||
|
if (codeEl) {
|
||||||
|
const lineSpans = codeEl.querySelectorAll('.line');
|
||||||
|
setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML));
|
||||||
|
} else {
|
||||||
|
setHighlightedLines(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setHighlightedLines(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setHighlighting(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [newContent, file.path]);
|
||||||
|
|
||||||
|
// ── Build new-line-number → highlighted-HTML map ───────────────────────
|
||||||
|
// Walk the hunks counting only add/context lines (which form the new file)
|
||||||
|
// and map each 1-based new-line-number to its highlighted HTML string.
|
||||||
|
const newLineHtmlMap = useMemo(() => {
|
||||||
|
if (!highlightedLines || !parsedFile) return new Map<number, string>();
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
let idx = 0;
|
||||||
|
for (const hunk of parsedFile.hunks) {
|
||||||
|
let newLineNo = hunk.newStart;
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.type === 'header') continue;
|
||||||
|
if (line.type === 'add' || line.type === 'context') {
|
||||||
|
if (idx < highlightedLines.length) {
|
||||||
|
map.set(newLineNo, highlightedLines[idx]!);
|
||||||
|
}
|
||||||
|
idx++;
|
||||||
|
newLineNo++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [highlightedLines, parsedFile]);
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className={cn('text-[11px] font-mono overflow-x-auto', wrapLines && 'break-all')}>
|
||||||
|
{highlighting && (
|
||||||
|
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||||
|
)}
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[40px]" />
|
||||||
|
<col />
|
||||||
|
<col className="w-px" />
|
||||||
|
<col className="w-[40px]" />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, idx) => {
|
||||||
|
if (row.kind === 'header') {
|
||||||
|
return (
|
||||||
|
<tr key={`h-${idx}`} className="bg-muted/30">
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="text-muted-foreground text-[11px] px-2 py-0.5 select-none"
|
||||||
|
>
|
||||||
|
{row.content}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = row.left;
|
||||||
|
const right = row.right;
|
||||||
|
|
||||||
|
const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : '';
|
||||||
|
const rightBg = right?.type === 'add' ? 'bg-green-950/30' : '';
|
||||||
|
|
||||||
|
const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined;
|
||||||
|
const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={`p-${idx}`} className="hover:bg-muted/10">
|
||||||
|
<td className={cn(leftBg, 'border-r border-border/20 align-top')}>
|
||||||
|
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
|
||||||
|
{left?.lineNumber ?? ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={cn(leftBg, 'align-top')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pl-2 text-[11px]',
|
||||||
|
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{left ? (
|
||||||
|
leftHtml ? (
|
||||||
|
// eslint-disable-next-line no-unsanitized/property
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: leftHtml }} />
|
||||||
|
) : (
|
||||||
|
<span>{left.content}</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-l border-border/30 w-px p-0" />
|
||||||
|
<td className={cn(rightBg, 'border-r border-border/20 align-top')}>
|
||||||
|
<span className="text-muted-foreground text-right pr-1 select-none text-[11px] block">
|
||||||
|
{right?.lineNumber ?? ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={cn(rightBg, 'align-top')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pl-2 text-[11px]',
|
||||||
|
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{right ? (
|
||||||
|
rightHtml ? (
|
||||||
|
// eslint-disable-next-line no-unsanitized/property
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: rightHtml }} />
|
||||||
|
) : (
|
||||||
|
<span>{right.content}</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react';
|
||||||
import { codeToHtml } from 'shiki';
|
import { codeToHtml } from 'shiki';
|
||||||
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DiffSplitView } from './DiffSplitView';
|
||||||
|
import { InlineReviewGutterCell } from './InlineReviewGutterCell';
|
||||||
|
import { InlineReviewEditor } from './InlineReviewEditor';
|
||||||
|
import { InlineReviewThread } from './InlineReviewThread';
|
||||||
|
import { useDiffComments } from '@/stores/useDiffCommentStore';
|
||||||
|
|
||||||
interface WriteProps {
|
interface WriteProps {
|
||||||
mutating: boolean;
|
mutating: boolean;
|
||||||
@@ -18,12 +23,19 @@ interface Props extends WriteProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
mode: GitDiffMode;
|
mode: GitDiffMode;
|
||||||
|
sessionId?: string;
|
||||||
onSelectMode: (m: GitDiffMode) => void;
|
onSelectMode: (m: GitDiffMode) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
|
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
|
||||||
modeSuggestion?: GitDiffMode | null;
|
modeSuggestion?: GitDiffMode | null;
|
||||||
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
|
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
|
||||||
pendingCount?: number;
|
pendingCount?: number;
|
||||||
|
layout: 'unified' | 'split';
|
||||||
|
wrapLines: boolean;
|
||||||
|
hideWhitespace: boolean;
|
||||||
|
onLayoutChange: (layout: 'unified' | 'split') => void;
|
||||||
|
onWrapLinesChange: (wrap: boolean) => void;
|
||||||
|
onHideWhitespaceChange: (hide: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -99,6 +111,12 @@ function FileDiffRow({
|
|||||||
onStage,
|
onStage,
|
||||||
onUnstage,
|
onUnstage,
|
||||||
onDiscardRequest,
|
onDiscardRequest,
|
||||||
|
layout,
|
||||||
|
wrapLines,
|
||||||
|
expanded,
|
||||||
|
onToggleExpand,
|
||||||
|
sessionId,
|
||||||
|
diffMode,
|
||||||
}: {
|
}: {
|
||||||
file: GitDiffFile;
|
file: GitDiffFile;
|
||||||
uncommitted: boolean;
|
uncommitted: boolean;
|
||||||
@@ -106,11 +124,21 @@ function FileDiffRow({
|
|||||||
onStage: (path: string) => void;
|
onStage: (path: string) => void;
|
||||||
onUnstage: (path: string) => void;
|
onUnstage: (path: string) => void;
|
||||||
onDiscardRequest: (file: GitDiffFile) => void;
|
onDiscardRequest: (file: GitDiffFile) => void;
|
||||||
|
layout: 'unified' | 'split';
|
||||||
|
wrapLines: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpand: (path: string) => void;
|
||||||
|
sessionId?: string;
|
||||||
|
diffMode?: string;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [html, setHtml] = useState<string | null>(null);
|
const [html, setHtml] = useState<string | null>(null);
|
||||||
const [highlighting, setHighlighting] = useState(false);
|
const [highlighting, setHighlighting] = useState(false);
|
||||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const commentKey = `${file.path}:${file.change_type}`;
|
||||||
|
const diffModeVal = diffMode ?? '';
|
||||||
|
const { comments, addComment, updateComment, deleteComment } = useDiffComments(sessionId ?? '', diffModeVal);
|
||||||
|
const fileComments = comments.get(commentKey) ?? [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded || !file.diff_body) return;
|
if (!expanded || !file.diff_body) return;
|
||||||
@@ -136,13 +164,27 @@ function FileDiffRow({
|
|||||||
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
|
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
|
||||||
const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path;
|
const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path;
|
||||||
|
|
||||||
|
const handleAddComment = (body: string) => {
|
||||||
|
const comment = { id: crypto.randomUUID(), body, createdAt: Date.now(), updatedAt: Date.now() };
|
||||||
|
addComment(commentKey, comment);
|
||||||
|
setShowEditor(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditComment = (id: string, body: string) => {
|
||||||
|
updateComment(commentKey, id, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = (id: string) => {
|
||||||
|
deleteComment(commentKey, id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="border-b border-border/30 last:border-0">
|
<li className="border-b border-border/30 last:border-0">
|
||||||
<div className="flex items-center group">
|
<div className="flex items-center group">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
|
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
|
||||||
onClick={() => setExpanded((p) => !p)}
|
onClick={() => onToggleExpand(file.path)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
{expanded
|
{expanded
|
||||||
@@ -203,6 +245,9 @@ function FileDiffRow({
|
|||||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked — not yet staged</p>
|
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked — not yet staged</p>
|
||||||
)}
|
)}
|
||||||
{!file.is_binary && !file.is_too_large && file.diff_body && (
|
{!file.is_binary && !file.is_too_large && file.diff_body && (
|
||||||
|
layout === 'split' ? (
|
||||||
|
<DiffSplitView file={file} wrapLines={wrapLines} />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{highlighting && (
|
{highlighting && (
|
||||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||||
@@ -214,12 +259,40 @@ function FileDiffRow({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
!highlighting && (
|
!highlighting && (
|
||||||
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
|
<pre className={cn(
|
||||||
|
'text-[11px] overflow-x-auto rounded bg-muted/30 p-2',
|
||||||
|
wrapLines ? 'whitespace-pre-wrap break-all' : 'whitespace-pre',
|
||||||
|
)}>
|
||||||
{file.diff_body}
|
{file.diff_body}
|
||||||
</pre>
|
</pre>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{/* Comment button */}
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditor(!showEditor)}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span>{showEditor ? 'Cancel' : 'Comment'}</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
|
{fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showEditor && (
|
||||||
|
<InlineReviewEditor
|
||||||
|
onSave={handleAddComment}
|
||||||
|
onCancel={() => setShowEditor(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InlineReviewThread
|
||||||
|
comments={fileComments}
|
||||||
|
onEditComment={handleEditComment}
|
||||||
|
onDeleteComment={handleDeleteComment}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -242,11 +315,41 @@ export function GitDiffView({
|
|||||||
onDiscard,
|
onDiscard,
|
||||||
modeSuggestion,
|
modeSuggestion,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
|
layout,
|
||||||
|
wrapLines,
|
||||||
|
hideWhitespace,
|
||||||
|
onLayoutChange,
|
||||||
|
onWrapLinesChange,
|
||||||
|
onHideWhitespaceChange,
|
||||||
|
sessionId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [commitMessage, setCommitMessage] = useState('');
|
const [commitMessage, setCommitMessage] = useState('');
|
||||||
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
|
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
|
||||||
const [lastAction, setLastAction] = useState<string | null>(null);
|
const [lastAction, setLastAction] = useState<string | null>(null);
|
||||||
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const allExpandedComputed = useMemo(
|
||||||
|
() => result !== null && (result.files?.length ?? 0) > 0 && result.files.every((f) => expandedFiles.has(f.path)),
|
||||||
|
[result, expandedFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExpandAllChange = useCallback((expand: boolean) => {
|
||||||
|
if (expand && result?.files) {
|
||||||
|
setExpandedFiles(new Set(result.files.map((f) => f.path)));
|
||||||
|
} else {
|
||||||
|
setExpandedFiles(new Set());
|
||||||
|
}
|
||||||
|
}, [result?.files]);
|
||||||
|
|
||||||
|
const handleToggleExpand = useCallback((path: string) => {
|
||||||
|
setExpandedFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
function flashAction(msg: string) {
|
function flashAction(msg: string) {
|
||||||
setLastAction(msg);
|
setLastAction(msg);
|
||||||
@@ -378,6 +481,83 @@ export function GitDiffView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Diff toolbar */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLayoutChange('unified')}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||||||
|
layout === 'unified'
|
||||||
|
? 'bg-muted text-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
title="Unified diff"
|
||||||
|
>
|
||||||
|
<AlignJustify size={12} />
|
||||||
|
Unified
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLayoutChange('split')}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded flex items-center gap-1 max-md:min-h-[44px]',
|
||||||
|
layout === 'split'
|
||||||
|
? 'bg-muted text-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
title="Split diff"
|
||||||
|
>
|
||||||
|
<Columns2 size={12} />
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||||
|
hideWhitespace
|
||||||
|
? 'bg-muted text-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
|
||||||
|
>
|
||||||
|
<Pilcrow size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onWrapLinesChange(!wrapLines)}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||||
|
wrapLines
|
||||||
|
? 'bg-muted text-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
title={wrapLines ? 'Unwrap lines' : 'Wrap lines'}
|
||||||
|
>
|
||||||
|
<WrapText size={12} />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleExpandAllChange(!allExpandedComputed)}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
title={allExpandedComputed ? 'Collapse all' : 'Expand all'}
|
||||||
|
>
|
||||||
|
{allExpandedComputed ? <ListChevronsDownUp size={12} /> : <ListChevronsUpDown size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading || mutating}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Refresh diff"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Committed-mode base label */}
|
{/* Committed-mode base label */}
|
||||||
{result.mode === 'committed' && base_label && (
|
{result.mode === 'committed' && base_label && (
|
||||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
|
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
|
||||||
@@ -445,6 +625,12 @@ export function GitDiffView({
|
|||||||
onStage={handleStage}
|
onStage={handleStage}
|
||||||
onUnstage={handleUnstage}
|
onUnstage={handleUnstage}
|
||||||
onDiscardRequest={handleDiscardRequest}
|
onDiscardRequest={handleDiscardRequest}
|
||||||
|
layout={layout}
|
||||||
|
wrapLines={wrapLines}
|
||||||
|
expanded={expandedFiles.has(file.path)}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
sessionId={sessionId}
|
||||||
|
diffMode={mode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
60
apps/web/src/components/InlineReviewEditor.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface InlineReviewEditorProps {
|
||||||
|
initialBody?: string;
|
||||||
|
onSave: (body: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineReviewEditor({ initialBody = '', onSave, onCancel }: InlineReviewEditorProps) {
|
||||||
|
const [text, setText] = useState(initialBody);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && text.trim()) {
|
||||||
|
onSave(text.trim());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCancel, onSave, text],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-2 my-1 rounded border border-border/80 bg-popover p-2 shadow-sm">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-1.5 mt-1.5 border-t border-border/40 pt-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-2 py-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!text.trim()}
|
||||||
|
onClick={() => onSave(text.trim())}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
43
apps/web/src/components/InlineReviewGutterCell.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InlineReviewGutterCellProps {
|
||||||
|
lineNumber: number | null;
|
||||||
|
type: 'add' | 'remove' | 'context' | 'header' | null;
|
||||||
|
hasComments: boolean;
|
||||||
|
canComment: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineReviewGutterCell({
|
||||||
|
lineNumber,
|
||||||
|
type,
|
||||||
|
hasComments,
|
||||||
|
canComment,
|
||||||
|
onClick,
|
||||||
|
}: InlineReviewGutterCellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center justify-end pr-1 min-w-[40px] h-5 text-[11px] font-mono select-none',
|
||||||
|
type === 'add' && 'bg-green-950/30',
|
||||||
|
type === 'remove' && 'bg-red-950/30',
|
||||||
|
type === 'context' && 'bg-muted/10',
|
||||||
|
canComment && 'cursor-pointer group',
|
||||||
|
)}
|
||||||
|
onClick={canComment ? onClick : undefined}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground/70">
|
||||||
|
{lineNumber != null ? lineNumber : ''}
|
||||||
|
</span>
|
||||||
|
{canComment && (
|
||||||
|
<span className="absolute left-0.5 hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-muted-foreground hover:text-foreground">
|
||||||
|
<Plus size={12} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasComments && (
|
||||||
|
<span className="absolute left-0.5 w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
92
apps/web/src/components/InlineReviewThread.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import type { DiffComment } from '@/stores/useDiffCommentStore';
|
||||||
|
import { InlineReviewEditor } from './InlineReviewEditor';
|
||||||
|
|
||||||
|
interface InlineReviewThreadProps {
|
||||||
|
comments: DiffComment[];
|
||||||
|
onEditComment: (id: string, body: string) => void;
|
||||||
|
onDeleteComment: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineReviewThread({
|
||||||
|
comments,
|
||||||
|
onEditComment,
|
||||||
|
onDeleteComment,
|
||||||
|
}: InlineReviewThreadProps) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editBody, setEditBody] = useState('');
|
||||||
|
|
||||||
|
if (comments.length === 0) return null;
|
||||||
|
|
||||||
|
const handleStartEdit = (id: string, body: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditBody(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = (body: string) => {
|
||||||
|
if (editingId) {
|
||||||
|
onEditComment(editingId, body);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-1 border-l-2 border-blue-400/40 pl-2 my-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground mb-0.5"
|
||||||
|
>
|
||||||
|
<MessageSquare size={10} />
|
||||||
|
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
|
||||||
|
<span className="text-[9px]">{expanded ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="text-xs">
|
||||||
|
{editingId === comment.id ? (
|
||||||
|
<InlineReviewEditor
|
||||||
|
initialBody={editBody}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
onCancel={handleCancelEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-1 group">
|
||||||
|
<span className="flex-1 text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{comment.body}
|
||||||
|
</span>
|
||||||
|
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0 mt-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStartEdit(comment.id, comment.body)}
|
||||||
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDeleteComment(comment.id)}
|
||||||
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
import { BarChart3, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import mascot from '@/assets/brand/banner-mascot.png';
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
@@ -519,11 +519,40 @@ export function ProjectSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* bottom-pinned nav buttons. Results → Analytics → Settings. */}
|
||||||
|
<div className="border-t shrink-0 p-2 space-y-0.5">
|
||||||
|
<NavLink
|
||||||
|
to="/results"
|
||||||
|
onClick={() => { if (isMobile) setDrawerOpen(false); }}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
|
||||||
|
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
aria-label="Results"
|
||||||
|
>
|
||||||
|
<ScrollText className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Results</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/analytics"
|
||||||
|
onClick={() => { if (isMobile) setDrawerOpen(false); }}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
|
||||||
|
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
aria-label="Token Analytics"
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Token Analytics</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||||
the panesHook). Outside a session there's no workspace to mount the
|
the panesHook). Outside a session there's no workspace to mount the
|
||||||
pane in, so we navigate to /settings (themes page) instead. */}
|
pane in, so we navigate to /settings (themes page) instead. */}
|
||||||
<div className="border-t shrink-0 p-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
|||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { useProjectGit } from '@/hooks/useProjectGit';
|
import { useProjectGit } from '@/hooks/useProjectGit';
|
||||||
import { useGitDiff } from '@/hooks/useGitDiff';
|
import { useGitDiff } from '@/hooks/useGitDiff';
|
||||||
|
import { useDiffPreferences } from '@/hooks/useDiffPreferences';
|
||||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||||
import { GitDiffView } from '@/components/GitDiffView';
|
import { GitDiffView } from '@/components/GitDiffView';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -90,6 +91,15 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Diff toolbar state (integration with expandedPaths pending)
|
||||||
|
const { preferences: diffPrefs, updatePreferences: updateDiffPrefs } = useDiffPreferences();
|
||||||
|
|
||||||
|
// File editing state
|
||||||
|
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
const openNewFile = useCallback(() => {
|
const openNewFile = useCallback(() => {
|
||||||
setNewFilePath('');
|
setNewFilePath('');
|
||||||
setNewFileContent('');
|
setNewFileContent('');
|
||||||
@@ -167,6 +177,44 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startEdit(path: string) {
|
||||||
|
setEditingFile(path);
|
||||||
|
setEditLoading(true);
|
||||||
|
setEditError(null);
|
||||||
|
try {
|
||||||
|
const result = await api.projects.viewFile(projectId, path);
|
||||||
|
setEditContent(result.content);
|
||||||
|
} catch {
|
||||||
|
setEditError('Failed to load file');
|
||||||
|
setEditingFile(null);
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editingFile) return;
|
||||||
|
try {
|
||||||
|
await api.projects.writeFile(projectId, editingFile, editContent);
|
||||||
|
setEditingFile(null);
|
||||||
|
setEditContent('');
|
||||||
|
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||||
|
} catch {
|
||||||
|
setEditError('Failed to save file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingFile(null);
|
||||||
|
setEditContent('');
|
||||||
|
setEditError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel edit when switching tabs
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== 'files') cancelEdit();
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
async function openFile(path: string) {
|
async function openFile(path: string) {
|
||||||
try {
|
try {
|
||||||
const result = await api.projects.viewFile(projectId, path);
|
const result = await api.projects.viewFile(projectId, path);
|
||||||
@@ -323,6 +371,30 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
||||||
)
|
)
|
||||||
|
) : editingFile ? (
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden p-2 gap-2">
|
||||||
|
<div className="text-xs font-mono truncate text-muted-foreground">{editingFile}</div>
|
||||||
|
{editLoading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
className="flex-1 font-mono text-xs p-2 rounded border bg-background resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveEdit();
|
||||||
|
if (e.key === 'Escape') cancelEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editError && <p className="text-xs text-destructive">{editError}</p>}
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button type="button" onClick={cancelEdit} className="text-xs px-2 py-1 rounded border hover:bg-muted">Cancel</button>
|
||||||
|
<button type="button" onClick={saveEdit} className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90">Save</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TreeLevel
|
<TreeLevel
|
||||||
parentPath=""
|
parentPath=""
|
||||||
@@ -332,6 +404,7 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
depth={0}
|
depth={0}
|
||||||
onToggleDir={toggleDir}
|
onToggleDir={toggleDir}
|
||||||
onSelectFile={(path) => void openFile(path)}
|
onSelectFile={(path) => void openFile(path)}
|
||||||
|
onEditFile={startEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -345,6 +418,7 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
loading={gitLoading}
|
loading={gitLoading}
|
||||||
error={gitError}
|
error={gitError}
|
||||||
mode={gitMode}
|
mode={gitMode}
|
||||||
|
sessionId={sessionId}
|
||||||
onSelectMode={selectMode}
|
onSelectMode={selectMode}
|
||||||
onRefresh={refreshDiff}
|
onRefresh={refreshDiff}
|
||||||
mutating={gitMutating}
|
mutating={gitMutating}
|
||||||
@@ -355,6 +429,12 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
onDiscard={gitDiscard}
|
onDiscard={gitDiscard}
|
||||||
modeSuggestion={gitModeSuggestion}
|
modeSuggestion={gitModeSuggestion}
|
||||||
pendingCount={pendingCount}
|
pendingCount={pendingCount}
|
||||||
|
layout={diffPrefs.layout}
|
||||||
|
wrapLines={diffPrefs.wrapLines}
|
||||||
|
hideWhitespace={diffPrefs.hideWhitespace}
|
||||||
|
onLayoutChange={(layout) => updateDiffPrefs({ layout })}
|
||||||
|
onWrapLinesChange={(wrapLines) => updateDiffPrefs({ wrapLines })}
|
||||||
|
onHideWhitespaceChange={(hideWhitespace) => updateDiffPrefs({ hideWhitespace })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
@@ -421,9 +501,10 @@ interface TreeLevelProps {
|
|||||||
depth: number;
|
depth: number;
|
||||||
onToggleDir: (dirPath: string) => void;
|
onToggleDir: (dirPath: string) => void;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
|
onEditFile?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
|
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile, onEditFile }: TreeLevelProps) {
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const copy = [...entries];
|
const copy = [...entries];
|
||||||
copy.sort((a, b) => {
|
copy.sort((a, b) => {
|
||||||
@@ -447,6 +528,9 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
|
|||||||
if (entry.kind === 'dir') onToggleDir(fullPath);
|
if (entry.kind === 'dir') onToggleDir(fullPath);
|
||||||
else onSelectFile(fullPath);
|
else onSelectFile(fullPath);
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (entry.kind === 'file') onEditFile?.(fullPath);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{entry.kind === 'dir' ? (
|
{entry.kind === 'dir' ? (
|
||||||
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
|
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
|
||||||
@@ -469,6 +553,7 @@ function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, o
|
|||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onToggleDir={onToggleDir}
|
onToggleDir={onToggleDir}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
onEditFile={onEditFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
68
apps/web/src/hooks/useDiffPreferences.ts
Normal file
68
apps/web/src/hooks/useDiffPreferences.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface DiffPreferences {
|
||||||
|
layout: 'unified' | 'split';
|
||||||
|
wrapLines: boolean;
|
||||||
|
hideWhitespace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES: DiffPreferences = {
|
||||||
|
layout: 'unified',
|
||||||
|
wrapLines: false,
|
||||||
|
hideWhitespace: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'boocode.diff.preferences';
|
||||||
|
|
||||||
|
function loadPreferences(): DiffPreferences {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as Partial<DiffPreferences>;
|
||||||
|
return {
|
||||||
|
layout: parsed.layout ?? DEFAULT_PREFERENCES.layout,
|
||||||
|
wrapLines: parsed.wrapLines ?? DEFAULT_PREFERENCES.wrapLines,
|
||||||
|
hideWhitespace: parsed.hideWhitespace ?? DEFAULT_PREFERENCES.hideWhitespace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreferences(prefs: DiffPreferences): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiffPreferences(): {
|
||||||
|
preferences: DiffPreferences;
|
||||||
|
updatePreferences: (updates: Partial<DiffPreferences>) => void;
|
||||||
|
resetPreferences: () => void;
|
||||||
|
} {
|
||||||
|
const [preferences, setPreferences] = useState<DiffPreferences>(loadPreferences);
|
||||||
|
|
||||||
|
// Sync from localStorage on mount (handles multi-tab changes if we add a storage listener later)
|
||||||
|
useEffect(() => {
|
||||||
|
setPreferences(loadPreferences());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePreferences = useCallback((updates: Partial<DiffPreferences>) => {
|
||||||
|
setPreferences((prev) => {
|
||||||
|
const next = { ...prev, ...updates };
|
||||||
|
savePreferences(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetPreferences = useCallback(() => {
|
||||||
|
setPreferences(DEFAULT_PREFERENCES);
|
||||||
|
savePreferences(DEFAULT_PREFERENCES);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { preferences, updatePreferences, resetPreferences };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { api } from '@/api/client';
|
|||||||
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||||
import { sessionEvents } from './sessionEvents';
|
import { sessionEvents } from './sessionEvents';
|
||||||
|
|
||||||
export function useGitDiff(projectId: string | null | undefined) {
|
export function useGitDiff(projectId: string | null | undefined, hideWhitespace = false) {
|
||||||
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
|
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
|
||||||
const [pinned, setPinned] = useState(false);
|
const [pinned, setPinned] = useState(false);
|
||||||
const [result, setResult] = useState<GitDiffResult | null>(null);
|
const [result, setResult] = useState<GitDiffResult | null>(null);
|
||||||
@@ -23,7 +23,7 @@ export function useGitDiff(projectId: string | null | undefined) {
|
|||||||
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
|
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
|
||||||
// dirty state (dirty → uncommitted, clean → committed).
|
// dirty state (dirty → uncommitted, clean → committed).
|
||||||
api.projects
|
api.projects
|
||||||
.gitDiff(projectId, pinned ? mode : null)
|
.gitDiff(projectId, pinned ? mode : null, hideWhitespace)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!pinned) {
|
if (!pinned) {
|
||||||
setMode(r.mode);
|
setMode(r.mode);
|
||||||
@@ -43,7 +43,7 @@ export function useGitDiff(projectId: string | null | undefined) {
|
|||||||
inFlightRef.current = false;
|
inFlightRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [projectId, mode, pinned]);
|
}, [projectId, mode, pinned, hideWhitespace]);
|
||||||
|
|
||||||
// Re-run refresh when mode changes (user pinned a new mode).
|
// Re-run refresh when mode changes (user pinned a new mode).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,7 +52,7 @@ export function useGitDiff(projectId: string | null | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
}, [projectId, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [projectId, mode, hideWhitespace]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
|
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
454
apps/web/src/pages/Analytics.tsx
Normal file
454
apps/web/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, BarChart3, Wifi, Wrench, Layers } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type {
|
||||||
|
AnalyticsSummary,
|
||||||
|
SessionAnalyticsRow,
|
||||||
|
ToolCostStat,
|
||||||
|
ContextWindowStats,
|
||||||
|
TokenBreakdownAgg,
|
||||||
|
} from '@/api/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// --- Independent section data fetcher ---
|
||||||
|
// Each section manages its own loading/error/data state so one failure doesn't
|
||||||
|
// block the rest of the page.
|
||||||
|
|
||||||
|
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
} {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetcher()
|
||||||
|
.then(setData)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { data, loading, error, retry: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skeleton pulse placeholder ---
|
||||||
|
function SkeletonBar({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Number formatting ---
|
||||||
|
function formatNumber(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (n < 0.001) return `$${(n * 1000).toFixed(2)}m`;
|
||||||
|
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||||
|
return `$${n.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return `${(n * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary Cards ---
|
||||||
|
function SummaryCards({ summary }: { summary: AnalyticsSummary }) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: 'Total Input Tokens',
|
||||||
|
value: formatNumber(summary.total_input_tokens),
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Output Tokens',
|
||||||
|
value: formatNumber(summary.total_output_tokens),
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'text-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Cost',
|
||||||
|
value: formatCost(summary.total_cost),
|
||||||
|
icon: Wifi,
|
||||||
|
color: 'text-amber-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sessions Tracked',
|
||||||
|
value: formatNumber(summary.session_count),
|
||||||
|
icon: Layers,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Card key={c.label} size="sm">
|
||||||
|
<CardContent className="flex items-start gap-3 pt-3">
|
||||||
|
<c.icon className={cn('size-5 shrink-0 mt-0.5', c.color)} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} size="sm">
|
||||||
|
<CardContent className="pt-3">
|
||||||
|
<SkeletonBar className="h-5 w-20 mb-2" />
|
||||||
|
<SkeletonBar className="h-3 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Section wrappers ---
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SkeletonBar className="h-4 w-full" />
|
||||||
|
<SkeletonBar className="h-4 w-3/4" />
|
||||||
|
<SkeletonBar className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-sm text-muted-foreground py-2">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-Session Token Table ---
|
||||||
|
function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return <EmptyState message="No session token data available yet. Token data is collected as agent sessions run." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="py-2 pr-4 font-medium">Session</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Input</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Output</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Cost</th>
|
||||||
|
<th className="py-2 font-medium tabular-nums text-right">Last Active</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-4 truncate max-w-[200px]" title={s.session_name}>
|
||||||
|
{s.session_name || 'Untitled'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_input_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_output_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatCost(s.total_cost)}</td>
|
||||||
|
<td className="py-2 tabular-nums text-right text-muted-foreground">{formatDate(s.last_active_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-Tool Cost Table ---
|
||||||
|
function ToolTable({ stats }: { stats: ToolCostStat[] }) {
|
||||||
|
if (stats.length === 0) {
|
||||||
|
return <EmptyState message="No tool cost data available yet. Stats accumulate after tool calls are made." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="py-2 pr-4 font-medium">Tool</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Calls</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Prompt</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Completion</th>
|
||||||
|
<th className="py-2 font-medium tabular-nums text-right">Avg Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map((t) => (
|
||||||
|
<tr key={t.tool_name} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-4 flex items-center gap-2">
|
||||||
|
<Wrench className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate max-w-[200px]" title={t.tool_name}>{t.tool_name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{t.n_calls}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_completion_tokens)}</td>
|
||||||
|
<td className="py-2 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens + t.mean_completion_tokens)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context Window Utilization ---
|
||||||
|
function ContextSection({ stats }: { stats: ContextWindowStats }) {
|
||||||
|
if (stats.message_count === 0) {
|
||||||
|
return <EmptyState message="No context window data available yet. Data is captured during inference." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Context Used</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_used ?? 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Context Limit</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_max ?? 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Utilization</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatPct(stats.avg_utilization_pct)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Based on {formatNumber(stats.message_count)} completed assistant messages</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: stats.avg_utilization_pct != null
|
||||||
|
? `${Math.min(stats.avg_utilization_pct * 100, 100)}%`
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Token Category Breakdown (CSS stacked bar) ---
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
system: 'bg-blue-500',
|
||||||
|
user: 'bg-green-500',
|
||||||
|
assistant: 'bg-amber-500',
|
||||||
|
tools: 'bg-purple-500',
|
||||||
|
reasoning: 'bg-rose-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
system: 'System',
|
||||||
|
user: 'User',
|
||||||
|
assistant: 'Assistant',
|
||||||
|
tools: 'Tools',
|
||||||
|
reasoning: 'Reasoning',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[] }) {
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return <EmptyState message="No token breakdown data available. Breakdown is captured for arena contestants and certain task types." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = categories.reduce((sum, c) => sum + c.total_tokens, 0);
|
||||||
|
if (total === 0) return <EmptyState message="Token breakdown totals are zero." />;
|
||||||
|
|
||||||
|
// Sort in a consistent order
|
||||||
|
const order = ['system', 'user', 'assistant', 'tools', 'reasoning'];
|
||||||
|
const sorted = [...categories].sort(
|
||||||
|
(a, b) => order.indexOf(a.category) - order.indexOf(b.category),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 rounded-full bg-muted overflow-hidden flex">
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const pct = (c.total_tokens / total) * 100;
|
||||||
|
if (pct < 1) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.category}
|
||||||
|
className={cn('h-full first:rounded-l-full last:rounded-r-full', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
title={`${CATEGORY_LABELS[c.category] ?? c.category}: ${formatNumber(c.total_tokens)} (${pct.toFixed(1)}%)`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const pct = (c.total_tokens / total) * 100;
|
||||||
|
return (
|
||||||
|
<div key={c.category} className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-2.5 rounded-sm', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')} />
|
||||||
|
<span className="text-muted-foreground">{CATEGORY_LABELS[c.category] ?? c.category}</span>
|
||||||
|
<span className="font-medium tabular-nums">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">({formatNumber(c.total_tokens)})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Page ---
|
||||||
|
export function Analytics() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const summary = useFetch(() => api.analytics.summary());
|
||||||
|
const sessions = useFetch(() => api.analytics.sessions().then((r) => r.sessions));
|
||||||
|
const tools = useFetch(() => api.tools.costStats().then((r) => r.stats));
|
||||||
|
const context = useFetch(() => api.analytics.context());
|
||||||
|
const breakdown = useFetch(() => api.analytics.tokenBreakdown().then((r) => r.categories));
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Token Analytics</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Aggregate token usage, cost, and context window data across all sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary.loading ? (
|
||||||
|
<SummaryCardsSkeleton />
|
||||||
|
) : summary.error ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-destructive">{summary.error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={summary.retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : summary.data ? (
|
||||||
|
<SummaryCards summary={summary.data} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Per-Session Token Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Per-Session Token Usage"
|
||||||
|
loading={sessions.loading}
|
||||||
|
error={sessions.error}
|
||||||
|
onRetry={sessions.retry}
|
||||||
|
>
|
||||||
|
{sessions.data && <SessionTable sessions={sessions.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Per-Tool Cost Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Per-Tool Token Cost"
|
||||||
|
loading={tools.loading}
|
||||||
|
error={tools.error}
|
||||||
|
onRetry={tools.retry}
|
||||||
|
>
|
||||||
|
{tools.data && <ToolTable stats={tools.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Context Window Utilization */}
|
||||||
|
<SectionCard
|
||||||
|
title="Context Window Utilization"
|
||||||
|
loading={context.loading}
|
||||||
|
error={context.error}
|
||||||
|
onRetry={context.retry}
|
||||||
|
>
|
||||||
|
{context.data && <ContextSection stats={context.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Token Category Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Token Breakdown by Category"
|
||||||
|
loading={breakdown.loading}
|
||||||
|
error={breakdown.error}
|
||||||
|
onRetry={breakdown.retry}
|
||||||
|
>
|
||||||
|
{breakdown.data && <TokenBreakdownSection categories={breakdown.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
510
apps/web/src/pages/Results.tsx
Normal file
510
apps/web/src/pages/Results.tsx
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ArrowLeft, Beaker, CheckCircle2, FileText, ScrollText, Swords, XCircle } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { BattleShape, FlowRunRow } from '@/api/types';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
||||||
|
|
||||||
|
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
} {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetcher()
|
||||||
|
.then(setData)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { data, loading, error, retry: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SkeletonBar({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startIso: string, endIso?: string | null): string {
|
||||||
|
const start = new Date(startIso).getTime();
|
||||||
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||||
|
const ms = end - start;
|
||||||
|
if (ms < 0) return '—';
|
||||||
|
const s = Math.round(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
||||||
|
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str: string, max: number): string {
|
||||||
|
if (str.length <= max) return str;
|
||||||
|
return str.slice(0, max) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status dot (shared visual language with OrchestratorPane/ArenaPane) ──────
|
||||||
|
|
||||||
|
type DotStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'pending';
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: DotStatus }) {
|
||||||
|
if (status === 'running') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="running"
|
||||||
|
className="inline-block w-2.5 h-2.5 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cls =
|
||||||
|
status === 'completed'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: status === 'failed'
|
||||||
|
? 'bg-destructive'
|
||||||
|
: status === 'cancelled'
|
||||||
|
? 'bg-muted-foreground/20'
|
||||||
|
: 'bg-muted-foreground/40'; // pending
|
||||||
|
return <span aria-label={status} className={cn('inline-block w-2 h-2 rounded-full shrink-0', cls)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TabId = 'runs' | 'battles';
|
||||||
|
|
||||||
|
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 border-b pb-px">
|
||||||
|
{[
|
||||||
|
{ id: 'runs' as TabId, label: 'Analysis Runs', icon: FileText },
|
||||||
|
{ id: 'battles' as TabId, label: 'Arena Battles', icon: Swords },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
||||||
|
active === tab.id
|
||||||
|
? 'bg-background border-border text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="size-3.5" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Empty state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project selector ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProjectSelector({
|
||||||
|
projects,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
projects: Array<{ id: string; name: string }>;
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="text-sm bg-muted/30 border border-border rounded px-2 py-1 text-foreground"
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Analysis Runs tab ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnalysisRunsTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.runs.list(projectId).then((r) => r.runs));
|
||||||
|
|
||||||
|
const [selectedRun, setSelectedRun] = useState<FlowRunRow | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<SkeletonBar key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No analysis runs yet. Start one from the Workflow button in any chat." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
{data.map((run) => (
|
||||||
|
<div key={run.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRun(selectedRun?.id === run.id ? null : run)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
||||||
|
selectedRun?.id === run.id && 'bg-muted/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusDot status={run.status as DotStatus} />
|
||||||
|
<span className="font-medium min-w-0 flex-1 truncate">
|
||||||
|
{run.flow_name}
|
||||||
|
<span className="text-muted-foreground font-normal ml-1.5 text-xs uppercase">
|
||||||
|
{run.band}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
||||||
|
{run.model ? run.model.split('/').pop() : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{formatDuration(run.created_at, run.updated_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(run.created_at)}
|
||||||
|
</span>
|
||||||
|
{run.error && (
|
||||||
|
<span className="text-destructive" title={run.error}>
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{run.status === 'completed' && run.report && (
|
||||||
|
<FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded detail — report preview */}
|
||||||
|
{selectedRun?.id === run.id && run.status === 'completed' && run.report && (
|
||||||
|
<div className="ml-8 mr-2 mb-2 p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
||||||
|
{truncate(run.report, 3000)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Arena Battles tab ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ArenaBattlesTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.battles.list(projectId).then((r) => r.battles));
|
||||||
|
|
||||||
|
const [selectedBattle, setSelectedBattle] = useState<BattleShape | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<SkeletonBar key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No arena battles yet. Start one from the Arena button in any chat." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-2">
|
||||||
|
{data.map((battle) => {
|
||||||
|
const hasAnalysis = battle.status === 'completed' && battle.results_path;
|
||||||
|
return (
|
||||||
|
<div key={battle.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedBattle(selectedBattle?.id === battle.id ? null : battle)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
||||||
|
selectedBattle?.id === battle.id && 'bg-muted/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusDot status={
|
||||||
|
battle.status === 'completed' ? 'completed'
|
||||||
|
: battle.status === 'failed' ? 'failed'
|
||||||
|
: battle.status === 'cancelled' ? 'cancelled'
|
||||||
|
: 'running'
|
||||||
|
} />
|
||||||
|
<span className="font-medium min-w-0 flex-1 truncate">
|
||||||
|
{battle.battle_type === 'coding' ? 'Coding Battle' : 'Q&A Battle'}
|
||||||
|
<span className="text-muted-foreground font-normal ml-1.5 text-xs">
|
||||||
|
{truncate(battle.prompt, 60)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{battle.winner_contestant_id && (
|
||||||
|
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="size-3" />
|
||||||
|
Winner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{battle.error && (
|
||||||
|
<span className="text-destructive" title={battle.error}>
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
|
||||||
|
{formatDate(battle.created_at)}
|
||||||
|
</span>
|
||||||
|
{hasAnalysis && (
|
||||||
|
<Beaker className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded detail — analysis preview */}
|
||||||
|
{selectedBattle?.id === battle.id && hasAnalysis && (
|
||||||
|
<div className="ml-8 mr-2 mb-2">
|
||||||
|
<AnalysisPreview battleId={battle.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Battle analysis preview (fetches analysis.md on expand) ─────────────────
|
||||||
|
|
||||||
|
function AnalysisPreview({ battleId }: { battleId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.battles.getAnalysis(battleId).then((r) => r.text));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3 rounded-md bg-muted/20 border border-border/50">
|
||||||
|
<SkeletonBar className="h-3 w-full" />
|
||||||
|
<SkeletonBar className="h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-md bg-muted/20 border border-border/50 text-xs">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
||||||
|
{data ? truncate(data, 3000) : 'No analysis available.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary strip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SummaryCards({
|
||||||
|
runs,
|
||||||
|
battles,
|
||||||
|
}: {
|
||||||
|
runs: FlowRunRow[] | null;
|
||||||
|
battles: BattleShape[] | null;
|
||||||
|
}) {
|
||||||
|
const totalRuns = runs?.length ?? 0;
|
||||||
|
const completedRuns = runs?.filter((r) => r.status === 'completed').length ?? 0;
|
||||||
|
const totalBattles = battles?.length ?? 0;
|
||||||
|
const completedBattles = battles?.filter((b) => b.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: 'Total Runs', value: totalRuns, icon: FileText, color: 'text-blue-500' },
|
||||||
|
{ label: 'Completed Runs', value: completedRuns, icon: CheckCircle2, color: 'text-emerald-500' },
|
||||||
|
{ label: 'Total Battles', value: totalBattles, icon: Swords, color: 'text-violet-500' },
|
||||||
|
{ label: 'Completed Battles', value: completedBattles, icon: CheckCircle2, color: 'text-emerald-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Card key={c.label} size="sm">
|
||||||
|
<CardContent className="flex items-start gap-3 pt-3">
|
||||||
|
<c.icon className={cn('size-4 shrink-0 mt-0.5', c.color)} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCardsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} size="sm">
|
||||||
|
<CardContent className="pt-3">
|
||||||
|
<SkeletonBar className="h-5 w-16 mb-2" />
|
||||||
|
<SkeletonBar className="h-3 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Results() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: sidebar, activeSession } = useSidebar();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<TabId>('runs');
|
||||||
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Derive default project from active session or first project.
|
||||||
|
const projects = useMemo(() => {
|
||||||
|
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
||||||
|
}, [sidebar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId && projects.length > 0) {
|
||||||
|
// Prefer active session's project, else first project.
|
||||||
|
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
||||||
|
setProjectId(defaultId);
|
||||||
|
}
|
||||||
|
}, [projects, activeSession, projectId]);
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runsFetch = useFetch(
|
||||||
|
projectId ? () => api.runs.list(projectId).then((r) => r.runs) : () => Promise.resolve([] as FlowRunRow[]),
|
||||||
|
);
|
||||||
|
const battlesFetch = useFetch(
|
||||||
|
projectId ? () => api.battles.list(projectId).then((r) => r.battles) : () => Promise.resolve([] as BattleShape[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryLoading = runsFetch.loading && battlesFetch.loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<ScrollText className="size-5" />
|
||||||
|
Results
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Completed orchestrator runs and arena battles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{projects.length > 0 && projectId && (
|
||||||
|
<ProjectSelector
|
||||||
|
projects={projects}
|
||||||
|
value={projectId}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summaryLoading ? (
|
||||||
|
<SummaryCardsSkeleton />
|
||||||
|
) : (
|
||||||
|
<SummaryCards runs={runsFetch.data} battles={battlesFetch.data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<TabBar active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{!projectId ? (
|
||||||
|
<EmptyState message="Select a project to view results." />
|
||||||
|
) : tab === 'runs' ? (
|
||||||
|
<AnalysisRunsTab projectId={projectId} />
|
||||||
|
) : (
|
||||||
|
<ArenaBattlesTab projectId={projectId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/web/src/stores/useDiffCommentStore.ts
Normal file
92
apps/web/src/stores/useDiffCommentStore.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface DiffComment {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffCommentTarget {
|
||||||
|
filePath: string;
|
||||||
|
side: 'old' | 'new';
|
||||||
|
lineNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromStorage(key: string): Map<string, DiffComment[]> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return new Map();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return new Map(Object.entries(parsed));
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToStorage(key: string, map: Map<string, DiffComment[]>) {
|
||||||
|
const obj: Record<string, DiffComment[]> = {};
|
||||||
|
for (const [k, v] of map) obj[k] = v;
|
||||||
|
localStorage.setItem(key, JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiffComments(sessionId: string, mode: string) {
|
||||||
|
const storageKey = `boocode.diff.comments.${sessionId}.${mode}`;
|
||||||
|
const [comments, setComments] = useState<Map<string, DiffComment[]>>(() =>
|
||||||
|
loadFromStorage(storageKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveToStorage(storageKey, comments);
|
||||||
|
}, [storageKey, comments]);
|
||||||
|
|
||||||
|
const addComment = useCallback(
|
||||||
|
(key: string, comment: DiffComment) => {
|
||||||
|
setComments((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const list = next.get(key) ?? [];
|
||||||
|
next.set(key, [...list, comment]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateComment = useCallback(
|
||||||
|
(key: string, id: string, body: string) => {
|
||||||
|
setComments((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const list = next.get(key);
|
||||||
|
if (!list) return prev;
|
||||||
|
next.set(
|
||||||
|
key,
|
||||||
|
list.map((c) =>
|
||||||
|
c.id === id ? { ...c, body, updatedAt: Date.now() } : c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteComment = useCallback(
|
||||||
|
(key: string, id: string) => {
|
||||||
|
setComments((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const list = next.get(key);
|
||||||
|
if (!list) return prev;
|
||||||
|
const filtered = list.filter((c) => c.id !== id);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.set(key, filtered);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { comments, addComment, updateComment, deleteComment };
|
||||||
|
}
|
||||||
284
apps/web/src/utils/diff-layout.ts
Normal file
284
apps/web/src/utils/diff-layout.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Pure utilities for parsing unified diff text and building display structures
|
||||||
|
* for both unified and side-by-side (split) diff views.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type DiffLineType = 'add' | 'remove' | 'context' | 'header';
|
||||||
|
|
||||||
|
export interface DiffLine {
|
||||||
|
type: DiffLineType;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffHunk {
|
||||||
|
oldStart: number;
|
||||||
|
oldCount: number;
|
||||||
|
newStart: number;
|
||||||
|
newCount: number;
|
||||||
|
lines: DiffLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedDiffFile {
|
||||||
|
path: string;
|
||||||
|
hunks: DiffHunk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single cell in the split (side-by-side) view */
|
||||||
|
export interface SplitDisplayLine {
|
||||||
|
type: DiffLineType;
|
||||||
|
content: string;
|
||||||
|
lineNumber: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row in the split view — either a hunk header or a left/right pair */
|
||||||
|
export type SplitRow =
|
||||||
|
| { kind: 'header'; content: string }
|
||||||
|
| { kind: 'pair'; left: SplitDisplayLine | null; right: SplitDisplayLine | null };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseDiff
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse unified diff text into an array of ParsedDiffFile objects.
|
||||||
|
*
|
||||||
|
* Splits on `diff --git` headers, extracts file paths from `+++ b/<path>`
|
||||||
|
* (falling back to `--- a/<path>`), and classifies each line within hunks.
|
||||||
|
*/
|
||||||
|
export function parseDiff(diffBody: string): ParsedDiffFile[] {
|
||||||
|
if (!diffBody || diffBody.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: ParsedDiffFile[] = [];
|
||||||
|
const sections = diffBody.split(/^diff --git /m).filter(Boolean);
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
const lines = section.split('\n');
|
||||||
|
const path = extractPath(lines);
|
||||||
|
const hunks = parseSectionBody(lines);
|
||||||
|
files.push({ path, hunks });
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildSplitRows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build side-by-side (split) display rows from a parsed diff file.
|
||||||
|
*
|
||||||
|
* For each hunk:
|
||||||
|
* - Emits a header row (`@@ -... +... @@`)
|
||||||
|
* - Buffers consecutive removals and additions
|
||||||
|
* - On a context line (or hunk end), flushes buffered removals/additions as
|
||||||
|
* paired rows (left = removal or null, right = addition or null)
|
||||||
|
* - Context lines become paired rows with identical content on both sides
|
||||||
|
*/
|
||||||
|
export function buildSplitRows(file: ParsedDiffFile): SplitRow[] {
|
||||||
|
const rows: SplitRow[] = [];
|
||||||
|
|
||||||
|
for (const hunk of file.hunks) {
|
||||||
|
// Header row
|
||||||
|
const headerLine = hunk.lines.find((l) => l.type === 'header');
|
||||||
|
rows.push({ kind: 'header', content: headerLine?.content ?? '@@' });
|
||||||
|
|
||||||
|
let oldLineNo = hunk.oldStart;
|
||||||
|
let newLineNo = hunk.newStart;
|
||||||
|
|
||||||
|
let pendingRemovals: SplitDisplayLine[] = [];
|
||||||
|
let pendingAdditions: SplitDisplayLine[] = [];
|
||||||
|
|
||||||
|
const flushPending = (): void => {
|
||||||
|
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||||
|
for (let i = 0; i < pairCount; i++) {
|
||||||
|
rows.push({
|
||||||
|
kind: 'pair',
|
||||||
|
left: pendingRemovals[i] ?? null,
|
||||||
|
right: pendingAdditions[i] ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingRemovals = [];
|
||||||
|
pendingAdditions = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.type === 'header') continue;
|
||||||
|
|
||||||
|
if (line.type === 'remove') {
|
||||||
|
pendingRemovals.push({
|
||||||
|
type: 'remove',
|
||||||
|
content: line.content,
|
||||||
|
lineNumber: oldLineNo++,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.type === 'add') {
|
||||||
|
pendingAdditions.push({
|
||||||
|
type: 'add',
|
||||||
|
content: line.content,
|
||||||
|
lineNumber: newLineNo++,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context line — flush any pending changes first
|
||||||
|
flushPending();
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
kind: 'pair',
|
||||||
|
left: {
|
||||||
|
type: 'context',
|
||||||
|
content: line.content,
|
||||||
|
lineNumber: oldLineNo++,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
type: 'context',
|
||||||
|
content: line.content,
|
||||||
|
lineNumber: newLineNo++,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any trailing removals/additions at hunk end
|
||||||
|
flushPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// reconstructNewContent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the "new" file content from diff hunks by concatenating
|
||||||
|
* addition and context lines. Useful for syntax-highlighting the split
|
||||||
|
* view's right column.
|
||||||
|
*/
|
||||||
|
export function reconstructNewContent(hunks: DiffHunk[]): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.type === 'add' || line.type === 'context') {
|
||||||
|
lines.push(line.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Extract file path from `+++ b/<path>` or `--- a/<path>` metadata lines. */
|
||||||
|
function extractPath(lines: string[]): string {
|
||||||
|
// Try +++ b/<path> first (most reliable for the "new" side)
|
||||||
|
const newLine = lines.find((l) => l.startsWith('+++ '));
|
||||||
|
if (newLine) {
|
||||||
|
const raw = newLine.slice(4).replace(/\t.*$/, '').trimEnd();
|
||||||
|
if (raw !== '/dev/null') {
|
||||||
|
return stripPrefix(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to --- a/<path>
|
||||||
|
const oldLine = lines.find((l) => l.startsWith('--- '));
|
||||||
|
if (oldLine) {
|
||||||
|
const raw = oldLine.slice(4).replace(/\t.*$/, '').trimEnd();
|
||||||
|
if (raw !== '/dev/null') {
|
||||||
|
return stripPrefix(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: parse the first line (e.g. "a/path b/path")
|
||||||
|
const firstLine = lines[0] ?? '';
|
||||||
|
const match = firstLine.match(/^a\/(.+)\s+b\/(.+)$/);
|
||||||
|
if (match) return match[2]!;
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip the `a/` or `b/` prefix that git adds to diff paths. */
|
||||||
|
function stripPrefix(path: string): string {
|
||||||
|
if (path.startsWith('b/') || path.startsWith('a/')) {
|
||||||
|
return path.slice(2);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse hunk headers and line content from a diff section body. */
|
||||||
|
function parseSectionBody(lines: string[]): DiffHunk[] {
|
||||||
|
const hunks: DiffHunk[] = [];
|
||||||
|
let currentHunk: DiffHunk | null = null;
|
||||||
|
|
||||||
|
// Start at index 1 to skip the first line (the "a/path b/path" header from
|
||||||
|
// the `diff --git` split)
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
|
||||||
|
if (isMetadataLine(line)) continue;
|
||||||
|
|
||||||
|
const newHunk = parseHunkHeader(line);
|
||||||
|
if (newHunk) {
|
||||||
|
if (currentHunk) hunks.push(currentHunk);
|
||||||
|
currentHunk = newHunk;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentHunk) continue;
|
||||||
|
|
||||||
|
if (line.startsWith('+')) {
|
||||||
|
currentHunk.lines.push({ type: 'add', content: line.slice(1) });
|
||||||
|
} else if (line.startsWith('-')) {
|
||||||
|
currentHunk.lines.push({ type: 'remove', content: line.slice(1) });
|
||||||
|
} else if (line.startsWith(' ')) {
|
||||||
|
currentHunk.lines.push({ type: 'context', content: line.slice(1) });
|
||||||
|
} else if (line.length > 0 && !line.startsWith('\\')) {
|
||||||
|
currentHunk.lines.push({ type: 'context', content: line });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHunk) hunks.push(currentHunk);
|
||||||
|
return hunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a `@@ -oldStart,oldCount +newStart,newCount @@` header line. */
|
||||||
|
function parseHunkHeader(line: string): DiffHunk | null {
|
||||||
|
const match = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
oldStart: parseInt(match[1]!, 10),
|
||||||
|
oldCount: parseInt(match[2] ?? '1', 10),
|
||||||
|
newStart: parseInt(match[3]!, 10),
|
||||||
|
newCount: parseInt(match[4] ?? '1', 10),
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
content: line.match(/^(@@ .+? @@)/)?.[1] ?? line,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a line is diff metadata (not content). */
|
||||||
|
function isMetadataLine(line: string): boolean {
|
||||||
|
return (
|
||||||
|
line.startsWith('index ') ||
|
||||||
|
line.startsWith('--- ') ||
|
||||||
|
line.startsWith('+++ ') ||
|
||||||
|
line.startsWith('new file mode') ||
|
||||||
|
line.startsWith('deleted file mode')
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user