resources

This commit is contained in:
2026-02-24 16:44:22 +00:00
parent d6ffe43a11
commit 05f0d0e8f7
39 changed files with 1385 additions and 1057 deletions
@@ -6,7 +6,7 @@ type TaskRunnerDialogProps = {
};
export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps) => {
const { runningTask, setRunningTask, refresh, homeRoot, currentPath } = fileBrowserManager;
const { runningTask, setRunningTask, refresh, homeRoot, currentPath, getEntryAbsPath } = fileBrowserManager;
if (!runningTask) return null;
@@ -21,6 +21,7 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
}}
task={runningTask.task}
entryName={runningTask.entry.name}
entryFullPath={getEntryAbsPath(runningTask.entry.name)}
entryType={runningTask.entry.type}
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
/>
@@ -1,11 +1,12 @@
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { X, Play, Square, CircleCheck } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import type { TaskInfo } from '../../../Chat';
import { usePiChat, EmbeddableChat } from '../../../Chat';
import type { TaskInfo, ChatMessage } from '../../../Chat';
import { usePiChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
import { useSettings } from 'state/useSettings';
import { useVisiblePiModels } from 'state/useModels';
import type { TaskSummary } from '../../useTasks';
const playDing = () => {
@@ -32,6 +33,8 @@ const playDing = () => {
setTimeout(() => ctx.close(), 1500);
};
type Phase = 'ready' | 'running' | 'done';
type PiMonoInnerProps = {
defaultInput: string;
cwd: { root?: string; path: string };
@@ -39,27 +42,139 @@ type PiMonoInnerProps = {
taskInfo: TaskInfo;
};
const PiMonoInner = ({
defaultInput,
cwd,
initialModel,
taskInfo,
}: PiMonoInnerProps) => {
const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo }: PiMonoInnerProps) => {
const [phase, setPhase] = useState<Phase>('ready');
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
const availableModels = useVisiblePiModels();
// --- Independent message accumulator (never loses messages) ---
const accRef = useRef<ChatMessage[]>([]);
const seenToolIdsRef = useRef(new Set<string>());
const seenResultRef = useRef(false);
const [, bump] = useState(0);
// Track tool/result messages from chat.messages (idempotent during render)
for (const m of chat.messages) {
if (m.role === 'tool' && 'toolCallId' in m) {
if (!seenToolIdsRef.current.has(m.toolCallId)) {
seenToolIdsRef.current.add(m.toolCallId);
accRef.current.push(m);
} else {
// Update existing tool message (e.g. output arrived)
const idx = accRef.current.findIndex(
(a) => a.role === 'tool' && 'toolCallId' in a && a.toolCallId === m.toolCallId,
);
if (idx >= 0) accRef.current[idx] = m;
}
}
if (m.role === 'result' && !seenResultRef.current) {
seenResultRef.current = true;
accRef.current.push(m);
}
}
// Capture assistant text when streaming is committed (streamingText goes non-empty → empty)
const lastStreamRef = useRef('');
useEffect(() => {
if (lastStreamRef.current && !chat.streamingText) {
const text = lastStreamRef.current;
const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text);
if (!isDuplicate) {
accRef.current.push({ role: 'assistant', text });
bump((n) => n + 1);
}
lastStreamRef.current = '';
}
if (chat.streamingText) {
lastStreamRef.current = chat.streamingText;
}
}, [chat.streamingText]);
// Auto-scroll
const bottomRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chat.messages, chat.streamingText]);
// Ding on completion + transition to done
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !chat.isGenerating) playDing();
if (wasGenerating.current && !chat.isGenerating) {
playDing();
setPhase('done');
}
wasGenerating.current = chat.isGenerating;
}, [chat.isGenerating]);
const handleRun = () => {
setPhase('running');
chat.sendPrompt(defaultInput, undefined, undefined, cwd);
};
if (phase === 'ready') {
return (
<>
<div className="flex-1 flex items-center justify-center">
<button
onClick={handleRun}
disabled={!chat.isConnected}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Play className="h-4 w-4" />
Run
</button>
</div>
<div className="shrink-0 px-4 py-3 border-t border-duck-dark/10">
<ModelSelector
messages={[]}
availableModels={availableModels}
selectedModel={chat.selectedModel}
onModelChange={chat.setSelectedModel}
model={chat.model}
isConnected={chat.isConnected}
isGenerating={false}
hasStarted={false}
/>
</div>
</>
);
}
// Show StreamingBubble with fallback: use lastStreamRef while the effect hasn't captured yet
const showStream = chat.streamingText || lastStreamRef.current;
return (
<EmbeddableChat
chat={chat}
defaultInput={defaultInput}
cwd={cwd}
className="flex-1 min-h-0"
/>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto">
{accRef.current.map((msg, i) => (
<div key={i} className="px-4 py-1.5">
<MessageBubble message={msg} onAnswer={() => {}} />
</div>
))}
{showStream && (
<div className="px-4 py-1.5">
<StreamingBubble text={showStream} />
</div>
)}
<div ref={bottomRef} />
</div>
<div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
{phase === 'running' ? (
<button
onClick={chat.stopGeneration}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
>
<Square className="h-3.5 w-3.5" />
Stop
</button>
) : (
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
<CircleCheck className="h-4 w-4" />
Task complete
</span>
)}
</div>
</div>
);
};
@@ -68,18 +183,20 @@ type TaskRunnerModalProps = {
onOpenChange: (open: boolean) => void;
task: TaskSummary;
entryName?: string;
entryFullPath?: string;
entryType?: 'file' | 'directory';
cwd?: { root?: string; path: string };
promptOverride?: string;
};
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
const { settings } = useSettings();
const taskSettings = settings.tasks;
const entryRef = entryFullPath ?? entryName;
const defaultInput = promptOverride
?? (entryName && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
: `Read the task instructions at ${task.filePath} and execute them`);
?? (entryRef && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryRef}\n\nBe verbose — explain each step you take and what the result was.`
: `Read the task instructions at ${task.filePath} and execute them\n\nBe verbose — explain each step you take and what the result was.`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
return (
@@ -102,7 +219,7 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
</DialogPrimitive.Close>
</div>
{/* Chat */}
{/* Task Runner */}
<PiMonoInner
key="pi"
defaultInput={defaultInput}
@@ -712,6 +712,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
handleDragOver,
handleDrop,
handleBackgroundClick,
getEntryAbsPath: (name: string) => absPath(entryPath(name)),
};
};
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { TextRenderer } from './TextRenderer';
import { highlight } from './highlight';
type CodeRendererProps = {
content: string;
@@ -11,14 +12,9 @@ export const CodeRenderer = ({ content, lang }: CodeRendererProps) => {
useEffect(() => {
let cancelled = false;
import('shiki')
.then(({ codeToHtml }) => codeToHtml(content, { lang, theme: 'github-dark-default' }))
.then((result) => {
if (!cancelled) setHtml(result);
})
.catch(() => {
if (!cancelled) setHtml(null);
});
highlight(content, lang).then((result) => {
if (!cancelled) setHtml(result);
});
return () => {
cancelled = true;
};
@@ -3,12 +3,27 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSlug from 'rehype-slug';
import { highlight } from './highlight';
type HastNode = {
type: string;
value?: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
};
type MarkdownRendererProps = {
content: string;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
function getNodeText(node: HastNode): string {
if (node.type === 'text') return node.value ?? '';
if (node.children) return node.children.map(getNodeText).join('');
return '';
}
export const MarkdownRenderer = ({ content, scrollContainer }: MarkdownRendererProps) => {
const handleAnchorClick = (ev: React.MouseEvent<HTMLElement>) => {
const target = (ev.target as HTMLElement).closest('a');
@@ -35,23 +50,22 @@ export const MarkdownRenderer = ({ content, scrollContainer }: MarkdownRendererP
</a>
);
},
pre({ children }) {
return <div className="relative">{children}</div>;
},
code({ className, children, ...props }) {
const isBlock = className?.startsWith('language-');
const lang = className?.replace('language-', '') ?? '';
const text = String(children).replace(/\n$/, '');
if (!isBlock) {
return (
<code className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono" {...props}>
{children}
</code>
);
pre({ node, children }) {
const codeChild = node?.children[0];
if (codeChild?.type === 'element' && codeChild.tagName === 'code') {
const classes = (codeChild.properties?.className ?? []) as string[];
const lang = classes[0]?.replace('language-', '') ?? '';
const text = getNodeText(codeChild).replace(/\n$/, '');
return <HighlightedCodeBlock code={text} lang={lang} />;
}
return <HighlightedCodeBlock code={text} lang={lang} />;
return <pre>{children}</pre>;
},
code({ children, ...props }) {
return (
<code className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono" {...props}>
{children}
</code>
);
},
}}
>
@@ -84,39 +98,32 @@ const HighlightedCodeBlock = ({ code, lang }: { code: string; lang: string }) =>
useEffect(() => {
let cancelled = false;
import('shiki')
.then(({ codeToHtml }) => codeToHtml(code, { lang, theme: 'github-dark-default' }))
.then((result) => {
if (!cancelled) setHtml(result);
})
.catch(() => {});
highlight(code, lang).then((result) => {
if (!cancelled) setHtml(result);
});
return () => {
cancelled = true;
};
}, [code, lang]);
if (html) {
return (
<div className="relative my-4">
<CopyButton text={code} />
{lang && (
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">{lang}</span>
)}
return (
<div className="relative my-4">
<CopyButton text={code} />
{lang && (
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">
{lang}
</span>
)}
{html ? (
<div
className="[&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:pt-8 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono [&_pre]:leading-relaxed [&_pre]:border [&_pre]:border-white/5 [&_code]:font-mono"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
return (
<pre className="relative rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 overflow-x-auto text-sm font-mono leading-relaxed my-4 border border-white/5">
<CopyButton text={code} />
{lang && (
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider">{lang}</span>
) : (
<pre className="rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 pt-8 overflow-x-auto text-sm font-mono leading-relaxed border border-white/5">
<code>{code}</code>
</pre>
)}
<code className="block pt-4">{code}</code>
</pre>
</div>
);
};
@@ -0,0 +1,25 @@
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki';
import { createHighlighter } from 'shiki';
const THEME = 'github-dark-default' as const;
let instance: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
function getHighlighter() {
if (!instance) {
instance = createHighlighter({ themes: [THEME], langs: [] });
}
return instance;
}
export async function highlight(code: string, lang: string): Promise<string | null> {
try {
const highlighter = await getHighlighter();
if (lang && !highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage);
}
return highlighter.codeToHtml(code, { lang: lang || 'text', theme: THEME });
} catch {
return null;
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings';
export { useResources, getResourceCategory } from './useResources';
export type { Resource, ResourceCredentials, ResourceConnectionConfig, PingResult, ResourceCategory } from './useResources';
export { useResources } from './useResources';
export type { ResourceSummary, ResourceDetail, PingResult } from './useResources';
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
+31 -38
View File
@@ -1,32 +1,21 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type ResourceCredentials = {
apiKey?: string;
username?: string;
password?: string;
};
export type ResourceConnectionConfig = {
url: string;
credentials?: ResourceCredentials;
};
export type Resource = {
id: string;
export type ResourceSummary = {
dirName: string;
name: string;
subtitle: string;
type: string;
port: string | null;
description: string;
installCommand: string | null;
uninstallCommand: string | null;
manageCommand: string | null;
verifyCommand: string | null;
updateCommand: string | null;
installed: boolean;
version: string | null;
connectionConfig: ResourceConnectionConfig | null;
scope: 'native' | 'global';
config: Record<string, string>;
};
export type ResourceDetail = ResourceSummary & {
body: string;
rawFrontmatter: string;
filePath: string;
configPath: string;
chatSessionId: string | null;
guidePath: string;
};
export type PingResult = {
@@ -34,10 +23,6 @@ export type PingResult = {
latencyMs: number | null;
};
export type ResourceCategory = 'api-based' | 'local-cli';
export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
const RESOURCES_KEY = ['RESOURCES'];
export const useResources = () => {
@@ -46,23 +31,31 @@ export const useResources = () => {
const { data: resources, isLoading } = useQuery({
queryKey: RESOURCES_KEY,
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
queryFn: () => client.get<ResourceSummary[]>('/server-settings/resources'),
});
const saveConnectionConfig = async (id: string, config: Partial<ResourceConnectionConfig>) => {
await client.patch(`/server-settings/resources/config/${id}`, config);
const getDetail = (name: string) =>
client.get<ResourceDetail>(`/server-settings/resources/${name}`);
const saveConfig = async (name: string, config: Record<string, string | null>) => {
await client.patch(`/server-settings/resources/${name}/config`, config);
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
};
const pingResource = async (id: string, url?: string) => {
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
};
const runCommand = async (id: string, action: string) => {
const result = await client.post<{ exitCode: number; output: string }>(`/server-settings/resources/${id}/run`, { action });
const createResource = async (name: string) => {
const result = await client.post<{ name: string; dirName: string }>('/server-settings/resources', { name });
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
return result;
};
return { resources, isLoading, saveConnectionConfig, pingResource, runCommand };
const deleteResource = async (name: string) => {
await client.delete(`/server-settings/resources/${name}`);
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
};
const pingResource = async (name: string, url?: string) => {
return client.post<PingResult>(`/server-settings/resources/${name}/ping`, { url });
};
return { resources, isLoading, getDetail, saveConfig, createResource, deleteResource, pingResource };
};