feat: overhaul chat artifact previews and file change experience (#937)

This commit is contained in:
paisley
2026-04-30 18:21:45 +08:00
committed by GitHub
parent f88d6bc17b
commit d18985712c
47 changed files with 5530 additions and 584 deletions

View File

@@ -0,0 +1,228 @@
/**
* Right-side artifact panel — the WorkBuddy-style split-pane sidebar
* shown next to the Chat conversation. Hosts three top-level tabs:
*
* - 变更 (changes): side-by-side diff for the focused file only (no
* in-panel file list — open a file from the runs file cards below
* the graph, or “查看文件变更” picks the latest change).
* - 预览 (preview): rendered preview of whichever file is currently
* focused (Markdown → rendered, code → syntax-highlighted). Shares
* `focusedFile` with the changes tab so switching tabs keeps
* context.
* - 工作空间 (browser): read-only workspace tree + file preview,
* scoped to the current agent's `agent.workspace`.
*
* Open/close + tab + focused-file state lives in the
* `useArtifactPanel` zustand store so any part of the page (file cards,
* toolbar buttons, "查看文件变更 →" links) can drive it.
*/
import { useLayoutEffect, useMemo } from 'react';
import { Eye, FileEdit, FolderTree, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { GeneratedFile } from '@/lib/generated-files';
import type { AgentSummary } from '@/types/agent';
import { useArtifactPanel } from '@/stores/artifact-panel';
import type { FilePreviewTarget } from './types';
import { FilePreviewBody } from './FilePreviewBody';
import { WorkspaceBrowserBody } from './WorkspaceBrowserBody';
import { WORKSPACE_BROWSER_ENABLED } from './workspace-browser-config';
export interface ArtifactPanelProps {
/** All files generated by the AI across the conversation. */
files: GeneratedFile[];
/** Currently selected agent (drives the workspace tab). */
agent: AgentSummary | null;
/** Used to mark "本轮新增" badges on the workspace tree. */
runStartedAt?: number | null;
/** Bumping this number triggers a workspace tree reload. */
refreshSignal?: number;
}
export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: ArtifactPanelProps) {
const { t } = useTranslation('chat');
const tab = useArtifactPanel((s) => s.tab);
const visibleTab = !WORKSPACE_BROWSER_ENABLED && tab === 'browser' ? 'changes' : tab;
const setTab = useArtifactPanel((s) => s.setTab);
const focusedFile = useArtifactPanel((s) => s.focusedFile);
const setFocusedFile = useArtifactPanel((s) => s.setFocusedFile);
const close = useArtifactPanel((s) => s.close);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-black/5 px-3 py-2 dark:border-white/10">
<div className="flex min-w-0 items-center gap-1">
<PanelTabButton
icon={<FileEdit className="h-3.5 w-3.5" />}
label={t('artifactPanel.tabs.changes', '变更')}
active={visibleTab === 'changes'}
onClick={() => setTab('changes')}
/>
<PanelTabButton
icon={<Eye className="h-3.5 w-3.5" />}
label={t('artifactPanel.tabs.preview', '预览')}
active={visibleTab === 'preview'}
onClick={() => setTab('preview')}
/>
{WORKSPACE_BROWSER_ENABLED && (
<PanelTabButton
icon={<FolderTree className="h-3.5 w-3.5" />}
label={t('artifactPanel.tabs.browser', '工作空间')}
active={visibleTab === 'browser'}
onClick={() => setTab('browser')}
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={close}
aria-label={t('filePreview.actions.close', '关闭')}
>
<X className="h-4 w-4 pointer-events-none" />
</Button>
</div>
<div className="min-h-0 flex-1">
{visibleTab === 'changes' && (
<ChangesTab
files={files}
focusedFile={focusedFile}
onFocus={(f) => setFocusedFile(f)}
/>
)}
{visibleTab === 'preview' && <PreviewTab focusedFile={focusedFile} />}
{WORKSPACE_BROWSER_ENABLED && visibleTab === 'browser' && (
<WorkspaceBrowserBody
agent={agent}
runStartedAt={runStartedAt}
refreshSignal={refreshSignal}
compact
/>
)}
</div>
</div>
);
}
interface PanelTabButtonProps {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
}
function PanelTabButton({ icon, label, active, onClick }: PanelTabButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'bg-foreground/10 text-foreground'
: 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
)}
>
{icon}
<span>{label}</span>
</button>
);
}
function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget {
return {
filePath: file.filePath,
fileName: file.fileName,
ext: file.ext,
mimeType: file.mimeType,
contentType: file.contentType,
action: file.action,
fullContent: file.fullContent,
baseline: file.baseline,
edits: file.edits,
};
}
interface ChangesTabProps {
files: GeneratedFile[];
focusedFile: FilePreviewTarget | null;
onFocus: (file: FilePreviewTarget) => void;
}
/**
* Full-width diff for the focused file. Which file is focused comes from
* the runs file cards or “查看文件变更 →” (auto-first); switching workspace
* tabs does not bring back a sidebar list.
*/
function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) {
const { t } = useTranslation('chat');
// De-dup files by path, keep the latest entry (highest lastSeenIndex).
const uniqueFiles = useMemo(() => {
const map = new Map<string, GeneratedFile>();
for (const f of files) {
const existing = map.get(f.filePath);
if (!existing || f.lastSeenIndex >= existing.lastSeenIndex) map.set(f.filePath, f);
}
return Array.from(map.values()).sort((a, b) => b.lastSeenIndex - a.lastSeenIndex);
}, [files]);
// Auto-select the first file when entering this tab without one in
// focus (or when the focused file disappears, e.g. session reset).
useLayoutEffect(() => {
if (uniqueFiles.length === 0) return;
const stillExists =
focusedFile && uniqueFiles.some((f) => f.filePath === focusedFile.filePath);
if (!stillExists) {
onFocus(generatedFileToTarget(uniqueFiles[0]));
}
}, [focusedFile, uniqueFiles, onFocus]);
if (uniqueFiles.length === 0) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
{t('artifactPanel.changes.empty', '本会话尚无文件变更')}
</div>
);
}
return focusedFile ? (
<div className="flex h-full min-h-0 flex-col">
<FilePreviewBody key={focusedFile.filePath} file={focusedFile} compact mode="diff" />
</div>
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
{t('artifactPanel.changes.selectFileHint', '请点击对话中的文件卡片,或「查看文件变更」')}
</div>
);
}
interface PreviewTabProps {
focusedFile: FilePreviewTarget | null;
}
function PreviewTab({ focusedFile }: PreviewTabProps) {
const { t } = useTranslation('chat');
if (!focusedFile) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-sm font-medium text-foreground">
{t('artifactPanel.preview.emptyTitle', '尚未选择文件')}
</p>
<p className="max-w-md text-xs text-muted-foreground">
{t(
'artifactPanel.preview.emptyHint',
'请先点击对话里的文件卡片打开侧栏并选中文件。',
)}
</p>
</div>
);
}
return <FilePreviewBody key={focusedFile.filePath} file={focusedFile} compact mode="preview" />;
}
export default ArtifactPanel;

View File

@@ -0,0 +1,547 @@
/**
* Inline file preview body.
*
* Renders the icon header (file name / path / save / revert) and a
* minimal tabbed view for a single file. Documents (Markdown) render
* only their `preview` tab; code / other text files render `source`.
* A `diff` tab is added when the file is editable AND there are
* AI-applied edits to display. The metadata `info` tab has been
* intentionally removed — path / size are visible in the header bar.
*
* The `mode` prop narrows the tab set for callers that want a single,
* fixed view (e.g. the artifact panel's 预览 tab forces `preview`, and
* the 变更 tab's right pane forces `diff`).
*
* Used by:
* - `FilePreviewOverlay` for the Skills detail Sheet (read-only).
* - `ArtifactPanel`'s ChangesTab and PreviewTab.
*
* All sandbox / read-only / large-file / binary edge cases are handled
* here so callers only pass a `FilePreviewTarget` and a `readOnly` flag.
*/
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { FolderOpen, Save, ShieldAlert, Undo2 } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { invokeIpc, readTextFile, writeTextFile } from '@/lib/api-client';
import type { FilePreviewTarget } from './types';
import { supportsInlineDiff, supportsInlineDocumentPreview } from '@/lib/generated-files';
import { FilePreviewIcon } from './file-card-utils';
import { formatFileSize } from './format';
import MarkdownPreview from './MarkdownPreview';
import ImageViewer from './ImageViewer';
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
const MonacoDiffViewerLazy = lazy(() => import('./MonacoDiffViewer'));
/**
* Tab set for the body.
*
* - 'full' default: preview / source / diff as appropriate.
* - 'preview' render-only; hides the diff tab and forces read-only.
* - 'diff' diff-only; the body collapses to a single diff view
* with no tab strip, save/revert, or source toggle.
*/
export type FilePreviewBodyMode = 'full' | 'preview' | 'diff';
export interface FilePreviewBodyProps {
file: FilePreviewTarget;
readOnly?: boolean;
/** Compact mode reduces padding/font for use inside the side panel. */
compact?: boolean;
/** Optional slot rendered to the LEFT of the header info (e.g. back button). */
leadingHeader?: React.ReactNode;
/** Optional slot rendered to the RIGHT of the header (extra actions). */
trailingHeader?: React.ReactNode;
/** Limit the visible tabs. Default: 'full'. */
mode?: FilePreviewBodyMode;
/** When true, hide the file header (name / path / actions). */
hideHeader?: boolean;
}
type LoadState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; content: string; size?: number; readOnly: boolean }
| { status: 'tooLarge'; size?: number }
| { status: 'binary' }
| { status: 'outsideSandbox' }
| { status: 'error'; message: string };
type Tab = 'source' | 'preview' | 'diff';
function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[] {
// Diff-only mode short-circuits. Callers (e.g. the 变更 tab's right
// pane) want a pure git-style diff with no tab strip — but only for
// formats where inline diff is actually supported.
if (mode === 'diff') return supportsInlineDiff(file) ? ['diff'] : [];
const tabs: Tab[] = [];
if (file.contentType === 'document') {
if (!supportsInlineDocumentPreview(file.ext)) {
return [];
}
// Markdown / plain-text style documents: rendered preview only.
tabs.push('preview');
} else if (file.contentType === 'snapshot') {
tabs.push('preview');
} else if (file.contentType === 'video' || file.contentType === 'audio') {
tabs.push('preview');
} else if (file.contentType === 'code') {
tabs.push('source');
} else {
tabs.push('source');
}
// Diff tab appears in 'full' mode whenever we captured a Write/Edit
// payload — read-only is fine, the diff is informational only.
if (
mode === 'full' &&
supportsInlineDiff(file) &&
(file.fullContent != null || (file.edits != null && file.edits.length > 0))
) {
tabs.push('diff');
}
return tabs;
}
function pickInitialTab(tabs: Tab[], file: FilePreviewTarget): Tab {
if (file.contentType === 'document' && tabs.includes('preview')) return 'preview';
// For changes view (edited code), prefer the diff tab if present so
// the user sees the change immediately on click.
if (tabs.includes('diff') && file.contentType !== 'document') return 'diff';
return tabs[0] ?? 'source';
}
function normaliseEol(s: string): string {
return s.replace(/\r\n/g, '\n');
}
type DiffPair = {
/** Left pane. `null` makes Monaco render the side as empty (new-file). */
oldContent: string | null;
/** Right pane. */
newContent: string;
/**
* - `whole` right pane is the full file (Write tool).
* - `snippet` left/right are the joined Edit op old/new strings.
* - `unavailable` the chat captured no payload to diff against.
*/
kind: 'whole' | 'snippet' | 'unavailable';
};
/** Visual separator between MultiEdit hunks. */
const SNIPPET_SEPARATOR = '\n\n';
/**
* Build a diff pair purely from the captured tool payload — never reads
* disk. Edit tools show the exact old → new snippet swap. Write-family
* tools prefer the captured baseline when available, so modified files
* render a true before/after diff instead of a misleading all-green view.
*/
function computeDiffPair(file: FilePreviewTarget): DiffPair {
// Edit / StrReplace / MultiEdit — show the snippet swap directly.
if (file.edits && file.edits.length > 0) {
const lefts = file.edits.map((op) => normaliseEol(op.old ?? ''));
const rights = file.edits.map((op) => normaliseEol(op.new ?? ''));
const left = lefts.join(SNIPPET_SEPARATOR);
const right = rights.join(SNIPPET_SEPARATOR);
if (left || right) {
return { oldContent: left, newContent: right, kind: 'snippet' };
}
}
if (file.fullContent != null) {
if (file.baseline?.status === 'ok') {
return {
oldContent: normaliseEol(file.baseline.content),
newContent: normaliseEol(file.fullContent),
kind: 'whole',
};
}
if (file.baseline?.status === 'missing') {
return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
}
if (file.baseline?.status === 'unavailable') {
return { oldContent: null, newContent: '', kind: 'unavailable' };
}
if (file.action === 'created') {
return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
}
return { oldContent: null, newContent: '', kind: 'unavailable' };
}
return { oldContent: null, newContent: '', kind: 'unavailable' };
}
export function FilePreviewBody({
file,
readOnly = false,
compact = false,
leadingHeader,
trailingHeader,
mode = 'full',
hideHeader = false,
}: FilePreviewBodyProps) {
const { t } = useTranslation('chat');
const [state, setState] = useState<LoadState>({ status: 'idle' });
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [tab, setTab] = useState<Tab>('source');
const [size, setSize] = useState<number | undefined>(undefined);
// Preview / diff modes are read-only by definition — those views are
// for inspecting content, not editing it.
const enforcedReadOnly = readOnly || mode === 'preview' || mode === 'diff';
const tabs = useMemo(() => tabsForFile(file, mode), [file, mode]);
const unsupportedPreviewFormat = file.contentType === 'document' && !supportsInlineDocumentPreview(file.ext);
const unsupportedDiffFormat = mode === 'diff' && !supportsInlineDiff(file);
useEffect(() => {
setTab(pickInitialTab(tabs, file));
// Diff-only mode renders entirely from the captured tool payload —
// no disk read needed, so we can mark the body "ready" immediately.
if (mode === 'diff') {
setState({ status: 'ready', content: '', readOnly: enforcedReadOnly });
setDraft(null);
return;
}
if (unsupportedPreviewFormat) {
setState({ status: 'ready', content: '', readOnly: enforcedReadOnly });
setDraft(null);
return;
}
if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') {
setState({ status: 'ready', content: '', readOnly: enforcedReadOnly });
setDraft(null);
return;
}
let cancelled = false;
setState({ status: 'loading' });
readTextFile(file.filePath)
.then((res) => {
if (cancelled) return;
if (!res.ok) {
if (res.error === 'tooLarge') {
setState({ status: 'tooLarge', size: res.size });
return;
}
if (res.error === 'binary') {
setState({ status: 'binary' });
return;
}
if (res.error === 'outsideSandbox') {
setState({ status: 'outsideSandbox' });
return;
}
setState({ status: 'error', message: String(res.error ?? 'unknown') });
return;
}
setState({
status: 'ready',
content: res.content ?? '',
size: res.size,
readOnly: enforcedReadOnly || !!res.readOnly,
});
setDraft(res.content ?? '');
setSize(res.size);
})
.catch((err) => {
if (cancelled) return;
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
});
return () => {
cancelled = true;
};
}, [file, enforcedReadOnly, mode, tabs, unsupportedPreviewFormat]);
const effectiveReadOnly = state.status === 'ready' ? state.readOnly : true;
const dirty =
state.status === 'ready' && !state.readOnly && draft != null && draft !== state.content;
const handleSave = useCallback(async () => {
if (!dirty || draft == null) return;
setSaving(true);
try {
const res = await writeTextFile(file.filePath, draft);
if (!res.ok) throw new Error(res.error ?? 'unknown');
setState({ status: 'ready', content: draft, size, readOnly: false });
toast.success(t('filePreview.toast.saved', '已保存到磁盘'));
} catch (err) {
const code = err instanceof Error ? err.message : String(err);
const localized =
code === 'outsideSandbox'
? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝写入')
: code === 'readOnlyRoot'
? t('filePreview.errors.readOnlyRoot', '该文件位于只读位置(如内置技能),无法修改')
: t('filePreview.toast.saveFailed', { defaultValue: '保存失败:{{error}}', error: code });
toast.error(localized);
} finally {
setSaving(false);
}
}, [file, dirty, draft, size, t]);
const handleRevert = useCallback(() => {
if (state.status !== 'ready') return;
setDraft(state.content);
}, [state]);
const handleOpenInFinder = useCallback(() => {
invokeIpc('shell:showItemInFolder', file.filePath).catch(() => {
toast.error(t('filePreview.errors.openInFinderFailed', '无法在 Finder 中显示'));
});
}, [file, t]);
const renderUnsupportedFormat = () => (
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
<div className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
{t('filePreview.errors.unsupportedFormatTitle', '此文件格式暂不支持内置预览或变更')}
</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
{t(
'filePreview.errors.unsupportedFormatHint',
'当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。',
)}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
const renderBody = () => {
if (unsupportedPreviewFormat || unsupportedDiffFormat) {
return renderUnsupportedFormat();
}
if (state.status === 'loading' || state.status === 'idle') {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (state.status === 'tooLarge') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<p>
{t('filePreview.errors.tooLarge', {
defaultValue: '文件过大({{size}}),已禁用预览',
size: formatFileSize(state.size ?? 0) || '> 2MB',
})}
</p>
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
if (state.status === 'binary') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<p>{t('filePreview.errors.binary', '二进制文件不支持文本预览')}</p>
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
if (state.status === 'outsideSandbox') {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/10 text-amber-500">
<ShieldAlert className="h-6 w-6" />
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
{t('filePreview.errors.outsideSandboxTitle', '此文件位于沙盒外')}
</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
{t(
'filePreview.errors.outsideSandboxHint',
'出于安全考虑ClawX 仅允许预览 ~/.openclaw、应用资源以及内置技能目录中的文件。可在 Finder 中查看。',
)}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
if (state.status === 'error') {
const errMsg = state.message;
const hint =
errMsg === 'notFound'
? t('filePreview.errors.notFound', '文件不存在')
: t('filePreview.errors.loadFailed', { defaultValue: '加载失败:{{error}}', error: errMsg });
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<p>{hint}</p>
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
return (
<Tabs value={tab} onValueChange={(next) => setTab(next as Tab)} className="flex h-full flex-col">
{/* Hide the tab strip when there's only one tab — keeps the UI
quiet for the common case (just preview / just source). */}
{tabs.length > 1 && (
<TabsList className="m-3 self-start">
{tabs.map((id) => (
<TabsTrigger key={id} value={id}>
{id === 'source' && t('filePreview.tabs.source', '源码')}
{id === 'preview' && t('filePreview.tabs.preview', '预览')}
{id === 'diff' && t('filePreview.tabs.changes', '变更')}
</TabsTrigger>
))}
</TabsList>
)}
<div
className={cn(
'min-h-0 flex-1',
tabs.length > 1 && 'border-t border-black/5 dark:border-white/10',
)}
>
{tabs.includes('source') && (
<TabsContent value="source" className="m-0 h-full">
{file.contentType === 'snapshot' ? (
<ImageViewer filePath={file.filePath} fileName={file.fileName} />
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
}
>
<MonacoViewerLazy
filePath={file.filePath}
value={draft ?? ''}
readOnly={effectiveReadOnly}
onChange={effectiveReadOnly ? undefined : (next) => setDraft(next)}
/>
</Suspense>
)}
</TabsContent>
)}
{tabs.includes('preview') && (
<TabsContent value="preview" className="m-0 h-full overflow-auto">
{file.contentType === 'snapshot' ? (
<ImageViewer filePath={file.filePath} fileName={file.fileName} />
) : file.contentType === 'document' ? (
<MarkdownPreview source={draft ?? state.content} />
) : (
<div className="p-4 text-sm text-muted-foreground">
{t('filePreview.errors.noPreview', '该文件没有预览')}
</div>
)}
</TabsContent>
)}
{tabs.includes('diff') && (
<TabsContent value="diff" className="m-0 h-full">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
}
>
{(() => {
const pair = computeDiffPair(file);
if (pair.kind === 'unavailable') {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<p className="max-w-md text-sm text-muted-foreground">
{t(
'filePreview.diff.unavailable',
'本会话没有抓到这个文件的精确变更基线,无法生成 diff。',
)}
</p>
<p className="max-w-md text-2xs text-muted-foreground/90">
{t(
'filePreview.diff.unavailableHint',
'可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。',
)}
</p>
</div>
);
}
return (
<MonacoDiffViewerLazy
filePath={file.filePath}
original={pair.oldContent}
modified={pair.newContent}
/>
);
})()}
</Suspense>
</TabsContent>
)}
</div>
</Tabs>
);
};
return (
<div className="flex h-full min-h-0 flex-col">
{!hideHeader && (
<header
className={
compact
? 'flex items-center justify-between gap-3 border-b border-black/5 px-4 py-2 dark:border-white/10'
: 'flex items-center justify-between gap-3 border-b border-black/5 px-5 py-3 dark:border-white/10'
}
>
<div className="flex min-w-0 items-center gap-3">
{leadingHeader}
<FilePreviewIcon
contentType={file.contentType}
mimeType={file.mimeType}
ext={file.ext}
className="h-5 w-5 shrink-0 text-muted-foreground"
/>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">{file.fileName}</h2>
<p className="truncate text-2xs text-muted-foreground">{file.filePath}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{!effectiveReadOnly && state.status === 'ready' && (
<>
<Button variant="ghost" size="sm" onClick={handleRevert} disabled={!dirty || saving}>
<Undo2 className="mr-1 h-3.5 w-3.5" />
{t('filePreview.actions.revert', '撤销')}
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
<Save className="mr-1 h-3.5 w-3.5" />
{saving ? t('filePreview.actions.saving', '保存中...') : t('filePreview.actions.save', '保存')}
</Button>
</>
)}
{trailingHeader}
</div>
</header>
)}
<div className="min-h-0 flex-1">{renderBody()}</div>
</div>
);
}
export default FilePreviewBody;

View File

@@ -0,0 +1,32 @@
/**
* Sheet-based wrapper around `FilePreviewBody`, used by the Skills page
* (read-only) to preview SKILL.md and friends in a full-screen overlay.
*
* The Chat page uses the inline `ArtifactPanel` instead of this component.
*/
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { FilePreviewBody } from './FilePreviewBody';
import type { FilePreviewTarget } from './types';
export type { FilePreviewTarget } from './types';
export interface FilePreviewOverlayProps {
file: FilePreviewTarget | null;
readOnly?: boolean;
onClose: () => void;
}
export function FilePreviewOverlay({ file, readOnly = false, onClose }: FilePreviewOverlayProps) {
return (
<Sheet open={!!file} onOpenChange={(open) => { if (!open) onClose(); }}>
<SheetContent
side="right"
className="w-[70vw] max-w-[1100px] sm:max-w-[1100px] p-0 flex flex-col"
>
{file && <FilePreviewBody file={file} readOnly={readOnly} />}
</SheetContent>
</Sheet>
);
}
export default FilePreviewOverlay;

View File

@@ -0,0 +1,75 @@
/**
* Inline panel showing files the AI wrote/edited in the current run.
* Lives directly under the ExecutionGraphCard for each user trigger
* (see Chat/index.tsx).
*/
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { computeLineStats, supportsInlineDiff, type GeneratedFile } from '@/lib/generated-files';
export interface GeneratedFilesPanelProps {
files: GeneratedFile[];
onOpen: (file: GeneratedFile) => void;
className?: string;
}
export function GeneratedFilesPanel({ files, onOpen, className }: GeneratedFilesPanelProps) {
const { t } = useTranslation('chat');
if (!files.length) return null;
return (
<div className={cn('space-y-2', className)}>
<div className="px-1">
<p className="text-xs font-semibold text-foreground/75">
{t('generatedFiles.title', { count: files.length, defaultValue: '文件变更({{count}} 个)' })}
</p>
</div>
<div className="flex flex-wrap gap-2">
{files.map((file) => {
const lineStats = computeLineStats(file);
const clickable = supportsInlineDiff(file);
return (
<button
key={`${file.filePath}-${file.lastSeenIndex}`}
type="button"
disabled={!clickable}
onClick={() => {
if (clickable) onOpen(file);
}}
className={cn(
'group inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border border-black/8 bg-black/[0.035] px-3.5 py-2 text-left transition-colors disabled:opacity-100',
clickable && 'hover:border-black/12 hover:bg-black/[0.055] dark:hover:bg-white/[0.07]',
!clickable && 'cursor-default',
'dark:border-white/10 dark:bg-white/[0.04]',
)}
title={file.filePath}
>
<div className="min-w-0 flex items-center gap-2 overflow-hidden whitespace-nowrap text-[13px] leading-none">
<span className="shrink-0 font-medium text-foreground">{file.fileName}</span>
<span className="truncate text-muted-foreground">{file.filePath}</span>
</div>
{lineStats && (
<div className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-none tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">+{lineStats.added}</span>
<span className="text-rose-600 dark:text-rose-400">-{lineStats.removed}</span>
</div>
)}
<Badge
variant="secondary"
className="shrink-0 rounded-full border border-black/8 bg-black/[0.045] px-1.5 py-0.5 text-2xs text-foreground/70 dark:border-white/10 dark:bg-white/[0.06] dark:text-foreground/75"
>
{file.action === 'created'
? t('generatedFiles.created', '新增')
: t('generatedFiles.modified', '修改')}
</Badge>
</button>
);
})}
</div>
</div>
);
}
export default GeneratedFilesPanel;

View File

@@ -0,0 +1,58 @@
/**
* Read-only image viewer with fit-to-window + click-to-zoom toggle.
*
* Renders the image directly off the disk via `file://` so we don't need
* to base64-encode it through IPC. clawx's renderer already loads via
* file:// in production so the protocol is allowlisted.
*/
import { useState } from 'react';
import { ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface ImageViewerProps {
filePath: string;
fileName: string;
className?: string;
}
function toFileUrl(path: string): string {
const norm = path.replace(/\\/g, '/');
if (norm.startsWith('file://')) return norm;
if (norm.startsWith('/')) return `file://${norm}`;
return `file:///${norm}`;
}
export default function ImageViewer({ filePath, fileName, className }: ImageViewerProps) {
const [zoomed, setZoomed] = useState(false);
return (
<div className={cn('relative flex h-full w-full items-center justify-center bg-black/5 dark:bg-black/40', className)}>
<div className="absolute right-3 top-3 z-10">
<Button
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full shadow-md"
onClick={() => setZoomed((v) => !v)}
title={zoomed ? '缩小' : '原始尺寸'}
>
{zoomed ? <ZoomOut className="h-4 w-4" /> : <ZoomIn className="h-4 w-4" />}
</Button>
</div>
<div className="h-full w-full overflow-auto p-6">
<img
src={toFileUrl(filePath)}
alt={fileName}
className={cn(
'mx-auto select-none transition-transform',
zoomed
? 'max-w-none cursor-zoom-out'
: 'max-h-full max-w-full object-contain cursor-zoom-in',
)}
onClick={() => setZoomed((v) => !v)}
draggable={false}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
/**
* Rendered Markdown preview.
*
* Two important deltas from a vanilla ReactMarkdown setup:
*
* 1. `remark-frontmatter` is enabled with the `yaml`/`toml` flavours so a
* file like `SKILL.md` doesn't render its `---\nname: …\n---` block as
* a giant paragraph between two horizontal rules. The frontmatter is
* extracted into a side-rendered metadata card above the body so users
* can still see it without the noise.
*
* 2. We render heading / inline-code / fenced-code with explicit Tailwind
* classes instead of relying solely on `.prose`, because the project
* doesn't ship `@tailwindcss/typography` — only the small subset of
* `.prose` rules from `globals.css` exists.
*/
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkFrontmatter from 'remark-frontmatter';
import rehypeKatex from 'rehype-katex';
import { cn } from '@/lib/utils';
export interface MarkdownPreviewProps {
source: string;
className?: string;
}
interface FrontmatterSplit {
body: string;
yaml: string | null;
}
function splitFrontmatter(source: string): FrontmatterSplit {
if (!source.startsWith('---')) {
return { body: source, yaml: null };
}
// Match opening fence + YAML body + closing fence on its own line.
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { body: source, yaml: null };
const yaml = match[1].trim();
const body = source.slice(match[0].length);
return { body, yaml: yaml.length > 0 ? yaml : null };
}
export default function MarkdownPreview({ source, className }: MarkdownPreviewProps) {
const { body, yaml } = useMemo(() => splitFrontmatter(source), [source]);
return (
<div className={cn('prose max-w-none px-6 py-4 text-sm leading-relaxed', className)}>
{yaml && (
<pre className="mb-4 rounded-lg border border-black/5 bg-black/[.03] px-3 py-2 text-2xs leading-relaxed text-foreground/70 dark:border-white/10 dark:bg-white/5">
<code className="font-mono">{yaml}</code>
</pre>
)}
<ReactMarkdown
remarkPlugins={[[remarkFrontmatter, ['yaml', 'toml']], remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
h1: ({ children, ...props }) => (
<h1 className="mb-3 mt-4 text-2xl font-semibold text-foreground first:mt-0" {...props}>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2 className="mb-2 mt-4 text-xl font-semibold text-foreground first:mt-0" {...props}>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3 className="mb-2 mt-3 text-base font-semibold text-foreground first:mt-0" {...props}>
{children}
</h3>
),
h4: ({ children, ...props }) => (
<h4 className="mb-1.5 mt-3 text-sm font-semibold text-foreground first:mt-0" {...props}>
{children}
</h4>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{children}
</a>
),
code: ({ className: codeClass, children, ...props }) => {
const match = /language-(\w+)/.exec(codeClass || '');
const isInline = !match && !codeClass;
if (isInline) {
return (
<code
className="rounded bg-black/5 px-1.5 py-0.5 font-mono text-[0.9em] text-foreground dark:bg-white/10"
{...props}
>
{children}
</code>
);
}
return (
<code className={cn('font-mono text-[0.9em]', codeClass)} {...props}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-lg bg-black/5 p-3 text-xs leading-relaxed dark:bg-white/10">
{children}
</pre>
),
}}
>
{body}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,86 @@
/**
* Monaco split diff editor (VS Codestyle). Replaces the custom table-based
* `SplitDiffViewer`: same Monaco bundle as `MonacoViewer`, with syntax
* highlighting, scroll sync, and virtualisation for large files.
*/
import { useMemo } from 'react';
import { DiffEditor, languageForPath } from '@/lib/monaco/loader';
import { useSettingsStore } from '@/stores/settings';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface MonacoDiffViewerProps {
filePath: string;
/** Left pane; `null`/`undefined` treated as empty (e.g. new file → all additions). */
original: string | null | undefined;
modified: string;
className?: string;
}
function resolveMonacoTheme(theme: string | undefined): string {
if (theme === 'dark') return 'vs-dark';
if (theme === 'light') return 'vs';
const prefersDark =
typeof window !== 'undefined'
&& window.matchMedia
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'vs-dark' : 'vs';
}
function diffModelPath(filePath: string, side: 'orig' | 'mod'): string {
const key = encodeURIComponent(filePath.replace(/\\/g, '/'));
return `inmemory://clawx-diff/${side}/${key}`;
}
export default function MonacoDiffViewer({
filePath,
original,
modified,
className,
}: MonacoDiffViewerProps) {
const theme = useSettingsStore((s) => s.theme);
const language = useMemo(() => languageForPath(filePath), [filePath]);
const monacoTheme = resolveMonacoTheme(theme);
const originalPath = useMemo(() => diffModelPath(filePath, 'orig'), [filePath]);
const modifiedPath = useMemo(() => diffModelPath(filePath, 'mod'), [filePath]);
const left = original ?? '';
return (
<div className={className ?? 'h-full w-full min-h-0'}>
<DiffEditor
height="100%"
language={language}
original={left}
modified={modified}
originalModelPath={originalPath}
modifiedModelPath={modifiedPath}
theme={monacoTheme}
loading={
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
}
options={{
readOnly: true,
domReadOnly: true,
originalEditable: false,
renderSideBySide: true,
useInlineViewWhenSpaceIsLimited: false,
renderIndicators: true,
renderOverviewRuler: true,
renderMarginRevertIcon: false,
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
scrollBeyondLastLine: false,
smoothScrolling: true,
wordWrap: 'on',
diffWordWrap: 'on',
automaticLayout: true,
guides: { indentation: false },
stickyScroll: { enabled: false },
padding: { top: 8, bottom: 8 },
}}
/>
</div>
);
}

View File

@@ -0,0 +1,76 @@
/**
* Monaco-backed text/code viewer.
*
* Lazily loads Monaco on first render (the parent overlay wraps this in
* `<Suspense>`), and resolves the language from the file extension so
* highlighting works for the dozens of file types the editor ships with
* out of the box.
*/
import { useMemo } from 'react';
import { Editor, languageForPath } from '@/lib/monaco/loader';
import { useSettingsStore } from '@/stores/settings';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface MonacoViewerProps {
filePath: string;
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
className?: string;
}
function resolveMonacoTheme(theme: string | undefined): string {
if (theme === 'dark') return 'vs-dark';
if (theme === 'light') return 'vs';
// 'system' — derive from media query at the moment of mount
const prefersDark = typeof window !== 'undefined'
&& window.matchMedia
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'vs-dark' : 'vs';
}
export default function MonacoViewer({
filePath,
value,
onChange,
readOnly = false,
className,
}: MonacoViewerProps) {
const theme = useSettingsStore((s) => s.theme);
const language = useMemo(() => languageForPath(filePath), [filePath]);
const monacoTheme = resolveMonacoTheme(theme);
return (
<div className={className ?? 'h-full w-full'}>
<Editor
height="100%"
path={filePath}
defaultLanguage={language}
language={language}
value={value}
onChange={(next) => onChange?.(next ?? '')}
theme={monacoTheme}
loading={
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
}
options={{
readOnly,
domReadOnly: readOnly,
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
scrollBeyondLastLine: false,
smoothScrolling: true,
wordWrap: 'on',
automaticLayout: true,
renderLineHighlight: readOnly ? 'none' : 'line',
padding: { top: 12, bottom: 12 },
stickyScroll: { enabled: false },
guides: { indentation: false },
}}
/>
</div>
);
}

View File

@@ -0,0 +1,107 @@
/**
* Drag-to-resize handle that sits between the chat column and the
* artifact panel on the Chat page.
*
* On `pointerdown` we capture the pointer, attach window-level
* `pointermove` / `pointerup` listeners, and convert the cursor X (in
* pixels) into a panel-width percentage relative to the supplied
* `containerRef`. The new width is clamped via the store.
*/
import { useCallback, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import {
ARTIFACT_PANEL_MAX_WIDTH,
ARTIFACT_PANEL_MIN_WIDTH,
useArtifactPanel,
} from '@/stores/artifact-panel';
export interface PanelResizeDividerProps {
/** The flex container that holds chat-left + panel-right. */
containerRef: React.RefObject<HTMLElement | null>;
className?: string;
}
export function PanelResizeDivider({ containerRef, className }: PanelResizeDividerProps) {
const setWidthPct = useArtifactPanel((s) => s.setWidthPct);
// Store window listeners in refs so the up-handler can remove the
// matching move-handler without a self-referential closure (which the
// `react-hooks/immutability` rule flags).
const moveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null);
const upHandlerRef = useRef<((e: PointerEvent) => void) | null>(null);
const stopDragging = useCallback(() => {
if (moveHandlerRef.current) {
window.removeEventListener('pointermove', moveHandlerRef.current);
moveHandlerRef.current = null;
}
if (upHandlerRef.current) {
window.removeEventListener('pointerup', upHandlerRef.current);
upHandlerRef.current = null;
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// setPointerCapture can throw on some browsers; window listeners
// below are sufficient on their own.
}
const onMove = (ev: PointerEvent) => {
const node = containerRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
if (rect.width <= 0) return;
// Right panel width = container right edge - cursor X.
const rightWidth = rect.right - ev.clientX;
const pct = (rightWidth / rect.width) * 100;
setWidthPct(pct);
};
const onUp = () => stopDragging();
moveHandlerRef.current = onMove;
upHandlerRef.current = onUp;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[containerRef, setWidthPct, stopDragging],
);
// Safety: if the divider unmounts mid-drag, clean up listeners.
useEffect(() => stopDragging, [stopDragging]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-valuemin={ARTIFACT_PANEL_MIN_WIDTH}
aria-valuemax={ARTIFACT_PANEL_MAX_WIDTH}
onPointerDown={handlePointerDown}
className={cn(
'group relative z-10 hidden w-1.5 shrink-0 cursor-col-resize select-none lg:block',
className,
)}
title="拖动调整宽度"
>
{/* Hairline (visible all the time) */}
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-black/5 transition-colors group-hover:bg-primary/40 dark:bg-white/10"
/>
{/* Wider hover hit-state */}
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 rounded-full opacity-0 transition-opacity group-hover:bg-primary/40 group-hover:opacity-100"
/>
</div>
);
}
export default PanelResizeDivider;

View File

@@ -0,0 +1,166 @@
/**
* Skill detail dialog file sections.
*
* Loads `loadSkillFiles(baseDir)` and groups the result into
* Docs / Scripts / Hooks / Assets cards (Other is hidden by default).
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import {
EMPTY_SKILL_GROUPS,
isSkillFileGroupsEmpty,
loadSkillFiles,
type SkillFile,
type SkillFileGroups,
} from '@/lib/skill-files';
import { FilePreviewIcon } from './file-card-utils';
import { formatFileSize } from './format';
export interface SkillFileSectionsProps {
baseDir: string;
onOpen: (file: SkillFile) => void;
className?: string;
}
export function SkillFileSections({ baseDir, onOpen, className }: SkillFileSectionsProps) {
const { t } = useTranslation('skills');
const [groups, setGroups] = useState<SkillFileGroups>(EMPTY_SKILL_GROUPS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
/* eslint-disable react-hooks/set-state-in-effect -- async fetch with loading/error flags */
if (!baseDir) {
setGroups(EMPTY_SKILL_GROUPS);
return;
}
setLoading(true);
setError(null);
/* eslint-enable react-hooks/set-state-in-effect */
loadSkillFiles(baseDir)
.then((next) => {
if (cancelled) return;
setGroups(next);
})
.catch((err) => {
if (cancelled) return;
setError(String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [baseDir]);
if (!baseDir) return null;
if (loading) {
return (
<div className={cn('flex items-center justify-center py-6', className)}>
<LoadingSpinner size="sm" />
</div>
);
}
if (error) {
return (
<div className={cn('rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-xs text-destructive', className)}>
{t('detail.sections.scanFailed', { defaultValue: '扫描技能目录失败' })}
</div>
);
}
if (isSkillFileGroupsEmpty(groups)) {
return (
<div className={cn('rounded-xl border border-black/10 bg-black/5 px-4 py-3 text-xs text-muted-foreground dark:border-white/10 dark:bg-white/5', className)}>
{t('detail.sections.empty', { defaultValue: '此技能未包含可预览的文件。' })}
</div>
);
}
return (
<div className={cn('space-y-5', className)}>
<SkillFileSection
title={t('detail.sections.docs', { defaultValue: '文档' })}
description={t('detail.sections.docsDesc', { defaultValue: 'SKILL.md 与说明文档' })}
files={groups.docs}
onOpen={onOpen}
/>
<SkillFileSection
title={t('detail.sections.scripts', { defaultValue: '脚本' })}
description={t('detail.sections.scriptsDesc', { defaultValue: '可执行的脚本与命令' })}
files={groups.scripts}
onOpen={onOpen}
/>
<SkillFileSection
title={t('detail.sections.hooks', { defaultValue: 'Hooks' })}
description={t('detail.sections.hooksDesc', { defaultValue: '注入到 OpenClaw 生命周期的钩子' })}
files={groups.hooks}
onOpen={onOpen}
/>
<SkillFileSection
title={t('detail.sections.assets', { defaultValue: '资源' })}
description={t('detail.sections.assetsDesc', { defaultValue: '模板、参考资料与静态资源' })}
files={groups.assets}
onOpen={onOpen}
/>
</div>
);
}
interface SkillFileSectionProps {
title: string;
description: string;
files: SkillFile[];
onOpen: (file: SkillFile) => void;
}
function SkillFileSection({ title, description, files, onOpen }: SkillFileSectionProps) {
if (!files.length) return null;
return (
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h4 className="text-meta font-bold text-foreground/80">
{title}
<span className="ml-2 text-2xs font-medium text-muted-foreground">{files.length}</span>
</h4>
<p className="text-2xs text-muted-foreground/80 truncate">{description}</p>
</div>
<div className="flex flex-col gap-1.5">
{files.map((file) => (
<button
key={file.filePath}
type="button"
onClick={() => onOpen(file)}
className={cn(
'flex items-center gap-2 rounded-xl border border-black/5 bg-surface-input px-3 py-2 text-left transition-colors',
'hover:border-primary/40 hover:bg-primary/5',
'dark:border-white/5 dark:hover:bg-white/10',
)}
title={file.filePath}
>
<FilePreviewIcon
contentType={file.contentType}
mimeType={file.mimeType}
ext={file.ext}
className="h-4 w-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-mono text-foreground">{file.relativePath || file.fileName}</p>
</div>
<span className="shrink-0 text-2xs text-muted-foreground">
{formatFileSize(file.size) || file.ext.replace('.', '').toUpperCase() || ''}
</span>
</button>
))}
</div>
</div>
);
}
export default SkillFileSections;

View File

@@ -0,0 +1,516 @@
/**
* Inline workspace browser body — left tree + right preview.
*
* Strictly scoped to the current agent's `agent.workspace` directory.
* Used by `ArtifactPanel`'s 浏览器 tab (split-pane on the chat page).
*/
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, FolderOpen, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { invokeIpc, readTextFile } from '@/lib/api-client';
import { supportsInlineDocumentPreview } from '@/lib/generated-files';
import {
collectInitialExpanded,
findNode,
loadWorkspaceTree,
type WorkspaceTreeNode,
} from '@/lib/workspace-tree';
import type { AgentSummary } from '@/types/agent';
import { FilePreviewIcon } from './file-card-utils';
import { formatFileSize } from './format';
import MarkdownPreview from './MarkdownPreview';
import ImageViewer from './ImageViewer';
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
export interface WorkspaceBrowserBodyProps {
agent: AgentSummary | null;
/** Used to mark "本轮新增" badges on the tree. */
runStartedAt?: number | null;
/** Bumping this number triggers a tree reload (e.g. after AI run idles). */
refreshSignal?: number;
/** Compact mode used inside the side panel (smaller fonts/paddings). */
compact?: boolean;
/** Left tree column width in px. */
treeWidth?: number;
/** Optional slot rendered in the toolbar (e.g. close button when used in a Sheet). */
toolbarTrailing?: React.ReactNode;
}
type LoadState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; root: WorkspaceTreeNode; truncated: boolean }
| { status: 'error'; message: string };
type FileState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; content: string }
| { status: 'tooLarge' }
| { status: 'binary' }
| { status: 'unsupported' }
| { status: 'error'; message: string };
export function WorkspaceBrowserBody({
agent,
runStartedAt,
refreshSignal,
compact = false,
treeWidth,
toolbarTrailing,
}: WorkspaceBrowserBodyProps) {
const { t } = useTranslation('chat');
const [state, setState] = useState<LoadState>({ status: 'idle' });
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedRel, setSelectedRel] = useState<string | null>(null);
const [fileState, setFileState] = useState<FileState>({ status: 'idle' });
const [refreshTick, setRefreshTick] = useState(0);
const [showHidden, setShowHidden] = useState(false);
const workspace = agent?.workspace ?? '';
const reload = useCallback(() => setRefreshTick((v) => v + 1), []);
// Reset selection when the agent changes.
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect -- intentional reset on agent switch */
setSelectedRel(null);
setFileState({ status: 'idle' });
/* eslint-enable react-hooks/set-state-in-effect */
}, [agent?.id]);
useEffect(() => {
if (!workspace) return;
let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- async tree fetch
setState({ status: 'loading' });
loadWorkspaceTree(workspace, {
runStartedAt: runStartedAt ?? null,
includeHidden: showHidden,
})
.then((res) => {
if (cancelled) return;
if (!res) {
setState({ status: 'error', message: 'load' });
return;
}
setState({ status: 'ready', root: res.root, truncated: res.truncated });
setExpanded((prev) => (prev.size > 0 ? prev : collectInitialExpanded(res.root, 1)));
})
.catch((err) => {
if (cancelled) return;
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
});
return () => {
cancelled = true;
};
}, [workspace, runStartedAt, refreshTick, showHidden, refreshSignal]);
const selectedNode = useMemo(() => {
if (!selectedRel || state.status !== 'ready') return null;
return findNode(state.root, selectedRel);
}, [selectedRel, state]);
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect -- selection-driven loader */
if (!selectedNode || selectedNode.isDir) {
setFileState({ status: 'idle' });
return;
}
const node = selectedNode;
if (node.contentType === 'document' && !supportsInlineDocumentPreview(node.ext ?? '')) {
setFileState({ status: 'unsupported' });
return;
}
if (node.contentType === 'snapshot' || node.contentType === 'video' || node.contentType === 'audio') {
setFileState({ status: 'ready', content: '' });
return;
}
let cancelled = false;
setFileState({ status: 'loading' });
/* eslint-enable react-hooks/set-state-in-effect */
readTextFile(node.absPath)
.then((res) => {
if (cancelled) return;
if (!res.ok) {
if (res.error === 'tooLarge') {
setFileState({ status: 'tooLarge' });
return;
}
if (res.error === 'binary') {
setFileState({ status: 'binary' });
return;
}
setFileState({ status: 'error', message: String(res.error ?? 'unknown') });
return;
}
setFileState({ status: 'ready', content: res.content ?? '' });
})
.catch((err) => {
if (cancelled) return;
setFileState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
});
return () => {
cancelled = true;
};
}, [selectedNode]);
const handleOpenWorkspaceInFinder = useCallback(() => {
if (!workspace) return;
invokeIpc('shell:openPath', workspace).catch(() => {
toast.error(t('filePreview.errors.openInFinderFailed', '无法在 Finder 中显示'));
});
}, [workspace, t]);
const toggleNode = useCallback((relPath: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(relPath)) next.delete(relPath);
else next.add(relPath);
return next;
});
}, []);
const renderTree = () => {
if (state.status === 'loading' || state.status === 'idle') {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner size="sm" />
</div>
);
}
if (state.status === 'error') {
return (
<div className="px-4 py-6 text-xs text-destructive">
{state.message === 'outsideSandbox'
? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝读取')
: t('workspace.empty', '工作空间为空或无法访问')}
</div>
);
}
return (
<div className="space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-2xs uppercase tracking-wide text-muted-foreground">
{t('workspace.title', '工作空间')}
{agent?.name ? <span className="ml-1 text-foreground/60">· {agent.name}</span> : null}
</div>
<FileTreeNodeList
nodes={state.root.children ?? []}
depth={0}
expanded={expanded}
selectedRel={selectedRel}
onToggle={toggleNode}
onSelect={(rel) => setSelectedRel(rel)}
/>
{state.truncated && (
<div className="mt-2 px-3 py-2 text-2xs text-muted-foreground/80">
{t('workspace.truncated', '目录过大,已截断显示 5000 个节点')}
</div>
)}
</div>
);
};
const renderBody = () => {
if (!selectedNode || selectedNode.isDir) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('workspace.pickFile', '从左侧选择一个文件预览')}
</div>
);
}
if (selectedNode.contentType === 'snapshot') {
return <ImageViewer filePath={selectedNode.absPath} fileName={selectedNode.name} />;
}
if (fileState.status === 'loading' || fileState.status === 'idle') {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (fileState.status === 'tooLarge') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<p>{t('filePreview.errors.tooLarge', '文件过大,已禁用预览')}</p>
<Button
variant="outline"
size="sm"
onClick={() => invokeIpc('shell:showItemInFolder', selectedNode.absPath)}
>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
if (fileState.status === 'binary') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
<p>{t('filePreview.errors.binary', '二进制文件不支持文本预览')}</p>
<Button
variant="outline"
size="sm"
onClick={() => invokeIpc('shell:showItemInFolder', selectedNode.absPath)}
>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
if (fileState.status === 'error') {
const errMsg = fileState.message;
const hint = errMsg === 'outsideSandbox'
? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝读取')
: errMsg === 'notFound'
? t('filePreview.errors.notFound', '文件不存在')
: errMsg;
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
{hint}
</div>
);
}
if (fileState.status === 'unsupported') {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
<div className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
{t('filePreview.errors.unsupportedFormatTitle', '此文件格式暂不支持内置预览或变更')}
</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
{t(
'filePreview.errors.unsupportedFormatHint',
'当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。',
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => invokeIpc('shell:showItemInFolder', selectedNode.absPath)}
>
<FolderOpen className="mr-2 h-4 w-4" />
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
</Button>
</div>
);
}
if (selectedNode.contentType === 'document') {
return (
<div className="h-full overflow-auto">
<MarkdownPreview source={fileState.content} />
</div>
);
}
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
}
>
<MonacoViewerLazy filePath={selectedNode.absPath} value={fileState.content} readOnly />
</Suspense>
);
};
return (
<div className="flex h-full min-h-0 flex-col">
<header
className={cn(
'flex items-center justify-between gap-3 border-b border-black/5 dark:border-white/10',
compact ? 'px-3 py-1.5' : 'px-4 py-2',
)}
>
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-sm font-semibold">
{t('workspace.title', '工作空间')}
{agent?.name ? <span className="ml-2 font-normal text-foreground/70">· {agent.name}</span> : null}
</h2>
{workspace && !compact ? (
<code className="hidden truncate rounded bg-black/5 px-2 py-0.5 text-2xs text-muted-foreground dark:bg-white/10 sm:inline">
{workspace}
</code>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setShowHidden((v) => !v)}
title={t('workspace.actions.toggleHidden', '显示/隐藏隐藏文件')}
>
{showHidden
? t('workspace.actions.hideHidden', '隐藏隐藏文件')
: t('workspace.actions.showHidden', '显示隐藏文件')}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={reload}
disabled={state.status === 'loading'}
title={t('workspace.actions.refresh', '刷新')}
>
<RefreshCw className={cn('h-3.5 w-3.5', state.status === 'loading' && 'animate-spin')} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleOpenWorkspaceInFinder}
title={t('workspace.actions.openRootInFinder', '在 Finder 中显示根目录')}
>
<FolderOpen className="h-3.5 w-3.5 pointer-events-none" />
</Button>
{toolbarTrailing}
</div>
</header>
<div
className="grid min-h-0 flex-1"
style={{ gridTemplateColumns: `${treeWidth ?? (compact ? 220 : 280)}px 1fr` }}
>
<aside className="min-h-0 overflow-hidden border-r border-black/5 dark:border-white/10">
<div className="h-full overflow-y-auto py-2 text-sm">{renderTree()}</div>
</aside>
<section className="min-h-0 overflow-hidden">
{selectedNode && !selectedNode.isDir && (
<div className="flex items-center justify-between gap-3 border-b border-black/5 px-4 py-1.5 text-xs text-muted-foreground dark:border-white/10">
<div className="flex min-w-0 items-center gap-2">
<FilePreviewIcon
contentType={selectedNode.contentType}
mimeType={selectedNode.mimeType}
ext={selectedNode.ext}
className="h-4 w-4 shrink-0"
/>
<span className="truncate font-mono">{selectedNode.relPath || selectedNode.name}</span>
{selectedNode.isFresh && (
<Badge variant="default" className="ml-1 text-2xs px-1.5 py-0">
{t('workspace.freshBadge', '本轮新增')}
</Badge>
)}
</div>
<span className="shrink-0">{formatFileSize(selectedNode.size ?? 0)}</span>
</div>
)}
<div className="h-[calc(100%-2rem)] min-h-0">{renderBody()}</div>
</section>
</div>
</div>
);
}
interface FileTreeNodeListProps {
nodes: WorkspaceTreeNode[];
depth: number;
expanded: Set<string>;
selectedRel: string | null;
onToggle: (relPath: string) => void;
onSelect: (relPath: string) => void;
}
function FileTreeNodeList({ nodes, depth, expanded, selectedRel, onToggle, onSelect }: FileTreeNodeListProps) {
return (
<ul className="space-y-0.5">
{nodes.map((node) => (
<FileTreeNodeRow
key={node.relPath || node.name}
node={node}
depth={depth}
expanded={expanded}
selectedRel={selectedRel}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</ul>
);
}
interface FileTreeNodeRowProps extends Omit<FileTreeNodeListProps, 'nodes'> {
node: WorkspaceTreeNode;
}
function FileTreeNodeRow({ node, depth, expanded, selectedRel, onToggle, onSelect }: FileTreeNodeRowProps) {
const isOpen = node.isDir && expanded.has(node.relPath);
const isSelected = selectedRel === node.relPath;
const indent = 12 + depth * 14;
if (node.isDir) {
return (
<li>
<button
type="button"
onClick={() => onToggle(node.relPath)}
className={cn(
'flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-xs transition-colors',
'hover:bg-black/5 dark:hover:bg-white/10',
)}
style={{ paddingLeft: indent }}
title={node.relPath || node.name}
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
isOpen && 'rotate-90',
)}
/>
<span className="truncate font-medium">{node.name}</span>
</button>
{isOpen && node.children && node.children.length > 0 && (
<FileTreeNodeList
nodes={node.children}
depth={depth + 1}
expanded={expanded}
selectedRel={selectedRel}
onToggle={onToggle}
onSelect={onSelect}
/>
)}
</li>
);
}
return (
<li>
<button
type="button"
onClick={() => onSelect(node.relPath)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors',
isSelected
? 'bg-primary/10 text-foreground'
: 'hover:bg-black/5 dark:hover:bg-white/10',
)}
style={{ paddingLeft: indent + 16 }}
title={node.relPath || node.name}
>
<FilePreviewIcon
contentType={node.contentType}
mimeType={node.mimeType}
ext={node.ext}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<span className="truncate">{node.name}</span>
{node.isFresh && (
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden />
)}
</button>
</li>
);
}
export default WorkspaceBrowserBody;

View File

@@ -0,0 +1,20 @@
/**
* Build a `FilePreviewTarget` from a raw filesystem path, applying
* mime / content-type defaults. Lives outside `FilePreviewOverlay.tsx`
* so importing the helper doesn't bring in the Sheet/Monaco component
* graph (and so React Fast Refresh stays happy).
*/
import { classifyFileExt, extnameOf, getMimeTypeForExt } from '@/lib/generated-files';
import type { FilePreviewTarget } from './FilePreviewOverlay';
export function buildPreviewTarget(filePath: string, fileName?: string): FilePreviewTarget {
const ext = extnameOf(filePath);
const name = fileName || (filePath.replace(/\\/g, '/').split('/').pop() ?? filePath);
return {
filePath,
fileName: name,
ext,
mimeType: getMimeTypeForExt(ext),
contentType: classifyFileExt(ext),
};
}

View File

@@ -0,0 +1,39 @@
/**
* Shared icon picker for file cards (chat generated files + skill detail
* dialog) and the workspace tree, so all panels render with consistent
* iconography.
*/
import { File, FileArchive, FileCode, FileText, Film, ImageIcon, Music } from 'lucide-react';
import type { FileContentType } from '@/lib/generated-files';
export interface FilePreviewIconProps {
contentType?: FileContentType;
mimeType?: string;
ext?: string;
className?: string;
}
export function FilePreviewIcon({ contentType, mimeType, ext, className }: FilePreviewIconProps) {
if (contentType === 'snapshot' || (mimeType && mimeType.startsWith('image/'))) {
return <ImageIcon className={className} />;
}
if (contentType === 'video' || (mimeType && mimeType.startsWith('video/'))) {
return <Film className={className} />;
}
if (contentType === 'audio' || (mimeType && mimeType.startsWith('audio/'))) {
return <Music className={className} />;
}
if (contentType === 'document') {
return <FileText className={className} />;
}
if (contentType === 'code') {
return <FileCode className={className} />;
}
if (mimeType === 'application/json' || mimeType === 'application/xml' || (mimeType?.startsWith('text/'))) {
return <FileText className={className} />;
}
if (ext && /\.(zip|tar|gz|7z|rar)$/i.test(ext)) {
return <FileArchive className={className} />;
}
return <File className={className} />;
}

View File

@@ -0,0 +1,12 @@
/**
* Tiny formatting helpers shared across file preview components.
* Kept in a non-`.tsx` module so React Fast Refresh doesn't bail on
* components that import them.
*/
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}

View File

@@ -0,0 +1,43 @@
/**
* Shared types for the file preview pipeline.
*
* Lives outside `FilePreviewOverlay.tsx` so callers (chat panel, workspace
* tree, skills page, …) can import the type without pulling in the Sheet /
* Monaco component graph.
*/
import type { FileContentType, FileEditOp, GeneratedFileBaseline } from '@/lib/generated-files';
export interface FilePreviewTarget {
filePath: string;
fileName: string;
ext: string;
mimeType: string;
contentType: FileContentType;
/**
* From chat extraction only. Drives the badge in the changes list and is
* not used by the diff view itself (which derives "before/after" from
* `fullContent` / `edits` directly, WorkBuddy-style).
*/
action?: 'created' | 'modified';
/**
* Full new content of the file when the tool payload provides it (Write
* family).
*/
fullContent?: string;
/**
* Content of the file *before* the AI's write, captured from disk when
* the tool_use was first detected in the stream.
*
* - `ok` → render a real before/after diff
* - `missing` → render a new-file diff (empty left pane)
* - `unavailable`→ avoid pretending the file was new; show diff unavailable
*/
baseline?: GeneratedFileBaseline;
/**
* Edit operations from Edit / StrReplace / MultiEdit. The diff view
* renders these directly as a snippet diff (left = joined `op.old`,
* right = joined `op.new`) — exactly what the AI changed, no disk
* reads, no reverse-application.
*/
edits?: FileEditOp[];
}

View File

@@ -0,0 +1,7 @@
/**
* Temporary feature gate for the Chat workspace browser UI.
*
* Kept as a single constant so we can re-enable the entry points later
* without ripping out the underlying implementation.
*/
export const WORKSPACE_BROWSER_ENABLED = false;