feat(export): refactor HTML export components and improve metadata

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mingholy.lmh 2026-03-17 21:12:42 +08:00
parent d59e668729
commit ccecc472dc
11 changed files with 511 additions and 328 deletions

View file

@ -109,47 +109,46 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats {
function calculateTokenStats( function calculateTokenStats(
records: ChatRecord[], records: ChatRecord[],
contextWindowSize?: number, contextWindowSize?: number,
): { totalTokens: number; promptTokens: number; contextUsagePercent?: number } { ): { totalTokens: number; contextUsagePercent?: number } {
let totalTokens = 0; let totalTokens = 0;
let lastPromptTokens = 0; let lastTotalTokens = 0;
// Aggregate usageMetadata from all assistant records // Aggregate usageMetadata from all assistant records
// Use last available promptTokenCount for context usage calculation // Use last available totalTokenCount for context usage calculation
for (const record of records) { for (const record of records) {
if (record.type === 'assistant' && record.usageMetadata) { if (record.type === 'assistant' && record.usageMetadata) {
totalTokens += record.usageMetadata.totalTokenCount ?? 0; totalTokens += record.usageMetadata.totalTokenCount ?? 0;
// Use the last available promptTokenCount (represents current context usage) // Use the last available totalTokenCount for context usage calculation
if (record.usageMetadata.promptTokenCount !== undefined) { if (record.usageMetadata.totalTokenCount !== undefined) {
lastPromptTokens = record.usageMetadata.promptTokenCount; lastTotalTokens = record.usageMetadata.totalTokenCount;
} }
} }
} }
// Use promptTokens (input tokens) for context usage calculation // Use last totalTokenCount for context usage calculation
// This represents how much of the context window is being used // This represents how much of the context window is being used by the total tokens
if (contextWindowSize && lastPromptTokens > 0) { if (contextWindowSize && lastTotalTokens > 0) {
const percent = (lastPromptTokens / contextWindowSize) * 100; const percent = (lastTotalTokens / contextWindowSize) * 100;
return { return {
totalTokens, totalTokens,
promptTokens: lastPromptTokens,
contextUsagePercent: Math.round(percent * 10) / 10, contextUsagePercent: Math.round(percent * 10) / 10,
}; };
} }
return { totalTokens, promptTokens: lastPromptTokens }; return { totalTokens };
} }
/** /**
* Extract session metadata from ChatRecords. * Extract session metadata from ChatRecords.
*/ */
function extractMetadata( async function extractMetadata(
conversation: { conversation: {
sessionId: string; sessionId: string;
startTime: string; startTime: string;
messages: ChatRecord[]; messages: ChatRecord[];
}, },
config: Config, config: Config,
): ExportMetadata { ): Promise<ExportMetadata> {
const { sessionId, startTime, messages } = conversation; const { sessionId, startTime, messages } = conversation;
// Extract basic info from the first record // Extract basic info from the first record
@ -157,6 +156,13 @@ function extractMetadata(
const cwd = firstRecord?.cwd ?? ''; const cwd = firstRecord?.cwd ?? '';
const gitBranch = firstRecord?.gitBranch; const gitBranch = firstRecord?.gitBranch;
// Get git repository name
let gitRepo: string | undefined;
if (cwd) {
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
gitRepo = getGitRepoName(cwd);
}
// Try to get model from assistant messages // Try to get model from assistant messages
let model: string | undefined; let model: string | undefined;
for (const record of messages) { for (const record of messages) {
@ -197,11 +203,13 @@ function extractMetadata(
startTime, startTime,
exportTime: new Date().toISOString(), exportTime: new Date().toISOString(),
cwd, cwd,
gitRepo,
gitBranch, gitBranch,
model, model,
channel, channel,
promptCount, promptCount,
contextUsagePercent: tokenStats.contextUsagePercent, contextUsagePercent: tokenStats.contextUsagePercent,
contextWindowSize,
totalTokens: tokenStats.totalTokens, totalTokens: tokenStats.totalTokens,
filesRead: fileStats.filesRead, filesRead: fileStats.filesRead,
filesWritten: fileStats.filesWritten, filesWritten: fileStats.filesWritten,
@ -505,7 +513,7 @@ export async function collectSessionData(
const messages = exportContext.getMessages(); const messages = exportContext.getMessages();
// Extract metadata from conversation // Extract metadata from conversation
const metadata = extractMetadata(conversation, config); const metadata = await extractMetadata(conversation, config);
return { return {
sessionId: conversation.sessionId, sessionId: conversation.sessionId,

View file

@ -64,6 +64,8 @@ export interface ExportMetadata {
exportTime: string; exportTime: string;
/** Current working directory */ /** Current working directory */
cwd: string; cwd: string;
/** Git repository name, if available */
gitRepo?: string;
/** Git branch name, if available */ /** Git branch name, if available */
gitBranch?: string; gitBranch?: string;
/** Model used in the session */ /** Model used in the session */
@ -74,6 +76,8 @@ export interface ExportMetadata {
promptCount: number; promptCount: number;
/** Context window utilization percentage (0-100) */ /** Context window utilization percentage (0-100) */
contextUsagePercent?: number; contextUsagePercent?: number;
/** Context window size in tokens (used for calculating percentage) */
contextWindowSize?: number;
/** Total tokens used (prompt + completion) */ /** Total tokens used (prompt + completion) */
totalTokens?: number; totalTokens?: number;
/** Number of files read */ /** Number of files read */

View file

@ -88,3 +88,61 @@ export const getGitBranch = (cwd: string): string | undefined => {
return undefined; return undefined;
} }
}; };
/**
* Gets the git repository full name (owner/repo), if in a git repository.
* Tries to get the name from the remote URL first, then falls back to the directory name.
*/
export const getGitRepoName = (cwd: string): string | undefined => {
try {
// Try to get the repository name from the remote URL
const remoteUrl = execSync('git remote get-url origin', {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (remoteUrl) {
// Extract owner/repo from various URL formats:
// - https://github.com/owner/repo.git -> owner/repo
// - git@github.com:owner/repo.git -> owner/repo
// - https://gitlab.com/owner/repo -> owner/repo
// - https://github.com/owner/repo/extra -> owner/repo (ignore extra path)
// Handle SSH format: git@host.com:owner/repo.git
let normalizedUrl = remoteUrl;
if (remoteUrl.startsWith('git@')) {
normalizedUrl = remoteUrl.replace(/^git@[^:]+:/, 'https://host.com/');
}
try {
const url = new URL(normalizedUrl);
// Remove .git suffix and split path
const pathParts = url.pathname
.replace(/\.git$/, '')
.split('/')
.filter(Boolean);
if (pathParts.length >= 2) {
// Return owner/repo format
return `${pathParts[0]}/${pathParts[1]}`;
}
} catch {
// URL parsing failed, try regex fallback
const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (match && match[1] && match[2]) {
return `${match[1]}/${match[2]}`;
}
}
}
} catch {
// Fall back to directory name if remote URL is not available
}
// Fallback: use the directory name of the git root
const gitRoot = findGitRoot(cwd);
if (gitRoot) {
return path.basename(gitRoot);
}
return undefined;
};

View file

@ -0,0 +1,53 @@
const React = window.React;
export type CopyButtonProps = {
text: string;
};
export const CopyButton = ({ text }: CopyButtonProps) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<button
onClick={handleCopy}
className="copy-button"
title={copied ? 'Copied!' : 'Copy to clipboard'}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
};

View file

@ -0,0 +1,28 @@
export type MetadataItemProps = {
label: string;
value?: string | number;
valueClass?: string;
};
export const MetadataItem = ({
label,
value,
valueClass,
}: MetadataItemProps) => {
if (value === undefined || value === null || value === '') {
return null;
}
return (
<div className="metadata-item">
<div className="metadata-content">
<span className="metadata-label">{label}</span>
<span
className={`metadata-value ${valueClass || ''}`}
title={typeof value === 'string' ? value : undefined}
>
{value}
</span>
</div>
</div>
);
};

View file

@ -0,0 +1,110 @@
import type { ExportMetadata } from './types.js';
import { MetadataItem } from './MetadataItem.js';
import { CopyButton } from './CopyButton.js';
import {
formatRelativeTime,
formatExportTime,
formatPath,
formatTokenLimit,
} from './utils.js';
export type MetadataSidebarProps = {
metadata: ExportMetadata;
};
export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => {
const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0;
return (
<aside className="metadata-sidebar">
<div className="metadata-section">
<h3 className="metadata-section-title">Session Info</h3>
<MetadataItem
label="Session created"
value={formatRelativeTime(metadata.startTime)}
/>
<MetadataItem label="Project" value={formatPath(metadata.cwd)} />
{metadata.gitRepo && (
<MetadataItem label="Repository" value={metadata.gitRepo} />
)}
{metadata.gitBranch && (
<MetadataItem label="Branch" value={metadata.gitBranch} />
)}
{metadata.model && (
<MetadataItem label="Model" value={metadata.model} />
)}
{metadata.channel && (
<MetadataItem label="Channel" value={metadata.channel} />
)}
</div>
<div className="metadata-section">
<h3 className="metadata-section-title">Statistics</h3>
<MetadataItem label="Prompts" value={metadata.promptCount} />
{metadata.contextUsagePercent !== undefined && (
<MetadataItem
label="Context"
value={`${metadata.contextUsagePercent}% of ${formatTokenLimit(metadata.contextWindowSize)}`}
/>
)}
{metadata.totalTokens !== undefined && (
<MetadataItem
label="Tokens"
value={metadata.totalTokens.toLocaleString()}
/>
)}
<MetadataItem label="Files" value={uniqueFilesCount} />
</div>
<div className="metadata-section">
<h3 className="metadata-section-title">File Operations</h3>
{metadata.filesRead !== undefined && metadata.filesRead > 0 && (
<MetadataItem label="Read" value={metadata.filesRead} />
)}
{metadata.filesWritten !== undefined && metadata.filesWritten > 0 && (
<MetadataItem label="Written" value={metadata.filesWritten} />
)}
{metadata.linesAdded !== undefined && metadata.linesAdded > 0 && (
<MetadataItem
label="Added"
value={`+${metadata.linesAdded}`}
valueClass="text-green"
/>
)}
{metadata.linesRemoved !== undefined && metadata.linesRemoved > 0 && (
<MetadataItem
label="Removed"
value={`-${metadata.linesRemoved}`}
valueClass="text-red"
/>
)}
</div>
<div className="metadata-section metadata-section-small">
{metadata.requestId ? (
<div className="metadata-item">
<div className="metadata-content">
<span className="metadata-label">Request Id</span>
<div className="metadata-value-with-copy">
<span className="metadata-value font-mono">
{metadata.requestId}
</span>
<CopyButton text={metadata.requestId} />
</div>
</div>
</div>
) : (
<MetadataItem
label="Session ID"
value={metadata.sessionId}
valueClass="font-mono"
/>
)}
<MetadataItem
label="Export Time"
value={formatExportTime(metadata.exportTime)}
/>
</div>
</aside>
);
};

View file

@ -0,0 +1,38 @@
import type { PlatformContextValue } from './types.js';
import { useModalState } from './TempFileModal.js';
const React = window.React;
/**
* Hook to provide platform context for the export HTML viewer
*/
export const usePlatformContext = () => {
const { modalState, openModal, closeModal } = useModalState();
const platformContext = React.useMemo(
() =>
({
platform: 'web' as PlatformContextValue['platform'],
postMessage: (message: unknown) => {
console.log('Posted message:', message);
},
onMessage: (handler: (event: MessageEvent) => void) => {
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
},
openFile: (path: string) => {
console.log('Opening file:', path);
},
openTempFile: openModal,
getResourceUrl: () => undefined,
features: {
canOpenFile: false,
canOpenTempFile: true,
canCopy: true,
},
}) satisfies PlatformContextValue,
[openModal],
);
return { platformContext, modalState, closeModal };
};

View file

@ -0,0 +1,48 @@
/**
* Type definitions for export-html
*/
export type ChatData = {
messages?: unknown[];
sessionId?: string;
startTime?: string;
metadata?: ExportMetadata;
};
export type ExportMetadata = {
sessionId: string;
startTime: string;
relativeTime: string;
exportTime: string;
cwd: string;
gitRepo?: string;
gitBranch?: string;
model?: string;
channel?: string;
promptCount: number;
contextUsagePercent?: number;
contextWindowSize?: number;
totalTokens?: number;
filesRead?: number;
filesWritten?: number;
linesAdded?: number;
linesRemoved?: number;
uniqueFiles: string[];
requestId?: string;
};
export type PlatformContextValue = {
platform: 'web';
postMessage: (message: unknown) => void;
onMessage: (handler: (event: MessageEvent) => void) => () => void;
openFile: (path: string) => void;
openTempFile?: (content: string, fileName?: string) => void;
getResourceUrl: () => string | undefined;
features: {
canOpenFile: boolean;
canOpenTempFile?: boolean;
canCopy: boolean;
};
};
export type ChatViewerMessage = { type?: string } & Record<string, unknown>;

View file

@ -0,0 +1,135 @@
import type { ChatData, ChatViewerMessage } from './types.js';
/**
* Type guard for ChatViewerMessage
*/
export const isChatViewerMessage = (
value: unknown,
): value is ChatViewerMessage => Boolean(value) && typeof value === 'object';
/**
* Parse chat data from the embedded script tag
*/
export const parseChatData = (): ChatData => {
const chatDataElement = document.getElementById('chat-data');
if (!chatDataElement?.textContent) {
return {};
}
try {
const parsed = JSON.parse(chatDataElement.textContent) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as ChatData;
}
return {};
} catch (error) {
console.error('Failed to parse chat data.', error);
return {};
}
};
/**
* Format session date for display
*/
export const formatSessionDate = (startTime?: string | null) => {
if (!startTime) {
return '-';
}
try {
const date = new Date(startTime);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return startTime;
}
};
/**
* Format export time for display
*/
export const formatExportTime = (exportTime?: string | null) => {
if (!exportTime) {
return '-';
}
try {
const date = new Date(exportTime);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return exportTime;
}
};
/**
* Format relative time (e.g., "5 minutes ago")
*/
export const formatRelativeTime = (startTime?: string | null) => {
if (!startTime) {
return '-';
}
try {
const date = new Date(startTime);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffSeconds < 60) {
return 'just now';
} else if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
} else if (diffWeeks < 4) {
return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
} else if (diffMonths < 12) {
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
} else {
return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`;
}
} catch {
return '-';
}
};
/**
* Format path with truncation
*/
export const formatPath = (path: string, maxLength: number = 40) => {
if (!path || path.length <= maxLength) return path;
return '...' + path.slice(-maxLength + 3);
};
/**
* Format token limit for display (e.g., 128k, 200k, 1m)
*/
export const formatTokenLimit = (tokens?: number): string => {
if (tokens === undefined || tokens === null) return '128k';
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}m`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(tokens % 1000 === 0 ? 0 : 1)}k`;
}
return tokens.toString();
};

View file

@ -1,6 +1,13 @@
import './styles.css'; import './styles.css';
import logoSvg from './favicon.svg'; import logoSvg from './favicon.svg';
import { TempFileModal, useModalState } from './components/TempFileModal'; import { TempFileModal } from './components/TempFileModal.js';
import { usePlatformContext } from './components/hooks.js';
import { MetadataSidebar } from './components/MetadataSidebar.js';
import {
parseChatData,
isChatViewerMessage,
formatSessionDate,
} from './components/utils.js';
declare global { declare global {
interface Window { interface Window {
@ -10,6 +17,7 @@ declare global {
} }
const ReactDOM = window.ReactDOM; const ReactDOM = window.ReactDOM;
const React = window.React;
declare const QwenCodeWebUI: { declare const QwenCodeWebUI: {
ChatViewer: (props: { ChatViewer: (props: {
@ -25,48 +33,6 @@ declare const QwenCodeWebUI: {
const { ChatViewer, PlatformProvider } = QwenCodeWebUI; const { ChatViewer, PlatformProvider } = QwenCodeWebUI;
type ChatData = {
messages?: unknown[];
sessionId?: string;
startTime?: string;
metadata?: ExportMetadata;
};
type ExportMetadata = {
sessionId: string;
startTime: string;
relativeTime: string;
exportTime: string;
cwd: string;
gitBranch?: string;
model?: string;
channel?: string;
promptCount: number;
contextUsagePercent?: number;
totalTokens?: number;
filesRead?: number;
filesWritten?: number;
linesAdded?: number;
linesRemoved?: number;
uniqueFiles: string[];
requestId?: string;
};
type PlatformContextValue = {
platform: 'web';
postMessage: (message: unknown) => void;
onMessage: (handler: (event: MessageEvent) => void) => () => void;
openFile: (path: string) => void;
openTempFile?: (content: string, fileName?: string) => void;
getResourceUrl: () => string | undefined;
features: {
canOpenFile: boolean;
canOpenTempFile?: boolean;
canCopy: boolean;
};
};
type ChatViewerMessage = { type?: string } & Record<string, unknown>;
const logoSvgWithGradient = (() => { const logoSvgWithGradient = (() => {
if (!logoSvg) { if (!logoSvg) {
return logoSvg; return logoSvg;
@ -80,271 +46,6 @@ const logoSvgWithGradient = (() => {
return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"'); return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"');
})(); })();
const React = window.React;
const usePlatformContext = () => {
const { modalState, openModal, closeModal } = useModalState();
const platformContext = React.useMemo(
() =>
({
platform: 'web' as PlatformContextValue['platform'],
postMessage: (message: unknown) => {
console.log('Posted message:', message);
},
onMessage: (handler: (event: MessageEvent) => void) => {
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
},
openFile: (path: string) => {
console.log('Opening file:', path);
},
openTempFile: openModal,
getResourceUrl: () => undefined,
features: {
canOpenFile: false,
canOpenTempFile: true,
canCopy: true,
},
}) satisfies PlatformContextValue,
[openModal],
);
return { platformContext, modalState, closeModal };
};
const isChatViewerMessage = (value: unknown): value is ChatViewerMessage =>
Boolean(value) && typeof value === 'object';
const parseChatData = (): ChatData => {
const chatDataElement = document.getElementById('chat-data');
if (!chatDataElement?.textContent) {
return {};
}
try {
const parsed = JSON.parse(chatDataElement.textContent) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as ChatData;
}
return {};
} catch (error) {
console.error('Failed to parse chat data.', error);
return {};
}
};
const formatSessionDate = (startTime?: string | null) => {
if (!startTime) {
return '-';
}
try {
const date = new Date(startTime);
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return startTime;
}
};
const formatExportTime = (exportTime?: string | null) => {
if (!exportTime) {
return '-';
}
try {
const date = new Date(exportTime);
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return exportTime;
}
};
const formatPath = (path: string, maxLength: number = 40) => {
if (!path || path.length <= maxLength) return path;
const parts = path.split('/');
if (parts.length <= 2) return '...' + path.slice(-maxLength + 3);
return '...' + path.slice(-maxLength + 3);
};
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<button
onClick={handleCopy}
className="copy-button"
title={copied ? 'Copied!' : 'Copy to clipboard'}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
};
const MetadataItem = ({
label,
value,
valueClass,
}: {
label: string;
value?: string | number;
valueClass?: string;
}) => {
if (value === undefined || value === null || value === '') {
return null;
}
return (
<div className="metadata-item">
<div className="metadata-content">
<span className="metadata-label">{label}</span>
<span
className={`metadata-value ${valueClass || ''}`}
title={typeof value === 'string' ? value : undefined}
>
{value}
</span>
</div>
</div>
);
};
const MetadataSidebar = ({ metadata }: { metadata: ExportMetadata }) => {
const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0;
return (
<aside className="metadata-sidebar">
<div className="metadata-section">
<h3 className="metadata-section-title">Session Info</h3>
<MetadataItem label="Time" value={metadata.relativeTime} />
<MetadataItem label="Project" value={formatPath(metadata.cwd)} />
{metadata.gitBranch && (
<MetadataItem label="Branch" value={metadata.gitBranch} />
)}
{metadata.model && (
<MetadataItem label="Model" value={metadata.model} />
)}
{metadata.channel && (
<MetadataItem label="Channel" value={metadata.channel} />
)}
</div>
<div className="metadata-section">
<h3 className="metadata-section-title">Statistics</h3>
<MetadataItem label="Prompts" value={metadata.promptCount} />
{metadata.contextUsagePercent !== undefined && (
<MetadataItem
label="Context"
value={`${metadata.contextUsagePercent}% of 128k`}
/>
)}
{metadata.totalTokens !== undefined && (
<MetadataItem
label="Tokens"
value={metadata.totalTokens.toLocaleString()}
/>
)}
<MetadataItem label="Files" value={uniqueFilesCount} />
</div>
<div className="metadata-section">
<h3 className="metadata-section-title">File Operations</h3>
{metadata.filesRead !== undefined && metadata.filesRead > 0 && (
<MetadataItem label="Read" value={metadata.filesRead} />
)}
{metadata.filesWritten !== undefined && metadata.filesWritten > 0 && (
<MetadataItem label="Written" value={metadata.filesWritten} />
)}
{metadata.linesAdded !== undefined && metadata.linesAdded > 0 && (
<MetadataItem
label="Added"
value={`+${metadata.linesAdded}`}
valueClass="text-green"
/>
)}
{metadata.linesRemoved !== undefined && metadata.linesRemoved > 0 && (
<MetadataItem
label="Removed"
value={`-${metadata.linesRemoved}`}
valueClass="text-red"
/>
)}
</div>
<div className="metadata-section metadata-section-small">
{metadata.requestId ? (
<div className="metadata-item">
<div className="metadata-content">
<span className="metadata-label">Request Id</span>
<div className="metadata-value-with-copy">
<span className="metadata-value font-mono">
{metadata.requestId}
</span>
<CopyButton text={metadata.requestId} />
</div>
</div>
</div>
) : (
<MetadataItem
label="Session ID"
value={metadata.sessionId}
valueClass="font-mono"
/>
)}
<MetadataItem
label="Export Time"
value={formatExportTime(metadata.exportTime)}
/>
</div>
</aside>
);
};
const App = () => { const App = () => {
const chatData = parseChatData(); const chatData = parseChatData();
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : []; const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];

View file

@ -212,8 +212,8 @@ body {
/* Metadata Sidebar - fixed on right */ /* Metadata Sidebar - fixed on right */
.metadata-sidebar { .metadata-sidebar {
width: 280px; width: 320px;
min-width: 280px; min-width: 320px;
padding: 12px; padding: 12px;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
@ -267,7 +267,7 @@ body {
} }
.metadata-content .metadata-value { .metadata-content .metadata-value {
font-size: 11px; font-size: 12px;
color: var(--text-primary); color: var(--text-primary);
word-break: break-all; word-break: break-all;
line-height: 1.3; line-height: 1.3;
@ -320,8 +320,8 @@ body {
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.metadata-sidebar { .metadata-sidebar {
width: 260px; width: 320px;
min-width: 260px; min-width: 320px;
padding: 10px; padding: 10px;
} }
} }