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;

View File

@@ -13,7 +13,95 @@
"noLogs": "(No logs available yet)",
"toolbar": {
"refresh": "Refresh chat",
"currentAgent": "Talking to {{agent}}"
"currentAgent": "Talking to {{agent}}",
"workspace": "Workspace"
},
"generatedFiles": {
"title": "File changes ({{count}})",
"created": "Created",
"modified": "Modified",
"viewAll": "View all changes"
},
"runError": {
"title": "Model call failed"
},
"artifactPanel": {
"tabs": {
"changes": "Changes",
"preview": "Preview",
"browser": "Workspace"
},
"actions": {
"back": "Back to list"
},
"changes": {
"heading": "File changes ({{count}})",
"empty": "No file changes in this conversation yet",
"selectFileHint": "Click a file card in the chat or “View changes” to open a diff."
},
"preview": {
"emptyTitle": "No file selected",
"emptyHint": "Click a file card in the chat first; the selected file will preview here."
}
},
"filePreview": {
"tabs": {
"source": "Source",
"preview": "Preview",
"changes": "Changes",
"info": "Info"
},
"actions": {
"save": "Save",
"saving": "Saving...",
"revert": "Revert",
"openInFinder": "Reveal in Finder",
"close": "Close"
},
"info": {
"path": "Path",
"size": "Size",
"type": "Type"
},
"toast": {
"saved": "Saved to disk",
"saveFailed": "Save failed: {{error}}"
},
"errors": {
"outsideSandbox": "Path outside sandbox — access denied",
"outsideSandboxTitle": "This file lives outside the sandbox",
"outsideSandboxHint": "For safety, ClawX only previews files in ~/.openclaw, app resources, and bundled skill directories. You can still open it in Finder.",
"readOnlyRoot": "File lives in a read-only location (e.g. bundled skill) and cannot be modified",
"tooLarge": "File too large ({{size}}); preview disabled",
"binary": "Binary file — text preview unavailable",
"notFound": "File not found",
"loadFailed": "Load failed: {{error}}",
"noPreview": "No preview available for this file",
"unsupportedFormatTitle": "This file format is not supported for built-in preview or diff",
"unsupportedFormatHint": "Built-in preview and diff currently support directly readable text/Markdown-style files only. Open the file in Finder instead.",
"openInFinderFailed": "Could not reveal file"
},
"diff": {
"noChanges": "No changes to display",
"newFile": "New file — no original to compare",
"tooLarge": "File too large — diff view disabled, switch to the Source tab to view the full content",
"unavailable": "The current chat did not capture a precise baseline for this file, so no diff is available.",
"unavailableHint": "Use the Preview tab for the current file; use Git or another VCS for an exact diff."
}
},
"workspace": {
"title": "Workspace",
"freshBadge": "Updated this run",
"pickFile": "Select a file from the left to preview",
"empty": "Workspace is empty or inaccessible",
"truncated": "Directory truncated — only the first 5000 entries shown",
"actions": {
"refresh": "Refresh",
"openRootInFinder": "Reveal workspace in Finder",
"toggleHidden": "Toggle hidden files",
"showHidden": "Show hidden files",
"hideHidden": "Hide hidden files"
}
},
"taskPanel": {
"eyebrow": "Run View",

View File

@@ -55,7 +55,20 @@
"configurable": "Configurable",
"uninstall": "Uninstall",
"enable": "Enable",
"disable": "Disable"
"disable": "Disable",
"sections": {
"title": "Contents",
"docs": "Docs",
"docsDesc": "SKILL.md and supporting docs",
"scripts": "Scripts",
"scriptsDesc": "Executable scripts and commands",
"hooks": "Hooks",
"hooksDesc": "Lifecycle hooks injected into OpenClaw",
"assets": "Assets",
"assetsDesc": "Templates, references and static files",
"empty": "This skill has no previewable files.",
"scanFailed": "Failed to scan the skill directory"
}
},
"source": {
"badge": {

View File

@@ -13,7 +13,95 @@
"noLogs": "(ログはまだありません)",
"toolbar": {
"refresh": "チャットを更新",
"currentAgent": "現在の会話相手: {{agent}}"
"currentAgent": "現在の会話相手: {{agent}}",
"workspace": "ワークスペース"
},
"generatedFiles": {
"title": "ファイルの変更({{count}} 件)",
"created": "新規",
"modified": "更新",
"viewAll": "ファイル変更を見る"
},
"runError": {
"title": "モデル呼び出しに失敗しました"
},
"artifactPanel": {
"tabs": {
"changes": "変更",
"preview": "プレビュー",
"browser": "ワークスペース"
},
"actions": {
"back": "一覧に戻る"
},
"changes": {
"heading": "ファイルの変更({{count}} 件)",
"empty": "この会話にはまだファイルの変更がありません",
"selectFileHint": "会話内のファイルカード、または「変更を表示」をクリックして diff を開きます。"
},
"preview": {
"emptyTitle": "ファイルが選択されていません",
"emptyHint": "まず会話内のファイルカードをクリックしてファイルを選択すると、ここにプレビューが表示されます。"
}
},
"filePreview": {
"tabs": {
"source": "ソース",
"preview": "プレビュー",
"changes": "変更",
"info": "情報"
},
"actions": {
"save": "保存",
"saving": "保存中...",
"revert": "元に戻す",
"openInFinder": "Finder で表示",
"close": "閉じる"
},
"info": {
"path": "パス",
"size": "サイズ",
"type": "種類"
},
"toast": {
"saved": "ディスクに保存しました",
"saveFailed": "保存に失敗しました: {{error}}"
},
"errors": {
"outsideSandbox": "サンドボックス外のパスのため拒否しました",
"outsideSandboxTitle": "このファイルはサンドボックス外にあります",
"outsideSandboxHint": "安全のため、ClawX は ~/.openclaw、アプリ内リソース、内蔵スキルのディレクトリにあるファイルだけをプレビューします。Finder では引き続き開けます。",
"readOnlyRoot": "このファイルは読み取り専用領域(内蔵スキルなど)にあり、変更できません",
"tooLarge": "ファイルが大きすぎます({{size}})。プレビューを無効化しました",
"binary": "バイナリファイルのためテキストプレビューはできません",
"notFound": "ファイルが見つかりません",
"loadFailed": "読み込みに失敗しました: {{error}}",
"noPreview": "このファイルにはプレビューがありません",
"unsupportedFormatTitle": "このファイル形式は内蔵プレビューまたは差分表示に未対応です",
"unsupportedFormatHint": "現在、内蔵プレビューと差分表示は直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。Finder で開いてください。",
"openInFinderFailed": "Finder で表示できません"
},
"diff": {
"noChanges": "表示する変更はありません",
"newFile": "新規ファイルのため比較対象がありません",
"tooLarge": "ファイルが大きすぎるため diff ビューは無効化されました。完全な内容は「ソース」タブで確認してください",
"unavailable": "この会話ではこのファイルの正確な変更ベースラインを取得できなかったため、差分を生成できません。",
"unavailableHint": "「プレビュー」で現在の内容を確認するか、Git などで正確な差分を参照してください。"
}
},
"workspace": {
"title": "ワークスペース",
"freshBadge": "今回の実行で更新",
"pickFile": "左側のファイルを選択してプレビュー",
"empty": "ワークスペースが空、またはアクセスできません",
"truncated": "ディレクトリが大きいため、5000 件までで切り詰めました",
"actions": {
"refresh": "更新",
"openRootInFinder": "ルートを Finder で表示",
"toggleHidden": "隠しファイル表示切替",
"showHidden": "隠しファイルを表示",
"hideHidden": "隠しファイルを隠す"
}
},
"taskPanel": {
"eyebrow": "実行ビュー",

View File

@@ -55,7 +55,20 @@
"configurable": "設定可能",
"uninstall": "アンインストール",
"enable": "有効化",
"disable": "無効化"
"disable": "無効化",
"sections": {
"title": "コンテンツ",
"docs": "ドキュメント",
"docsDesc": "SKILL.md と関連ドキュメント",
"scripts": "スクリプト",
"scriptsDesc": "実行可能なスクリプトとコマンド",
"hooks": "Hooks",
"hooksDesc": "OpenClaw のライフサイクルに注入されるフック",
"assets": "アセット",
"assetsDesc": "テンプレート、参考資料、静的ファイル",
"empty": "このスキルにはプレビュー可能なファイルがありません。",
"scanFailed": "スキルディレクトリのスキャンに失敗しました"
}
},
"source": {
"badge": {

View File

@@ -13,7 +13,95 @@
"noLogs": "(Журналы ещё недоступны)",
"toolbar": {
"refresh": "Обновить чат",
"currentAgent": "Общение с {{agent}}"
"currentAgent": "Общение с {{agent}}",
"workspace": "Рабочая область"
},
"generatedFiles": {
"title": "Изменения файлов ({{count}})",
"created": "Создано",
"modified": "Изменено",
"viewAll": "Все изменения"
},
"runError": {
"title": "Ошибка вызова модели"
},
"artifactPanel": {
"tabs": {
"changes": "Изменения",
"preview": "Просмотр",
"browser": "Рабочая область"
},
"actions": {
"back": "Назад к списку"
},
"changes": {
"heading": "Изменения файлов ({{count}})",
"empty": "В этом разговоре пока нет изменений файлов",
"selectFileHint": "Нажмите на карточку файла в чате или «Просмотр изменений», чтобы открыть diff."
},
"preview": {
"emptyTitle": "Файл не выбран",
"emptyHint": "Сначала откройте файл через карточку в чате — выбранный файл появится в этом предпросмотре."
}
},
"filePreview": {
"tabs": {
"source": "Исходник",
"preview": "Предпросмотр",
"changes": "Изменения",
"info": "Информация"
},
"actions": {
"save": "Сохранить",
"saving": "Сохранение...",
"revert": "Откатить",
"openInFinder": "Показать в Finder",
"close": "Закрыть"
},
"info": {
"path": "Путь",
"size": "Размер",
"type": "Тип"
},
"toast": {
"saved": "Сохранено на диск",
"saveFailed": "Ошибка сохранения: {{error}}"
},
"errors": {
"outsideSandbox": "Путь вне песочницы — доступ запрещён",
"outsideSandboxTitle": "Файл находится вне песочницы",
"outsideSandboxHint": "В целях безопасности ClawX показывает предпросмотр только для файлов в ~/.openclaw, ресурсах приложения и встроенных каталогах навыков. Файл можно открыть в Finder.",
"readOnlyRoot": "Файл находится в режиме только для чтения (например, встроенный навык) и не может быть изменён",
"tooLarge": "Файл слишком большой ({{size}}); предпросмотр отключён",
"binary": "Бинарный файл — текстовый предпросмотр недоступен",
"notFound": "Файл не найден",
"loadFailed": "Ошибка загрузки: {{error}}",
"noPreview": "Для этого файла предпросмотр недоступен",
"unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр или diff",
"unsupportedFormatHint": "Встроенный предпросмотр и diff сейчас поддерживают только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через Finder.",
"openInFinderFailed": "Не удалось показать файл"
},
"diff": {
"noChanges": "Нет изменений для отображения",
"newFile": "Новый файл — нет оригинала для сравнения",
"tooLarge": "Файл слишком большой — режим diff отключён, откройте вкладку «Источник» для просмотра содержимого",
"unavailable": "В этом чате не удалось зафиксировать точную базовую версию этого файла, поэтому diff недоступен.",
"unavailableHint": "Откройте вкладку «Просмотр» для текущего содержимого; точное сравнение — в Git или другой системе."
}
},
"workspace": {
"title": "Рабочая область",
"freshBadge": "Обновлено в этот раз",
"pickFile": "Выберите файл слева для предпросмотра",
"empty": "Рабочая область пуста или недоступна",
"truncated": "Каталог обрезан — показаны первые 5000 узлов",
"actions": {
"refresh": "Обновить",
"openRootInFinder": "Показать корень в Finder",
"toggleHidden": "Скрытые файлы",
"showHidden": "Показать скрытые файлы",
"hideHidden": "Скрыть скрытые файлы"
}
},
"taskPanel": {
"eyebrow": "Вид выполнения",

View File

@@ -55,7 +55,20 @@
"configurable": "Настраиваемый",
"uninstall": "Удалить",
"enable": "Включить",
"disable": "Выключить"
"disable": "Выключить",
"sections": {
"title": "Содержимое",
"docs": "Документация",
"docsDesc": "SKILL.md и сопутствующие документы",
"scripts": "Скрипты",
"scriptsDesc": "Исполняемые скрипты и команды",
"hooks": "Hooks",
"hooksDesc": "Хуки в жизненный цикл OpenClaw",
"assets": "Ресурсы",
"assetsDesc": "Шаблоны, справочники и статические файлы",
"empty": "В этом скилле нет файлов для предпросмотра.",
"scanFailed": "Не удалось просканировать каталог скилла"
}
},
"source": {
"badge": {

View File

@@ -13,7 +13,95 @@
"noLogs": "(暂无日志)",
"toolbar": {
"refresh": "刷新聊天",
"currentAgent": "当前对话对象:{{agent}}"
"currentAgent": "当前对话对象:{{agent}}",
"workspace": "工作空间"
},
"generatedFiles": {
"title": "文件变更({{count}} 个)",
"created": "新增",
"modified": "修改",
"viewAll": "查看文件变更"
},
"runError": {
"title": "模型调用失败"
},
"artifactPanel": {
"tabs": {
"changes": "变更",
"preview": "预览",
"browser": "工作空间"
},
"actions": {
"back": "返回列表"
},
"changes": {
"heading": "文件变更({{count}} 个)",
"empty": "本会话尚无文件变更",
"selectFileHint": "请点击对话中的文件卡片,或「查看文件变更」打开 diff。"
},
"preview": {
"emptyTitle": "尚未选择文件",
"emptyHint": "请先点击对话里的文件卡片,侧栏会选中该文件后在此预览。"
}
},
"filePreview": {
"tabs": {
"source": "源码",
"preview": "预览",
"changes": "变更",
"info": "信息"
},
"actions": {
"save": "保存",
"saving": "保存中...",
"revert": "撤销",
"openInFinder": "在 Finder 中显示",
"close": "关闭"
},
"info": {
"path": "路径",
"size": "大小",
"type": "类型"
},
"toast": {
"saved": "已保存到磁盘",
"saveFailed": "保存失败:{{error}}"
},
"errors": {
"outsideSandbox": "路径越界,已拒绝访问",
"outsideSandboxTitle": "此文件位于沙盒外",
"outsideSandboxHint": "出于安全考虑ClawX 仅允许预览 ~/.openclaw、应用资源以及内置技能目录中的文件。可在 Finder 中查看。",
"readOnlyRoot": "该文件位于只读位置(如内置技能),无法修改",
"tooLarge": "文件过大({{size}}),已禁用预览",
"binary": "二进制文件不支持文本预览",
"notFound": "文件不存在",
"loadFailed": "加载失败:{{error}}",
"noPreview": "该文件没有预览",
"unsupportedFormatTitle": "此文件格式暂不支持内置预览或变更",
"unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。",
"openInFinderFailed": "无法在 Finder 中显示"
},
"diff": {
"noChanges": "没有可显示的变更",
"newFile": "这是新增文件,无对比内容",
"tooLarge": "文件过大,已禁用 diff 视图,请到「源码」标签查看完整内容",
"unavailable": "本会话没有抓到这个文件的精确变更基线,无法生成 diff。",
"unavailableHint": "可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。"
}
},
"workspace": {
"title": "工作空间",
"freshBadge": "本轮新增",
"pickFile": "从左侧选择一个文件预览",
"empty": "工作空间为空或无法访问",
"truncated": "目录过大,已截断显示前 5000 个节点",
"actions": {
"refresh": "刷新",
"openRootInFinder": "在 Finder 中显示根目录",
"toggleHidden": "显示/隐藏隐藏文件",
"showHidden": "显示隐藏文件",
"hideHidden": "隐藏隐藏文件"
}
},
"taskPanel": {
"eyebrow": "运行视图",

View File

@@ -55,7 +55,20 @@
"configurable": "可配置",
"uninstall": "卸载",
"enable": "启用",
"disable": "禁用"
"disable": "禁用",
"sections": {
"title": "内容",
"docs": "文档",
"docsDesc": "SKILL.md 与说明文档",
"scripts": "脚本",
"scriptsDesc": "可执行的脚本与命令",
"hooks": "Hooks",
"hooksDesc": "注入到 OpenClaw 生命周期的钩子",
"assets": "资源",
"assetsDesc": "模板、参考资料与静态资源",
"empty": "此技能未包含可预览的文件。",
"scanFailed": "扫描技能目录失败"
}
},
"source": {
"badge": {

View File

@@ -1061,3 +1061,100 @@ export async function invokeIpcWithRetry<T>(
throw normalizeAppError(lastError);
}
// ── File preview wrappers ─────────────────────────────────────────────
//
// Thin typed wrappers over the sandboxed file:* IPC channels exposed by
// the main process. Callers stay free of `invokeIpc('file:readText', ...)`
// boilerplate and get exhaustive error codes.
export type FilePreviewError =
| 'outsideSandbox'
| 'readOnlyRoot'
| 'tooLarge'
| 'binary'
| 'notFound'
| 'notDirectory'
| 'invalidContent'
| string;
export interface ReadTextFileResult {
ok: boolean;
content?: string;
mimeType?: string;
size?: number;
/**
* Set by the main process when the resolved path lives in a read-only
* root (bundled skill, app resources, …). The renderer should disable
* editing affordances when this is true even if the caller passes
* `readOnly={false}`.
*/
readOnly?: boolean;
error?: FilePreviewError;
}
export interface WriteTextFileResult {
ok: boolean;
error?: FilePreviewError;
}
export interface StatFileResult {
ok: boolean;
size?: number;
mtime?: number;
isFile?: boolean;
isDir?: boolean;
readOnly?: boolean;
error?: FilePreviewError;
}
export interface ListDirEntry {
name: string;
path: string;
isDir: boolean;
size: number;
}
export interface ListDirResult {
ok: boolean;
entries?: ListDirEntry[];
error?: FilePreviewError;
}
export interface TreeNode {
name: string;
relPath: string;
absPath: string;
isDir: boolean;
size?: number;
mtime?: number;
children?: TreeNode[];
}
export interface ListTreeOptions {
maxDepth?: number;
maxNodes?: number;
includeHidden?: boolean;
}
export interface ListTreeResult {
ok: boolean;
root?: TreeNode;
truncated?: boolean;
error?: FilePreviewError;
}
export const readTextFile = (path: string): Promise<ReadTextFileResult> =>
invokeIpc<ReadTextFileResult>('file:readText', path);
export const writeTextFile = (path: string, content: string): Promise<WriteTextFileResult> =>
invokeIpc<WriteTextFileResult>('file:writeText', path, content);
export const statFile = (path: string): Promise<StatFileResult> =>
invokeIpc<StatFileResult>('file:stat', path);
export const listDir = (path: string): Promise<ListDirResult> =>
invokeIpc<ListDirResult>('file:listDir', path);
export const listTree = (path: string, opts?: ListTreeOptions): Promise<ListTreeResult> =>
invokeIpc<ListTreeResult>('file:listTree', path, opts);

450
src/lib/generated-files.ts Normal file
View File

@@ -0,0 +1,450 @@
/**
* Generated files extraction.
*
* Inspects a run segment of chat messages (the slice from a user trigger
* to its terminating assistant reply) and surfaces files the AI wrote /
* edited via tool calls. Used by `GeneratedFilesPanel` to render inline
* file cards under each run, and by `FilePreviewBody` / `ArtifactPanel`
* to power the diff view.
*/
import { diffLines } from 'diff';
import type { ContentBlock, RawMessage } from '@/stores/chat';
export type FileContentType =
| 'snapshot'
| 'code'
| 'document'
| 'video'
| 'audio'
| 'other';
/** A single (old_string -> new_string) replacement extracted from an edit tool. */
export interface FileEditOp {
old: string;
new: string;
}
export type GeneratedFileBaseline =
| { status: 'ok'; content: string }
| { status: 'missing' }
| { status: 'unavailable'; reason: string };
export interface FileLineStats {
added: number;
removed: number;
}
export interface GeneratedFile {
filePath: string;
fileName: string;
ext: string;
mimeType: string;
contentType: FileContentType;
action: 'created' | 'modified';
/**
* Full new content of the file when known (only set by `Write`-family
* tools that provide the whole document in their input).
*/
fullContent?: string;
/**
* Ordered list of edits applied to this file during the run (Edit /
* StrReplace / MultiEdit). The diff view renders these directly as a
* snippet diff (joined `old` vs joined `new`), matching WorkBuddy /
* Codex behaviour.
*/
edits?: FileEditOp[];
/**
* File content captured immediately before a Write-family tool executed.
* `missing` means the file did not exist yet. `unavailable` means the
* renderer could not read the existing file precisely enough to build a
* trustworthy before/after diff (outside sandbox, binary, too large, ...).
*/
baseline?: GeneratedFileBaseline;
/** Index of the latest tool call that touched this file (for stable ordering). */
lastSeenIndex: number;
}
/** Visual separator between multiple edit hunks. */
const SNIPPET_SEPARATOR = '\n\n';
/**
* True when the chat extraction captured enough tool payload to render a
* diff (Write `fullContent` and/or non-empty Edit ops). Entries without
* this should not appear in generated-file UIs.
*/
export function generatedFileHasDiffPayload(file: Pick<GeneratedFile, 'fullContent' | 'edits'>): boolean {
if (file.fullContent != null) return true;
if (file.edits?.length) {
return file.edits.some((op) => (op.old ?? '') !== '' || (op.new ?? '') !== '');
}
return false;
}
const WRITE_TOOLS = new Set([
'Write',
'write_file',
'create_file',
'WriteFile',
'createFile',
'write',
]);
const EDIT_TOOLS = new Set([
'Edit',
'edit',
'edit_file',
'EditFile',
'StrReplace',
'str_replace',
'str_replace_editor',
'MultiEdit',
'multi_edit',
'multiEdit',
]);
const FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path'];
/** Best-effort detector that mirrors the buckets WorkBuddy uses internally. */
const SNAPSHOT_EXTS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico',
]);
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']);
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.m4a']);
const DOCUMENT_EXTS = new Set([
'.md', '.markdown', '.txt', '.rst', '.adoc',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
]);
const TEXT_DOCUMENT_EXTS = new Set([
'.md', '.markdown', '.txt', '.rst', '.adoc',
]);
const CODE_EXTS = new Set([
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
'.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
'.json', '.yaml', '.yml', '.toml', '.xml',
'.sh', '.bash', '.zsh', '.ps1',
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.sql', '.lua', '.r', '.dart',
]);
const EXT_MIME_MAP: Record<string, string> = {
'.md': 'text/markdown',
'.txt': 'text/plain',
'.json': 'application/json',
'.yaml': 'application/yaml',
'.yml': 'application/yaml',
'.toml': 'application/toml',
'.xml': 'application/xml',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.jsx': 'text/javascript',
'.mjs': 'text/javascript',
'.cjs': 'text/javascript',
'.ts': 'text/typescript',
'.tsx': 'text/typescript',
'.py': 'text/x-python',
'.sh': 'text/x-shellscript',
'.bash': 'text/x-shellscript',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.pdf': 'application/pdf',
'.csv': 'text/csv',
};
export function getMimeTypeForExt(ext: string): string {
return EXT_MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
}
export function classifyFileExt(ext: string): FileContentType {
const lower = ext.toLowerCase();
if (SNAPSHOT_EXTS.has(lower)) return 'snapshot';
if (VIDEO_EXTS.has(lower)) return 'video';
if (AUDIO_EXTS.has(lower)) return 'audio';
if (DOCUMENT_EXTS.has(lower)) return 'document';
if (CODE_EXTS.has(lower)) return 'code';
return 'other';
}
export function supportsInlineDocumentPreview(ext: string): boolean {
return TEXT_DOCUMENT_EXTS.has(ext.toLowerCase());
}
export function supportsInlineDiff(file: Pick<GeneratedFile, 'ext' | 'contentType'>): boolean {
if (file.contentType === 'document') return supportsInlineDocumentPreview(file.ext);
if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') return false;
return true;
}
export function basenameOf(path: string): string {
if (!path) return '';
const norm = path.replace(/\\/g, '/');
const last = norm.lastIndexOf('/');
return last >= 0 ? norm.slice(last + 1) : norm;
}
export function extnameOf(path: string): string {
const name = basenameOf(path);
const dot = name.lastIndexOf('.');
if (dot <= 0) return '';
return name.slice(dot);
}
function asRecord(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== 'object') return null;
return input as Record<string, unknown>;
}
function pickFilePath(input: unknown): string | null {
const rec = asRecord(input);
if (!rec) return null;
for (const key of FILE_PATH_KEYS) {
const value = rec[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return null;
}
function pickWriteContent(input: unknown): string | undefined {
const rec = asRecord(input);
if (!rec) return undefined;
for (const key of [
'content',
'contents',
'text',
'body',
'data',
'new_content',
'new_string',
'newString',
'string',
'source',
]) {
const value = rec[key];
if (typeof value === 'string') return value;
}
return undefined;
}
// Aliases mirror WorkBuddy's tool-arg normaliser so we accept whichever
// naming convention the agent emits (Codex, Claude, Cursor, Cline …).
const OLD_KEYS = [
'old_string', 'oldString', 'old_str', 'oldStr',
'old_text', 'oldText',
'old', 'oldContent', 'before', 'find', 'search',
];
const NEW_KEYS = [
'new_string', 'newString', 'new_str', 'newStr',
'new_text', 'newText',
'new', 'newContent', 'after', 'replace', 'replacement',
];
function pickStringByKeys(rec: Record<string, unknown>, keys: string[]): string | undefined {
for (const k of keys) {
const v = rec[k];
if (typeof v === 'string') return v;
}
return undefined;
}
function pickEditOps(input: unknown): FileEditOp[] {
const rec = asRecord(input);
if (!rec) return [];
const ops: FileEditOp[] = [];
const singleOld = pickStringByKeys(rec, OLD_KEYS);
const singleNew = pickStringByKeys(rec, NEW_KEYS);
if (singleOld !== undefined || singleNew !== undefined) {
ops.push({ old: singleOld ?? '', new: singleNew ?? '' });
}
const edits = rec.edits;
if (Array.isArray(edits)) {
for (const edit of edits as Array<Record<string, unknown>>) {
const o = pickStringByKeys(edit, OLD_KEYS) ?? '';
const n = pickStringByKeys(edit, NEW_KEYS) ?? '';
if (o !== '' || n !== '') ops.push({ old: o, new: n });
}
}
return ops;
}
function determineWriteAction(
existing: GeneratedFile | undefined,
baseline: GeneratedFileBaseline | undefined,
): 'created' | 'modified' {
if (existing?.action === 'created') return 'created';
if (!baseline) return existing ? 'modified' : 'created';
return baseline.status === 'missing' ? 'created' : 'modified';
}
function buildGeneratedFile(
filePath: string,
action: 'created' | 'modified',
parts: { fullContent?: string; edits?: FileEditOp[]; baseline?: GeneratedFileBaseline } | undefined,
index: number,
): GeneratedFile {
const fileName = basenameOf(filePath);
const ext = extnameOf(filePath);
return {
filePath,
fileName,
ext,
mimeType: getMimeTypeForExt(ext),
contentType: classifyFileExt(ext),
action,
fullContent: parts?.fullContent,
edits: parts?.edits,
baseline: parts?.baseline,
lastSeenIndex: index,
};
}
function normaliseEol(text: string): string {
return text.replace(/\r\n/g, '\n');
}
function joinEditText(edits: FileEditOp[], side: 'old' | 'new'): string {
return edits.map((op) => normaliseEol(op[side] ?? '')).join(SNIPPET_SEPARATOR);
}
function countLogicalLines(text: string): number {
const normalized = normaliseEol(text);
if (!normalized) return 0;
const parts = normalized.split('\n');
return normalized.endsWith('\n') ? Math.max(1, parts.length - 1) : parts.length;
}
function diffLineStats(oldText: string, newText: string): FileLineStats {
const pieces = diffLines(normaliseEol(oldText), normaliseEol(newText));
let added = 0;
let removed = 0;
for (const piece of pieces) {
const count = typeof piece.count === 'number' ? piece.count : countLogicalLines(piece.value);
if (piece.added) added += count;
if (piece.removed) removed += count;
}
return { added, removed };
}
export function computeLineStats(file: GeneratedFile): FileLineStats | null {
if (!supportsInlineDiff(file)) return null;
if (file.edits?.length) {
return diffLineStats(joinEditText(file.edits, 'old'), joinEditText(file.edits, 'new'));
}
if (file.fullContent == null) return null;
if (file.baseline?.status === 'ok') {
return diffLineStats(file.baseline.content, file.fullContent);
}
if (file.baseline?.status === 'missing') {
return { added: countLogicalLines(file.fullContent), removed: 0 };
}
if (file.baseline?.status === 'unavailable') {
return null;
}
if (file.action === 'created') {
return { added: countLogicalLines(file.fullContent), removed: 0 };
}
return null;
}
/**
* Walk the messages in `[triggerIndex, segmentEnd]` (inclusive) and
* collect the unique files written or edited by tool calls in that
* window. Deduplicates by `filePath`; if the file is touched by both
* a `Write` and a later `Edit`, the action is upgraded to `'modified'`
* but the diff content is kept from the last edit.
*/
export function extractGeneratedFiles(
messages: RawMessage[],
triggerIndex: number,
segmentEnd: number,
baselineGetter?: (filePath: string) => GeneratedFileBaseline | undefined,
): GeneratedFile[] {
const map = new Map<string, GeneratedFile>();
const start = Math.max(0, Math.min(triggerIndex + 1, messages.length));
const end = Math.max(start - 1, Math.min(segmentEnd, messages.length - 1));
for (let i = start; i <= end; i += 1) {
const message = messages[i];
if (!message || message.role !== 'assistant') continue;
const content = message.content;
if (!Array.isArray(content)) continue;
for (const block of content as ContentBlock[]) {
if (block.type !== 'tool_use' && block.type !== 'toolCall') continue;
const name = typeof block.name === 'string' ? block.name : '';
if (!name) continue;
const input = block.input ?? block.arguments;
const filePath = pickFilePath(input);
if (!filePath) continue;
const isWrite = WRITE_TOOLS.has(name);
const isEdit = EDIT_TOOLS.has(name);
const looksLikeFile = isWrite || isEdit || /\.[a-z0-9]{1,8}$/i.test(basenameOf(filePath));
if (!isWrite && !isEdit && !looksLikeFile) continue;
const existing = map.get(filePath);
if (isWrite) {
const newContent = pickWriteContent(input);
const baseline = baselineGetter?.(filePath);
const next = buildGeneratedFile(
filePath,
determineWriteAction(existing, baseline),
{ fullContent: newContent, edits: undefined, baseline },
i,
);
map.set(filePath, next);
continue;
}
if (isEdit) {
const newOps = pickEditOps(input);
const next = buildGeneratedFile(
filePath,
'modified',
{
fullContent: existing?.fullContent,
edits: [...(existing?.edits ?? []), ...newOps],
baseline: existing?.baseline,
},
i,
);
map.set(filePath, next);
continue;
}
// Unknown tool but its input mentions a file path with extension —
// surface as best-effort "modified" without diff content.
if (!existing) {
map.set(filePath, buildGeneratedFile(filePath, 'modified', undefined, i));
} else {
existing.lastSeenIndex = i;
}
}
}
return Array.from(map.values()).sort((a, b) => a.lastSeenIndex - b.lastSeenIndex);
}

95
src/lib/monaco/loader.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Self-hosted Monaco Editor loader.
*
* `@monaco-editor/react` defaults to fetching Monaco from a CDN, which
* is unusable in clawx's offline Electron environment. We instead bundle
* the editor + its language workers locally via Vite's `?worker` import
* so each preview overlay can spin up Monaco without any network.
*
* Importing this module once is enough — `loader.config({ monaco })`
* registers the bundled Monaco instance globally.
*/
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import { loader } from '@monaco-editor/react';
interface MonacoEnvironment {
getWorker(workerId: string, label: string): Worker;
}
(self as unknown as { MonacoEnvironment: MonacoEnvironment }).MonacoEnvironment = {
getWorker(_workerId: string, label: string): Worker {
if (label === 'json') return new jsonWorker();
if (label === 'typescript' || label === 'javascript') return new tsWorker();
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker();
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker();
return new editorWorker();
},
};
loader.config({ monaco });
export { monaco };
export { Editor, DiffEditor, loader } from '@monaco-editor/react';
const EXT_LANGUAGE_MAP: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.json': 'json',
'.html': 'html',
'.htm': 'html',
'.css': 'css',
'.scss': 'scss',
'.less': 'less',
'.md': 'markdown',
'.markdown': 'markdown',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'plaintext',
'.xml': 'xml',
'.py': 'python',
'.rb': 'ruby',
'.go': 'go',
'.rs': 'rust',
'.java': 'java',
'.kt': 'kotlin',
'.swift': 'swift',
'.sh': 'shell',
'.bash': 'shell',
'.zsh': 'shell',
'.ps1': 'powershell',
'.sql': 'sql',
'.dart': 'dart',
'.php': 'php',
'.c': 'c',
'.cc': 'cpp',
'.cpp': 'cpp',
'.h': 'cpp',
'.hpp': 'cpp',
'.cs': 'csharp',
'.vue': 'html',
'.dockerfile': 'dockerfile',
};
export function languageForExt(ext: string): string {
if (!ext) return 'plaintext';
const lower = ext.toLowerCase();
return EXT_LANGUAGE_MAP[lower] ?? 'plaintext';
}
export function languageForPath(path: string): string {
if (!path) return 'plaintext';
const norm = path.replace(/\\/g, '/').toLowerCase();
if (norm.endsWith('/dockerfile') || norm === 'dockerfile') return 'dockerfile';
const dot = norm.lastIndexOf('.');
if (dot < 0) return 'plaintext';
return languageForExt(norm.slice(dot));
}

229
src/lib/skill-files.ts Normal file
View File

@@ -0,0 +1,229 @@
/**
* Skill directory introspection.
*
* Recursively scans a skill's `baseDir`, filtering noisy directories
* (node_modules, __pycache__, .venv, …) and assigns each surviving file
* to one of four buckets so the Skills detail page can render
* "Docs / Scripts / Hooks / Assets" sections.
*/
import { listDir } from './api-client';
import {
basenameOf,
classifyFileExt,
extnameOf,
getMimeTypeForExt,
type FileContentType,
} from './generated-files';
export type SkillFileCategory = 'doc' | 'script' | 'hook' | 'asset' | 'other';
export interface SkillFile {
filePath: string;
relativePath: string;
fileName: string;
ext: string;
mimeType: string;
contentType: FileContentType;
category: SkillFileCategory;
size: number;
}
export interface SkillFileGroups {
docs: SkillFile[];
scripts: SkillFile[];
hooks: SkillFile[];
assets: SkillFile[];
others: SkillFile[];
}
export const EMPTY_SKILL_GROUPS: SkillFileGroups = {
docs: [],
scripts: [],
hooks: [],
assets: [],
others: [],
};
const SCAN_BLACKLIST = new Set([
'node_modules',
'.venv',
'__pycache__',
'.git',
'dist',
'build',
'.next',
'.turbo',
'.cache',
]);
const SCRIPT_EXTS = new Set([
'.py', '.js', '.ts', '.mjs', '.cjs', '.sh', '.bash', '.zsh', '.ps1',
'.rb', '.go', '.rs', '.java', '.kt', '.swift',
'.tsx', '.jsx', '.lua', '.r',
]);
const ASSET_EXTS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico',
'.mp3', '.wav', '.ogg', '.flac', '.m4a',
'.mp4', '.webm', '.mov',
'.ttf', '.otf', '.woff', '.woff2',
'.pdf', '.zip', '.tar', '.gz',
]);
const DOC_NAME_HINTS = ['readme', 'changelog', 'license', 'contributing', 'authors'];
const ASSET_PATH_HINTS = ['/assets/', '/references/', '/templates/', '/static/', '/public/'];
const MAX_SCAN_DEPTH = 3;
const MAX_SCAN_FILES = 400;
function categorizeSkillFile(relativePath: string, fileName: string, ext: string): SkillFileCategory {
const lowerExt = ext.toLowerCase();
const lowerName = fileName.toLowerCase();
const lowerRel = relativePath.replace(/\\/g, '/').toLowerCase();
const lowerRelWithSlash = `/${lowerRel}`;
if (lowerRel.startsWith('hooks/') || lowerRelWithSlash.includes('/hooks/')) {
return 'hook';
}
if (lowerRel.startsWith('scripts/') || lowerRelWithSlash.includes('/scripts/')) {
return 'script';
}
if (ASSET_PATH_HINTS.some((hint) => lowerRelWithSlash.includes(hint))) {
return 'asset';
}
if (lowerExt === '.md' || lowerExt === '.markdown' || lowerExt === '.rst' || lowerExt === '.txt') {
return 'doc';
}
if (DOC_NAME_HINTS.some((hint) => lowerName.startsWith(hint))) {
return 'doc';
}
if (SCRIPT_EXTS.has(lowerExt)) {
return 'script';
}
if (ASSET_EXTS.has(lowerExt)) {
return 'asset';
}
return 'other';
}
function buildSkillFile(absPath: string, relPath: string, size: number): SkillFile {
const fileName = basenameOf(absPath);
const ext = extnameOf(absPath);
return {
filePath: absPath,
relativePath: relPath,
fileName,
ext,
mimeType: getMimeTypeForExt(ext),
contentType: classifyFileExt(ext),
category: categorizeSkillFile(relPath, fileName, ext),
size,
};
}
function relPathFromBase(absPath: string, baseDir: string): string {
const baseNorm = baseDir.replace(/\\/g, '/').replace(/\/+$/, '');
const norm = absPath.replace(/\\/g, '/');
if (norm === baseNorm) return '';
if (norm.startsWith(baseNorm + '/')) {
return norm.slice(baseNorm.length + 1);
}
return norm;
}
/**
* Scan `baseDir` (skill root) and return files grouped by category.
* Stops descending into noise directories and caps total file count.
*/
export async function loadSkillFiles(baseDir: string): Promise<SkillFileGroups> {
if (!baseDir) return EMPTY_SKILL_GROUPS;
const groups: SkillFileGroups = {
docs: [],
scripts: [],
hooks: [],
assets: [],
others: [],
};
const files: SkillFile[] = [];
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > MAX_SCAN_DEPTH) return;
if (files.length >= MAX_SCAN_FILES) return;
const result = await listDir(dir);
if (!result.ok || !result.entries) return;
for (const entry of result.entries) {
if (files.length >= MAX_SCAN_FILES) break;
if (entry.isDir) {
if (SCAN_BLACKLIST.has(entry.name)) continue;
if (entry.name.startsWith('.')) continue;
await walk(entry.path, depth + 1);
} else {
if (entry.name.startsWith('.')) continue;
const rel = relPathFromBase(entry.path, baseDir);
files.push(buildSkillFile(entry.path, rel, entry.size));
}
}
};
try {
await walk(baseDir, 1);
} catch {
return groups;
}
for (const file of files) {
switch (file.category) {
case 'doc':
groups.docs.push(file);
break;
case 'script':
groups.scripts.push(file);
break;
case 'hook':
groups.hooks.push(file);
break;
case 'asset':
groups.assets.push(file);
break;
default:
groups.others.push(file);
}
}
// Stable order: SKILL.md first in docs, then alphabetical; others alphabetical.
const skillMdSort = (a: SkillFile, b: SkillFile): number => {
const aIsSkill = a.fileName.toLowerCase() === 'skill.md';
const bIsSkill = b.fileName.toLowerCase() === 'skill.md';
if (aIsSkill !== bIsSkill) return aIsSkill ? -1 : 1;
return a.relativePath.localeCompare(b.relativePath);
};
const alphaSort = (a: SkillFile, b: SkillFile): number =>
a.relativePath.localeCompare(b.relativePath);
groups.docs.sort(skillMdSort);
groups.scripts.sort(alphaSort);
groups.hooks.sort(alphaSort);
groups.assets.sort(alphaSort);
groups.others.sort(alphaSort);
return groups;
}
export function isSkillFileGroupsEmpty(groups: SkillFileGroups): boolean {
return groups.docs.length === 0 &&
groups.scripts.length === 0 &&
groups.hooks.length === 0 &&
groups.assets.length === 0 &&
groups.others.length === 0;
}

124
src/lib/workspace-tree.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* Agent workspace file tree.
*
* Loads the directory tree rooted at `agent.workspace` (e.g.
* `~/.openclaw/workspace` for the default agent) for the
* `WorkspaceBrowserBody` / artifact-panel browser tab. Strictly scoped to that one directory
* so sibling configuration paths (`runs/`, `agents/`,
* `auth-profiles.json`, …) under `~/.openclaw` are never exposed.
*/
import { listTree, type TreeNode } from './api-client';
import {
basenameOf,
classifyFileExt,
extnameOf,
getMimeTypeForExt,
type FileContentType,
} from './generated-files';
export interface WorkspaceTreeNode {
name: string;
relPath: string;
absPath: string;
isDir: boolean;
size?: number;
mtime?: number;
/** True when the file's mtime is at or after the provided run start time. */
isFresh?: boolean;
ext?: string;
mimeType?: string;
contentType?: FileContentType;
children?: WorkspaceTreeNode[];
}
export interface LoadWorkspaceTreeResult {
root: WorkspaceTreeNode;
truncated: boolean;
}
export interface LoadWorkspaceTreeOptions {
runStartedAt?: number | null;
maxDepth?: number;
maxNodes?: number;
includeHidden?: boolean;
}
function decorate(node: TreeNode, runStartedAt: number | null): WorkspaceTreeNode {
if (node.isDir) {
const children = (node.children ?? []).map((child) => decorate(child, runStartedAt));
return {
name: node.name,
relPath: node.relPath,
absPath: node.absPath,
isDir: true,
mtime: node.mtime,
children,
};
}
const ext = extnameOf(node.absPath);
const isFresh =
runStartedAt != null && typeof node.mtime === 'number' && node.mtime >= runStartedAt;
return {
name: node.name,
relPath: node.relPath,
absPath: node.absPath,
isDir: false,
size: node.size,
mtime: node.mtime,
ext,
mimeType: getMimeTypeForExt(ext),
contentType: classifyFileExt(ext),
isFresh,
};
}
export async function loadWorkspaceTree(
workspace: string,
opts: LoadWorkspaceTreeOptions = {},
): Promise<LoadWorkspaceTreeResult | null> {
if (!workspace) return null;
const result = await listTree(workspace, {
maxDepth: opts.maxDepth,
maxNodes: opts.maxNodes,
includeHidden: opts.includeHidden,
});
if (!result.ok || !result.root) {
return null;
}
const runStartedAt = opts.runStartedAt ?? null;
const root = decorate(result.root, runStartedAt);
// Use the workspace's directory name for the synthetic root label so the
// tree shows e.g. `workspace` instead of an absolute path.
root.name = basenameOf(workspace) || root.name;
return { root, truncated: !!result.truncated };
}
export function findNode(root: WorkspaceTreeNode, relPath: string): WorkspaceTreeNode | null {
if (!root) return null;
if (root.relPath === relPath) return root;
if (!root.children) return null;
for (const child of root.children) {
const hit = findNode(child, relPath);
if (hit) return hit;
}
return null;
}
export function collectInitialExpanded(
root: WorkspaceTreeNode | null,
initialDepth = 1,
): Set<string> {
const out = new Set<string>();
if (!root) return out;
const walk = (node: WorkspaceTreeNode, depth: number): void => {
if (!node.isDir) return;
if (depth <= initialDepth) {
out.add(node.relPath);
}
for (const child of node.children ?? []) {
walk(child, depth + 1);
}
};
walk(root, 0);
return out;
}

View File

@@ -39,6 +39,12 @@ interface ChatMessageProps {
durationMs?: number;
summary?: string;
}>;
/**
* Optional callback invoked when a non-image file card is clicked.
* When provided, the file opens in the in-app preview panel instead of
* the system default editor.
*/
onOpenFile?: (file: AttachedFileMeta) => void;
}
interface ExtractedImage { url?: string; data?: string; mimeType: string; }
@@ -88,6 +94,7 @@ export const ChatMessage = memo(function ChatMessage({
suppressAssistantText = false,
isStreaming = false,
streamingTools = [],
onOpenFile,
}: ChatMessageProps) {
const isUser = message.role === 'user';
const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
@@ -102,12 +109,14 @@ export const ChatMessage = memo(function ChatMessage({
const images = extractImages(message);
const tools = extractToolUse(message);
const visibleTools = suppressToolCards ? [] : tools;
const shouldHideProcessAttachments = suppressProcessAttachments
&& (hasText || images.length > 0 || visibleTools.length > 0);
const attachedFiles = shouldHideProcessAttachments
? (message._attachedFiles || []).filter((file) => file.source !== 'tool-result')
: (message._attachedFiles || []);
const rawAttachedFiles = message._attachedFiles || [];
const filteredProcessAttachments = rawAttachedFiles.filter((file) => file.source !== 'tool-result' && file.source !== 'message-ref');
// When a message is attachment-only, keep those attachments visible even if
// process attachments are generally suppressed for this run segment
// otherwise the reply disappears entirely.
const attachedFiles = suppressProcessAttachments && (hasText || images.length > 0 || visibleTools.length > 0)
? filteredProcessAttachments
: rawAttachedFiles;
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
// Never render tool result messages in chat UI
@@ -198,7 +207,7 @@ export const ChatMessage = memo(function ChatMessage({
);
}
// Non-image files → file card
return <FileCard key={`local-${i}`} file={file} />;
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
</div>
)}
@@ -257,7 +266,7 @@ export const ChatMessage = memo(function ChatMessage({
</div>
);
}
return <FileCard key={`local-${i}`} file={file} />;
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
</div>
)}
@@ -454,12 +463,15 @@ function FileIcon({ mimeType, className }: { mimeType: string; className?: strin
return <File className={className} />;
}
function FileCard({ file }: { file: AttachedFileMeta }) {
function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) {
const handleOpen = useCallback(() => {
if (file.filePath) {
if (!file.filePath) return;
if (onOpen) {
onOpen(file);
} else {
invokeIpc('shell:openPath', file.filePath);
}
}, [file.filePath]);
}, [file, onOpen]);
return (
<div

View File

@@ -1,27 +1,36 @@
/**
* Chat Toolbar
* Session selector, new session, and refresh.
* Rendered in the Header when on the Chat page.
* Session selector, new session, refresh, and the workspace browser
* entry point. Rendered in the Header when on the Chat page.
*/
import { useMemo } from 'react';
import { RefreshCw, Bot } from 'lucide-react';
import { RefreshCw, Bot, FolderTree } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useChatStore } from '@/stores/chat';
import { useAgentsStore } from '@/stores/agents';
import { useArtifactPanel } from '@/stores/artifact-panel';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { WORKSPACE_BROWSER_ENABLED } from '@/components/file-preview/workspace-browser-config';
export function ChatToolbar() {
const refresh = useChatStore((s) => s.refresh);
const loading = useChatStore((s) => s.loading);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const agents = useAgentsStore((s) => s.agents);
const openBrowser = useArtifactPanel((s) => s.openBrowser);
const panelOpen = useArtifactPanel((s) => s.open);
const panelTab = useArtifactPanel((s) => s.tab);
const closePanel = useArtifactPanel((s) => s.close);
const { t } = useTranslation('chat');
const currentAgentName = useMemo(
() => (agents ?? []).find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
const currentAgent = useMemo(
() => (agents ?? []).find((agent) => agent.id === currentAgentId) ?? null,
[agents, currentAgentId],
);
const currentAgentName = currentAgent?.name ?? currentAgentId;
const browserActive = WORKSPACE_BROWSER_ENABLED && panelOpen && panelTab === 'browser';
return (
<div className="flex items-center gap-2">
@@ -29,6 +38,25 @@ export function ChatToolbar() {
<Bot className="h-3.5 w-3.5 text-primary" />
<span>{t('toolbar.currentAgent', { agent: currentAgentName })}</span>
</div>
{WORKSPACE_BROWSER_ENABLED && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', browserActive && 'bg-foreground/10 text-foreground')}
onClick={() => (browserActive ? closePanel() : openBrowser())}
disabled={!currentAgent?.workspace}
aria-label={t('toolbar.workspace', '工作空间')}
>
<FolderTree className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('toolbar.workspace', '工作空间')}</p>
</TooltipContent>
</Tooltip>
)}
{/* Refresh */}
<Tooltip>
<TooltipTrigger asChild>
@@ -38,6 +66,7 @@ export function ChatToolbar() {
className="h-8 w-8"
onClick={() => refresh()}
disabled={loading}
aria-label={t('toolbar.refresh')}
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>

View File

@@ -4,11 +4,13 @@
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh
* are in the toolbar; messages render with markdown + streaming.
*/
import { useEffect, useMemo, useState } from 'react';
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
import { useChatStore, type RawMessage } from '@/stores/chat';
import { buildBaselineRunKey, getBaseline } from '@/stores/baseline-cache';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { useArtifactPanel } from '@/stores/artifact-panel';
import { hostApiFetch } from '@/lib/host-api';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage';
@@ -21,6 +23,18 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
import { useMinLoading } from '@/hooks/use-min-loading';
import { extractGeneratedFiles, generatedFileHasDiffPayload, type GeneratedFile } from '@/lib/generated-files';
import { GeneratedFilesPanel } from '@/components/file-preview/GeneratedFilesPanel';
import type { FilePreviewTarget } from '@/components/file-preview/types';
import { buildPreviewTarget } from '@/components/file-preview/build-preview-target';
import type { AttachedFileMeta } from '@/stores/chat/types';
const ArtifactPanelLazy = lazy(() =>
import('@/components/file-preview/ArtifactPanel').then((m) => ({ default: m.ArtifactPanel })),
);
const PanelResizeDividerLazy = lazy(() =>
import('@/components/file-preview/PanelResizeDivider').then((m) => ({ default: m.PanelResizeDivider })),
);
type GraphStepCacheEntry = {
steps: ReturnType<typeof deriveTaskSteps>;
@@ -61,6 +75,20 @@ function getPrimaryMessageStepTexts(steps: TaskStep[]): string[] {
.map((step) => step.detail!);
}
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,
};
}
// Keep the last non-empty execution-graph snapshot per session/run outside
// React state so `loadHistory` refreshes can still fall back to the previous
// steps without tripping React's set-state-in-effect lint rule.
@@ -91,7 +119,32 @@ export function Chat() {
const agents = useAgentsStore((s) => s.agents);
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
const lastUserMessageAt = useChatStore((s) => s.lastUserMessageAt);
const agentsList = useAgentsStore((s) => s.agents);
const currentAgent = useMemo(
() => (agentsList ?? []).find((a) => a.id === currentAgentId) ?? null,
[agentsList, currentAgentId],
);
const panelOpen = useArtifactPanel((s) => s.open);
const panelWidthPct = useArtifactPanel((s) => s.widthPct);
const openChanges = useArtifactPanel((s) => s.openChanges);
const openPreview = useArtifactPanel((s) => s.openPreview);
const closeArtifactPanel = useArtifactPanel((s) => s.close);
const splitContainerRef = useRef<HTMLDivElement | null>(null);
// Close the panel when the session changes — its contents would otherwise
// be stale (file list belongs to the previous chat).
useEffect(() => {
closeArtifactPanel();
}, [currentSessionKey, closeArtifactPanel]);
const [childTranscripts, setChildTranscripts] = useState<Record<string, RawMessage[]>>({});
// Callback for file cards in chat messages — opens the in-app preview
// panel instead of the system default editor.
const handleOpenAttachedFile = useCallback((file: AttachedFileMeta) => {
if (!file.filePath) return;
const target = buildPreviewTarget(file.filePath, file.fileName);
openPreview(target);
}, [openPreview]);
// Persistent per-run override for the Execution Graph's expanded/collapsed
// state. Keyed by a stable run id (trigger message id, or a fallback of
// `${sessionKey}:${triggerIdx}`) so user toggles survive the `loadHistory`
@@ -505,6 +558,38 @@ export function Chat() {
// streaming or has a reply override) during render instead of in an effect,
// so we don't violate react-hooks/set-state-in-effect. Explicit user toggles
// still win via `graphExpandedOverrides` and are merged in at the call site.
// Pre-compute generated files per run (memoised so the cards and the
// ArtifactPanel can both read them without re-parsing tool calls every
// render).
const filesByRun = useMemo(() => {
const map = new Map<number, GeneratedFile[]>();
for (const card of userRunCards) {
const userTurnOrdinal = messages
.slice(0, card.triggerIndex + 1)
.filter((msg) => msg.role === 'user' && (!Array.isArray(msg.content) || !(msg.content as Array<{ type?: string }>).every((b) => b.type === 'tool_result' || b.type === 'toolResult')))
.length;
const runKey = buildBaselineRunKey(currentSessionKey, userTurnOrdinal);
const raw = extractGeneratedFiles(
messages,
card.triggerIndex,
card.segmentEnd,
runKey ? (filePath) => getBaseline(runKey, filePath) : undefined,
);
map.set(card.triggerIndex, raw.filter(generatedFileHasDiffPayload));
}
return map;
}, [currentSessionKey, userRunCards, messages]);
const allGeneratedFiles = useMemo(() => {
const all: GeneratedFile[] = [];
for (const files of filesByRun.values()) all.push(...files);
return all;
}, [filesByRun]);
const refreshSignal = useMemo(() => {
if (sending) return undefined;
return lastUserMessageAt ?? 0;
}, [sending, lastUserMessageAt]);
const autoCollapsedRunKeys = useMemo(() => {
const keys = new Set<string>();
for (const card of userRunCards) {
@@ -573,7 +658,13 @@ export function Chat() {
}, [userRunCards, messages, currentSessionKey]);
return (
<div className={cn("relative flex min-h-0 flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
<div
ref={splitContainerRef}
className={cn('relative flex min-h-0 -m-6 transition-colors duration-500 dark:bg-background')}
style={{ height: 'calc(100vh - 2.5rem)' }}
>
{/* Left column: chat */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-end px-4 py-2">
<ChatToolbar />
@@ -611,6 +702,7 @@ export function Chat() {
textOverride={replyTextOverrides.get(idx)}
suppressToolCards={suppressToolCards}
suppressProcessAttachments={suppressToolCards}
onOpenFile={handleOpenAttachedFile}
/>
{userRunCards
.filter((card) => card.triggerIndex === idx)
@@ -629,18 +721,27 @@ export function Chat() {
const expanded = userOverride != null
? userOverride
: !autoCollapsedRunKeys.has(runKey);
const generatedFiles = filesByRun.get(card.triggerIndex) ?? [];
return (
<ExecutionGraphCard
key={`graph-${currentSessionKey}:${card.triggerIndex}`}
agentLabel={card.agentLabel}
steps={card.steps}
active={card.active}
suppressThinking={card.suppressThinking}
expanded={expanded}
onExpandedChange={(next) =>
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
}
/>
<div key={`run-${currentSessionKey}:${card.triggerIndex}`} className="space-y-3">
<ExecutionGraphCard
key={`graph-${currentSessionKey}:${card.triggerIndex}`}
agentLabel={card.agentLabel}
steps={card.steps}
active={card.active}
suppressThinking={card.suppressThinking}
expanded={expanded}
onExpandedChange={(next) =>
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
}
/>
{generatedFiles.length > 0 && (
<GeneratedFilesPanel
files={generatedFiles}
onOpen={(file) => openChanges(generatedFileToTarget(file))}
/>
)}
</div>
);
})}
</div>
@@ -681,6 +782,7 @@ export function Chat() {
textOverride={streamingReplyText ?? undefined}
isStreaming
streamingTools={streamingReplyText != null ? [] : streamingTools}
onOpenFile={handleOpenAttachedFile}
/>
)}
@@ -742,6 +844,35 @@ export function Chat() {
sending={sending || hasActiveExecutionGraph}
isEmpty={isEmpty}
/>
</div>
{/* Right column: artifact / file preview panel (WorkBuddy-style) */}
{panelOpen && (
<>
<Suspense fallback={null}>
<PanelResizeDividerLazy containerRef={splitContainerRef} />
</Suspense>
<aside
className="hidden shrink-0 border-l border-black/5 dark:border-white/10 lg:flex lg:flex-col"
style={{ width: `${panelWidthPct}%` }}
>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<LoadingSpinner size="md" />
</div>
}
>
<ArtifactPanelLazy
files={allGeneratedFiles}
agent={currentAgent}
runStartedAt={lastUserMessageAt ?? null}
refreshSignal={refreshSignal}
/>
</Suspense>
</aside>
</>
)}
{/* Transparent loading overlay */}
{minLoading && !sending && (

View File

@@ -2,7 +2,7 @@
* Skills Page
* Browse and manage AI skills
*/
import { useEffect, useState, useCallback } from 'react';
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
import {
Search,
Puzzle,
@@ -10,13 +10,9 @@ import {
Package,
X,
AlertCircle,
Plus,
Key,
Trash2,
RefreshCw,
FolderOpen,
FileCode,
Globe,
Copy,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -36,6 +32,23 @@ import type { Skill } from '@/types/skill';
import { rendererExtensionRegistry } from '@/extensions/registry';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { SkillFileSections } from '@/components/file-preview/SkillFileSections';
import type { FilePreviewTarget } from '@/components/file-preview/FilePreviewOverlay';
import type { SkillFile } from '@/lib/skill-files';
const FilePreviewOverlayLazy = lazy(() =>
import('@/components/file-preview/FilePreviewOverlay').then((m) => ({ default: m.FilePreviewOverlay })),
);
function skillFileToTarget(file: SkillFile): FilePreviewTarget {
return {
filePath: file.filePath,
fileName: file.fileName,
ext: file.ext,
mimeType: file.mimeType,
contentType: file.contentType,
};
}
const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitError']);
const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']);
@@ -70,57 +83,9 @@ function resolveSkillSourceLabel(skill: Skill, t: TFunction<'skills'>): string {
function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOpenFolder }: SkillDetailDialogProps) {
const { t } = useTranslation('skills');
const { fetchSkills } = useSkillsStore();
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
const [apiKey, setApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [openedSkillFile, setOpenedSkillFile] = useState<FilePreviewTarget | null>(null);
const detailMetaComponents = rendererExtensionRegistry.getSkillDetailMetaComponents();
// Initialize config from skill
useEffect(() => {
if (!skill) return;
// API Key
if (skill.config?.apiKey) {
setApiKey(String(skill.config.apiKey));
} else {
setApiKey('');
}
// Env Vars
if (skill.config?.env) {
const vars = Object.entries(skill.config.env).map(([key, value]) => ({
key,
value: String(value),
}));
setEnvVars(vars);
} else {
setEnvVars([]);
}
}, [skill]);
const handleOpenClawhub = async () => {
if (!skill?.slug) return;
await invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
};
const handleOpenEditor = async () => {
if (!skill?.id) return;
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', {
method: 'POST',
body: JSON.stringify({ skillKey: skill.id, slug: skill.slug, baseDir: skill.baseDir }),
});
if (result.success) {
toast.success(t('toast.openedEditor'));
} else {
toast.error(result.error || t('toast.failedEditor'));
}
} catch (err) {
toast.error(t('toast.failedEditor') + ': ' + String(err));
}
};
const handleCopyPath = async () => {
if (!skill?.baseDir) return;
try {
@@ -131,65 +96,17 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp
}
};
const handleAddEnv = () => {
setEnvVars([...envVars, { key: '', value: '' }]);
};
const handleUpdateEnv = (index: number, field: 'key' | 'value', value: string) => {
const newVars = [...envVars];
newVars[index] = { ...newVars[index], [field]: value };
setEnvVars(newVars);
};
const handleRemoveEnv = (index: number) => {
const newVars = [...envVars];
newVars.splice(index, 1);
setEnvVars(newVars);
};
const handleSaveConfig = async () => {
if (isSaving || !skill) return;
setIsSaving(true);
try {
// Build env object, filtering out empty keys
const envObj = envVars.reduce((acc, curr) => {
const key = curr.key.trim();
const value = curr.value.trim();
if (key) {
acc[key] = value;
}
return acc;
}, {} as Record<string, string>);
// Use direct file access instead of Gateway RPC for reliability
const result = await invokeIpc<{ success: boolean; error?: string }>(
'skill:updateConfig',
{
skillKey: skill.id,
apiKey: apiKey || '', // Empty string will delete the key
env: envObj // Empty object will clear all env vars
}
) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Unknown error');
}
// Refresh skills from gateway to get updated config
await fetchSkills();
toast.success(t('detail.configSaved'));
} catch (err) {
toast.error(t('toast.failedSave') + ': ' + String(err));
} finally {
setIsSaving(false);
}
};
if (!skill) return null;
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Suspense fallback={null}>
<FilePreviewOverlayLazy
file={openedSkillFile}
readOnly
onClose={() => setOpenedSkillFile(null)}
/>
</Suspense>
<SheetContent
className="w-full sm:max-w-[450px] p-0 flex flex-col border-l border-black/10 dark:border-white/10 bg-surface-modal shadow-[0_0_40px_rgba(0,0,0,0.2)]"
side="right"
@@ -264,120 +181,27 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp
</div>
</div>
{/* API Key Section */}
{!skill.isCore && (
<div className="space-y-2">
<h3 className="text-meta font-bold flex items-center gap-2 text-foreground/80">
<Key className="h-3.5 w-3.5 text-blue-500" />
{t('detail.apiKey')}
</h3>
<Input
placeholder={t('detail.apiKeyPlaceholder', 'Enter API Key (optional)')}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
type="password"
className="h-[44px] font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40"
/>
<p className="text-xs text-foreground/50 mt-2 font-medium">
{t('detail.apiKeyDesc', 'The primary API key for this skill. Leave blank if not required or configured elsewhere.')}
</p>
</div>
)}
{/* Environment Variables Section */}
{!skill.isCore && (
{/* File Sections — read-only preview of skill content */}
{skill.baseDir && (
<div className="space-y-3">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<h3 className="text-meta font-bold text-foreground/80">
{t('detail.envVars')}
{envVars.length > 0 && (
<Badge variant="secondary" className="ml-2 px-1.5 py-0 text-2xs h-5 bg-black/10 dark:bg-white/10 text-foreground">
{envVars.length}
</Badge>
)}
</h3>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs font-semibold text-foreground/80 gap-1.5 px-2.5 hover:bg-black/5 dark:hover:bg-white/5"
onClick={handleAddEnv}
>
<Plus className="h-3 w-3" strokeWidth={3} />
{t('detail.addVariable', 'Add Variable')}
</Button>
</div>
<div className="space-y-2">
{envVars.length === 0 && (
<div className="text-meta text-foreground/50 font-medium italic flex items-center bg-surface-input border border-black/5 dark:border-white/5 rounded-xl px-4 py-3 shadow-sm">
{t('detail.noEnvVars', 'No environment variables configured.')}
</div>
)}
{envVars.map((env, index) => (
<div className="flex items-center gap-3" key={index}>
<Input
value={env.key}
onChange={(e) => handleUpdateEnv(index, 'key', e.target.value)}
className="flex-1 h-[40px] font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm text-foreground"
placeholder={t('detail.keyPlaceholder', 'Key')}
/>
<Input
value={env.value}
onChange={(e) => handleUpdateEnv(index, 'value', e.target.value)}
className="flex-1 h-[40px] font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm text-foreground"
placeholder={t('detail.valuePlaceholder', 'Value')}
/>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-destructive/70 hover:text-destructive hover:bg-destructive/10 shrink-0 rounded-xl transition-colors"
onClick={() => handleRemoveEnv(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<h3 className="text-meta font-bold text-foreground/80">
{t('detail.sections.title', { defaultValue: '内容' })}
</h3>
<SkillFileSections
baseDir={skill.baseDir}
onOpen={(file) => setOpenedSkillFile(skillFileToTarget(file))}
/>
</div>
)}
{/* External Links */}
{skill.slug && !skill.isBundled && !skill.isCore && (
<div className="flex gap-2 justify-center pt-8">
<Button variant="outline" size="sm" className="h-[28px] text-tiny font-medium px-3 gap-1.5 rounded-full border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/70" onClick={handleOpenClawhub}>
<Globe className="h-[12px] w-[12px]" />
ClawHub
</Button>
<Button variant="outline" size="sm" className="h-[28px] text-tiny font-medium px-3 gap-1.5 rounded-full border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/70" onClick={handleOpenEditor}>
<FileCode className="h-[12px] w-[12px]" />
{t('detail.openManual')}
</Button>
</div>
)}
</div>
{/* Centered Footer Buttons */}
<div className="pt-8 pb-4 flex items-center justify-center gap-4 w-full px-2 max-w-[340px] mx-auto">
{!skill.isCore && (
<Button
onClick={handleSaveConfig}
className={cn(
"flex-1 h-[42px] text-meta rounded-full font-semibold shadow-sm border border-transparent transition-all",
"bg-brand hover:bg-brand-hover text-white"
)}
disabled={isSaving}
>
{isSaving ? t('detail.saving') : t('detail.saveConfig')}
</Button>
)}
{!skill.isCore && (
{/* Centered Footer Button — uninstall / disable / enable */}
{!skill.isCore && (
<div className="pt-8 pb-4 flex items-center justify-center w-full px-2 max-w-[340px] mx-auto">
<Button
variant="outline"
className="flex-1 h-[42px] text-meta rounded-full font-semibold shadow-sm bg-transparent border-black/20 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-foreground/80 hover:text-foreground"
className="w-full h-[42px] text-meta rounded-full font-semibold shadow-sm bg-transparent border-black/20 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-foreground/80 hover:text-foreground"
onClick={() => {
if (!skill.isBundled && onUninstall && skill.slug) {
onUninstall(skill.slug);
@@ -391,8 +215,8 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp
? t('detail.uninstall')
: (skill.enabled ? t('detail.disable') : t('detail.enable'))}
</Button>
)}
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>

View File

@@ -0,0 +1,91 @@
/**
* Artifact panel state.
*
* Drives the right-side split panel on the Chat page: which tab is
* active (变更 / 预览 / 工作空间), the focused file shared across the
* 变更 and 预览 tabs, and the open/close state.
*
* The actual content (file lists, workspace tree, etc.) is provided by
* the chat page as props — we only track UI state here so the panel can
* be opened/closed/focused from anywhere (file cards, toolbar buttons,
* "查看文件变更 →" links, …).
*
* `widthPct` is persisted via `zustand/middleware`'s `persist` so the
* user's preferred split survives reloads.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { FilePreviewTarget } from '@/components/file-preview/types';
export type ArtifactTab = 'changes' | 'preview' | 'browser';
/** Width clamp (% of the chat container). */
export const ARTIFACT_PANEL_MIN_WIDTH = 28;
export const ARTIFACT_PANEL_MAX_WIDTH = 70;
export const ARTIFACT_PANEL_DEFAULT_WIDTH = 45;
interface ArtifactPanelState {
open: boolean;
tab: ArtifactTab;
/**
* The currently selected file inside the panel. Shared between the
* 变更 tab (drives the right-pane diff) and the 预览 tab (drives the
* rendered preview). `null` means "no selection" — the changes tab
* may auto-select the first file in that case.
*/
focusedFile: FilePreviewTarget | null;
/** Persisted panel width as a % of the chat container (clamped on read). */
widthPct: number;
setTab: (tab: ArtifactTab) => void;
setFocusedFile: (file: FilePreviewTarget | null) => void;
/** Open the changes tab. Optionally focus a single file. */
openChanges: (file?: FilePreviewTarget | null) => void;
/** Open the preview tab on a specific file. */
openPreview: (file?: FilePreviewTarget | null) => void;
/** Open the workspace browser tab. */
openBrowser: () => void;
toggle: () => void;
close: () => void;
/** Update the panel width (clamped). */
setWidthPct: (pct: number) => void;
}
function clampWidth(pct: number): number {
if (!Number.isFinite(pct)) return ARTIFACT_PANEL_DEFAULT_WIDTH;
if (pct < ARTIFACT_PANEL_MIN_WIDTH) return ARTIFACT_PANEL_MIN_WIDTH;
if (pct > ARTIFACT_PANEL_MAX_WIDTH) return ARTIFACT_PANEL_MAX_WIDTH;
return pct;
}
export const useArtifactPanel = create<ArtifactPanelState>()(
persist(
(set, get) => ({
open: false,
tab: 'changes',
focusedFile: null,
widthPct: ARTIFACT_PANEL_DEFAULT_WIDTH,
setTab: (tab) => {
// The browser tab has its own internal selection (workspace tree)
// and should not inherit a chat-side focused file. The changes
// and preview tabs share `focusedFile`.
if (tab === 'browser') {
set({ tab, focusedFile: null });
} else {
set({ tab, focusedFile: get().focusedFile });
}
},
setFocusedFile: (focusedFile) => set({ focusedFile }),
openChanges: (file = null) => set({ open: true, tab: 'changes', focusedFile: file ?? null }),
openPreview: (file = null) => set({ open: true, tab: 'preview', focusedFile: file ?? null }),
openBrowser: () => set({ open: true, tab: 'browser', focusedFile: null }),
toggle: () => set((s) => ({ open: !s.open })),
close: () => set({ open: false, focusedFile: null }),
setWidthPct: (pct) => set({ widthPct: clampWidth(pct) }),
}),
{
name: 'clawx.artifact-panel',
// Only persist the user-controlled width — open/tab/focus reset on reload.
partialize: (state) => ({ widthPct: state.widthPct }),
},
),
);

View File

@@ -0,0 +1,98 @@
/**
* Per-run baseline content cache for file diff computation.
*
* When the AI issues a Write-family tool_use, we read the target file from
* disk *before* the runtime actually writes it, so we have the "before"
* content to render a proper before/after diff. Cache entries are scoped to a
* single run (represented by a stable run key derived from the triggering user
* message), not shared across the whole session — otherwise a later run that
* edits the same path would incorrectly diff against an older baseline.
*/
import { readTextFile, type FilePreviewError } from '@/lib/api-client';
import type { GeneratedFileBaseline } from '@/lib/generated-files';
const KEY_SEPARATOR = '\u0000';
const cache = new Map<string, GeneratedFileBaseline>();
const inflight = new Map<string, Promise<void>>();
function makeCompositeKey(runKey: string, filePath: string): string {
return `${runKey}${KEY_SEPARATOR}${filePath}`;
}
/**
* Build a stable per-run key from the session + user-turn ordinal.
*
* We intentionally avoid Gateway `runId` (not present after history reload)
* and timestamps (the optimistic local user message timestamp can differ from
* the persisted history timestamp). The nth real user turn inside a session is
* stable across streaming and post-final history reloads, which makes it a
* reliable join key for captured baselines.
*/
export function buildBaselineRunKey(sessionKey: string, userTurnOrdinal: number): string | null {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) return null;
if (!Number.isFinite(userTurnOrdinal) || userTurnOrdinal <= 0) return null;
return `${normalizedSessionKey}|turn:${userTurnOrdinal}`;
}
export function getBaseline(runKey: string, filePath: string): GeneratedFileBaseline | undefined {
return cache.get(makeCompositeKey(runKey, filePath));
}
export function hasBaseline(runKey: string, filePath: string): boolean {
return cache.has(makeCompositeKey(runKey, filePath));
}
function unavailable(reason: FilePreviewError | string | undefined): GeneratedFileBaseline {
return { status: 'unavailable', reason: String(reason ?? 'unknown') };
}
/**
* Capture the baseline for `filePath` inside `runKey` if we haven't already.
* Returns immediately — the IPC read runs in the background.
*/
export function captureBaseline(runKey: string, filePath: string): void {
if (!runKey || !filePath) return;
const key = makeCompositeKey(runKey, filePath);
if (cache.has(key) || inflight.has(key)) return;
const promise = (async () => {
try {
const result = await readTextFile(filePath);
if (result.ok && typeof result.content === 'string') {
cache.set(key, { status: 'ok', content: result.content });
} else if (result.error === 'notFound') {
cache.set(key, { status: 'missing' });
} else {
cache.set(key, unavailable(result.error));
}
} catch (error) {
cache.set(key, unavailable(error instanceof Error ? error.message : String(error)));
} finally {
inflight.delete(key);
}
})();
inflight.set(key, promise);
}
export function clearBaselinesForRun(runKey: string): void {
if (!runKey) return;
for (const key of cache.keys()) {
if (key.startsWith(`${runKey}${KEY_SEPARATOR}`)) {
cache.delete(key);
}
}
for (const key of inflight.keys()) {
if (key.startsWith(`${runKey}${KEY_SEPARATOR}`)) {
inflight.delete(key);
}
}
}
/** Clear all cached baselines (call on session switch). */
export function clearBaselines(): void {
cache.clear();
inflight.clear();
}

View File

@@ -7,6 +7,7 @@ import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway';
import { useAgentsStore } from './agents';
import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache';
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
import {
CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS,
@@ -620,6 +621,9 @@ function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] {
}
}
}
// Tag all files from tool results so ChatMessage can suppress them
// in segments that already have an ExecutionGraphCard.
for (const f of imageFiles) f.source = 'tool-result';
pending.push(...imageFiles);
// 2. [media attached: ...] patterns in tool result text output
@@ -628,12 +632,12 @@ function enrichWithToolResultFiles(messages: RawMessage[]): RawMessage[] {
const mediaRefs = extractMediaRefs(text);
const mediaRefPaths = new Set(mediaRefs.map(r => r.filePath));
for (const ref of mediaRefs) {
pending.push(makeAttachedFile(ref));
pending.push({ ...makeAttachedFile(ref), source: 'tool-result' });
}
// 3. Raw file paths in tool result text (documents, audio, video, etc.)
for (const ref of extractRawFilePaths(text)) {
if (!mediaRefPaths.has(ref.filePath)) {
pending.push(makeAttachedFile(ref));
pending.push({ ...makeAttachedFile(ref), source: 'tool-result' });
}
}
}
@@ -710,9 +714,9 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] {
const files: AttachedFileMeta[] = allRefs.map(ref => {
const cached = _imageCache.get(ref.filePath);
if (cached) return { ...cached, filePath: ref.filePath };
if (cached) return { ...cached, filePath: ref.filePath, source: 'message-ref' as const };
const fileName = ref.filePath.split(/[\\/]/).pop() || 'file';
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath };
return { fileName, mimeType: ref.mimeType, fileSize: 0, preview: null, filePath: ref.filePath, source: 'message-ref' as const };
});
return { ...msg, _attachedFiles: files };
});
@@ -1023,6 +1027,71 @@ function isRuntimeSystemInjection(text: string): boolean {
return false;
}
// ── Write tool_use baseline capture ─────────────────────────────
//
// Tool name sets mirror generated-files.ts so we detect the same tools.
const BASELINE_WRITE_TOOLS = new Set([
'Write', 'write_file', 'create_file', 'WriteFile', 'createFile', 'write',
]);
const BASELINE_EDIT_TOOLS = new Set([
'Edit', 'edit', 'edit_file', 'EditFile',
'StrReplace', 'str_replace', 'str_replace_editor',
'MultiEdit', 'multi_edit', 'multiEdit',
]);
const BASELINE_FILE_PATH_KEYS = ['file_path', 'filepath', 'path', 'fileName', 'file_name', 'target_path'];
function pickFilePathFromInput(input: unknown): string | null {
if (!input || typeof input !== 'object') return null;
const rec = input as Record<string, unknown>;
for (const key of BASELINE_FILE_PATH_KEYS) {
const value = rec[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
}
/**
* Scan a streaming message for Write/Edit tool_use blocks and trigger
* async baseline reads from disk for each target file. Called on every
* `delta` event; `captureBaseline` is idempotent — duplicate calls for
* the same path are no-ops.
*/
function isBaselineRealUserMessage(message: RawMessage | undefined): boolean {
if (!message || message.role !== 'user') return false;
const content = message.content;
if (!Array.isArray(content)) return true;
const blocks = content as Array<{ type?: string }>;
return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult');
}
function countBaselineRealUserMessages(messages: RawMessage[]): number {
let count = 0;
for (const message of messages) {
if (isBaselineRealUserMessage(message)) count += 1;
}
return count;
}
function getBaselineRunKeyForMessages(sessionKey: string, messages: RawMessage[]): string | null {
const userTurnOrdinal = countBaselineRealUserMessages(messages);
return buildBaselineRunKey(sessionKey, userTurnOrdinal);
}
function captureBaselinesFromMessage(message: unknown, runKey: string | null): void {
if (!runKey || !message || typeof message !== 'object') return;
const content = (message as Record<string, unknown>).content;
if (!Array.isArray(content)) return;
for (const block of content as ContentBlock[]) {
if (block.type !== 'tool_use' && block.type !== 'toolCall') continue;
const name = typeof block.name === 'string' ? block.name : '';
if (!name) continue;
if (!BASELINE_WRITE_TOOLS.has(name) && !BASELINE_EDIT_TOOLS.has(name)) continue;
const input = block.input ?? block.arguments;
const filePath = pickFilePathFromInput(input);
if (filePath) captureBaseline(runKey, filePath);
}
}
function extractTextFromContent(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
@@ -1412,6 +1481,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// This prevents the poll timer from firing after the switch and loading
// the wrong session's history into the new session's view.
clearHistoryPoll();
clearBaselines();
set((s) => buildSessionSwitchPatch(s, key));
get().loadHistory();
},
@@ -1654,13 +1724,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!userMsTs || !msg.timestamp) return true;
return toMs(msg.timestamp) >= userMsTs;
};
const latestTerminalAssistantError = [...filteredMessages].reverse().find((msg) => (
msg.role === 'assistant'
&& getMessageStopReason(msg) === 'error'
&& isAfterUserMsg(msg)
));
const latestTerminalAssistantErrorMessage = latestTerminalAssistantError
? getMessageErrorMessage(latestTerminalAssistantError)
const isRealUserBoundary = (msg: RawMessage): boolean => {
if (msg.role !== 'user') return false;
if (!Array.isArray(msg.content)) return true;
const blocks = msg.content as Array<{ type?: string }>;
return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult');
};
const postBoundaryMessages = userMsTs
? filteredMessages.filter((msg) => isAfterUserMsg(msg))
: (() => {
for (let i = filteredMessages.length - 1; i >= 0; i -= 1) {
if (isRealUserBoundary(filteredMessages[i])) {
return filteredMessages.slice(i + 1);
}
}
return filteredMessages;
})();
const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant');
const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary
&& getMessageStopReason(lastAssistantAfterBoundary) === 'error'
? getMessageErrorMessage(lastAssistantAfterBoundary)
: null;
set({
@@ -2124,6 +2207,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ error: null, runError: null });
}
const updates = collectToolUpdates(event.message, resolvedState);
// Capture baseline file content from disk before the runtime
// executes Write tool calls — enables proper before/after diff.
captureBaselinesFromMessage(
event.message,
getBaselineRunKeyForMessages(currentSessionKey, get().messages),
);
set((s) => ({
streamingMessage: (() => {
if (event.message && typeof event.message === 'object') {

View File

@@ -119,13 +119,26 @@ export function createHistoryActions(
if (!userMsTs || !msg.timestamp) return true;
return toMs(msg.timestamp) >= userMsTs;
};
const latestTerminalAssistantError = [...filteredMessages].reverse().find((msg) => (
msg.role === 'assistant'
&& getMessageStopReason(msg) === 'error'
&& isAfterUserMsg(msg)
));
const latestTerminalAssistantErrorMessage = latestTerminalAssistantError
? getMessageErrorMessage(latestTerminalAssistantError)
const isRealUserBoundary = (msg: RawMessage): boolean => {
if (msg.role !== 'user') return false;
if (!Array.isArray(msg.content)) return true;
const blocks = msg.content as Array<{ type?: string }>;
return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult');
};
const postBoundaryMessages = userMsTs
? filteredMessages.filter((msg) => isAfterUserMsg(msg))
: (() => {
for (let i = filteredMessages.length - 1; i >= 0; i -= 1) {
if (isRealUserBoundary(filteredMessages[i])) {
return filteredMessages.slice(i + 1);
}
}
return filteredMessages;
})();
const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant');
const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary
&& getMessageStopReason(lastAssistantAfterBoundary) === 'error'
? getMessageErrorMessage(lastAssistantAfterBoundary)
: null;
set({