feat: overhaul chat artifact previews and file change experience (#937)
This commit is contained in:
228
src/components/file-preview/ArtifactPanel.tsx
Normal file
228
src/components/file-preview/ArtifactPanel.tsx
Normal 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 run’s 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 run’s 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;
|
||||
547
src/components/file-preview/FilePreviewBody.tsx
Normal file
547
src/components/file-preview/FilePreviewBody.tsx
Normal 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;
|
||||
32
src/components/file-preview/FilePreviewOverlay.tsx
Normal file
32
src/components/file-preview/FilePreviewOverlay.tsx
Normal 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;
|
||||
75
src/components/file-preview/GeneratedFilesPanel.tsx
Normal file
75
src/components/file-preview/GeneratedFilesPanel.tsx
Normal 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;
|
||||
58
src/components/file-preview/ImageViewer.tsx
Normal file
58
src/components/file-preview/ImageViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/components/file-preview/MarkdownPreview.tsx
Normal file
121
src/components/file-preview/MarkdownPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/file-preview/MonacoDiffViewer.tsx
Normal file
86
src/components/file-preview/MonacoDiffViewer.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Monaco split diff editor (VS Code–style). 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>
|
||||
);
|
||||
}
|
||||
76
src/components/file-preview/MonacoViewer.tsx
Normal file
76
src/components/file-preview/MonacoViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/file-preview/PanelResizeDivider.tsx
Normal file
107
src/components/file-preview/PanelResizeDivider.tsx
Normal 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;
|
||||
166
src/components/file-preview/SkillFileSections.tsx
Normal file
166
src/components/file-preview/SkillFileSections.tsx
Normal 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;
|
||||
516
src/components/file-preview/WorkspaceBrowserBody.tsx
Normal file
516
src/components/file-preview/WorkspaceBrowserBody.tsx
Normal 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;
|
||||
20
src/components/file-preview/build-preview-target.ts
Normal file
20
src/components/file-preview/build-preview-target.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
39
src/components/file-preview/file-card-utils.tsx
Normal file
39
src/components/file-preview/file-card-utils.tsx
Normal 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} />;
|
||||
}
|
||||
12
src/components/file-preview/format.ts
Normal file
12
src/components/file-preview/format.ts
Normal 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`;
|
||||
}
|
||||
43
src/components/file-preview/types.ts
Normal file
43
src/components/file-preview/types.ts
Normal 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[];
|
||||
}
|
||||
7
src/components/file-preview/workspace-browser-config.ts
Normal file
7
src/components/file-preview/workspace-browser-config.ts
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "実行ビュー",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "Вид выполнения",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "运行视图",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
450
src/lib/generated-files.ts
Normal 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
95
src/lib/monaco/loader.ts
Normal 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
229
src/lib/skill-files.ts
Normal 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
124
src/lib/workspace-tree.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
91
src/stores/artifact-panel.ts
Normal file
91
src/stores/artifact-panel.ts
Normal 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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
98
src/stores/baseline-cache.ts
Normal file
98
src/stores/baseline-cache.ts
Normal 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();
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user