wip: remove opencode, searxng, resources; fix user settings read

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 07:27:19 +00:00
co-authored by Claude Opus 4.6
parent 5925ac49a1
commit 7bbcccabf1
46 changed files with 325 additions and 2037 deletions
@@ -2,15 +2,9 @@ import { useState } from 'react';
import { Terminal, Copy, Check } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Card } from '@/components/Card';
import { useClient } from 'hooks/useClient';
type Harnesses = {
claudeCode: boolean;
opencode: boolean;
};
type AIHarnessesCardProps = {
onNext: () => void;
onBack: () => void;
@@ -20,30 +14,14 @@ type AIHarnessesCardProps = {
export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCardProps) => {
const client = useClient();
const queryClient = useQueryClient();
const [harnesses, setHarnesses] = useState<Harnesses>({ claudeCode: false, opencode: false });
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean }>({
claudeCode: false,
opencode: false,
});
const [installing, setInstalling] = useState(false);
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
queryKey: ['CLAUDE_CODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
enabled: harnesses.claudeCode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
queryKey: ['OPENCODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
enabled: harnesses.opencode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
@@ -57,30 +35,13 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
});
const { data: opencodeAuth } = useQuery({
queryKey: ['OPENCODE_AUTH'],
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
enabled: !!opencodeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
});
const installClaude = async () => {
setInstalling((prev) => ({ ...prev, claudeCode: true }));
setInstalling(true);
try {
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, claudeCode: false }));
}
};
const installOpencode = async () => {
setInstalling((prev) => ({ ...prev, opencode: true }));
try {
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
queryClient.setQueryData(['OPENCODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, opencode: false }));
setInstalling(false);
}
};
@@ -112,15 +73,10 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
</div>
);
const claudeReady =
!harnesses.claudeCode || (!!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated);
const opencodeReady =
!harnesses.opencode ||
(!!opencodeVersion?.version && !!opencodeVersion?.globalPath && !!opencodeAuth?.authenticated);
const canProceed = (harnesses.claudeCode || harnesses.opencode) && claudeReady && opencodeReady;
const claudeReady = !!claudeVersion?.version && !!claudeVersion?.globalPath && !!claudeAuth?.authenticated;
const handleNext = async () => {
await saveSettings({ aiHarnesses: harnesses, onboardingComplete: true });
await saveSettings({ aiHarnesses: { claudeCode: true }, onboardingComplete: true });
onNext();
};
@@ -130,111 +86,51 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
<Terminal className="h-5 w-5 text-duck-forest" />
<h2 className="text-xl font-bold text-duck-dark">AI Harnesses</h2>
</div>
<p className="text-duck-dark/70 text-sm mb-6">Which AI coding tools do you use?</p>
<p className="text-duck-dark/70 text-sm mb-6">Set up Claude Code for AI-assisted development.</p>
<div className="flex flex-col gap-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={harnesses.opencode}
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, opencode: !!checked }))}
/>
<span className="text-sm font-medium text-duck-dark">Opencode</span>
</label>
{harnesses.opencode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{opencodeLoading ? (
'Checking version...'
) : opencodeVersion?.version ? (
<>
<div>{opencodeVersion.version}</div>
<div>{opencodeVersion.path}</div>
{opencodeAuth && (
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{opencodeAuth.authenticated ? (
`Logged in (${opencodeAuth.providers.join(', ')})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/opencode/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={harnesses.claudeCode}
onCheckedChange={(checked) => setHarnesses((prev) => ({ ...prev, claudeCode: !!checked }))}
/>
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
</label>
{harnesses.claudeCode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{claudeLoading ? (
'Checking version...'
) : claudeVersion?.version ? (
<>
<div>{claudeVersion.version}</div>
<div>{claudeVersion.path}</div>
{claudeAuth && (
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{claudeAuth.authenticated ? (
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/claude-code/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{claudeLoading ? (
'Checking version...'
) : claudeVersion?.version ? (
<>
<div>{claudeVersion.version}</div>
<div>{claudeVersion.path}</div>
{claudeAuth && (
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{claudeAuth.authenticated ? (
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/claude-code/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installClaude}
disabled={installing}
>
{installing ? 'Installing...' : 'Install'}
</Button>
)}
</div>
</div>
</div>
@@ -242,7 +138,7 @@ export const AIHarnessesCard = ({ onNext, onBack, saveSettings }: AIHarnessesCar
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button disabled={!canProceed} onClick={handleNext}>
<Button disabled={!claudeReady} onClick={handleNext}>
Complete Setup
</Button>
</div>
@@ -19,7 +19,6 @@ import { AIModels } from './ProfileSettings/AIModels';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
opencode: 'OpenCode Zen',
zai: 'ZAI',
google: 'Google',
groq: 'Groq',
@@ -1,162 +0,0 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
type AppStatus = {
id: string;
name: string;
description: string;
installed: boolean;
version: string | null;
running: boolean | null;
hasInstall: boolean;
hasUpdate: boolean;
manualInstallCommand: string | null;
manualUpdateCommand: string | null;
};
const CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 dark:bg-foreground/5 rounded px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70">{command}</code>
<button
type="button"
onClick={copy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />}
</button>
</div>
);
};
export const Applications = () => {
const client = useClient();
const queryClient = useQueryClient();
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
const { data: apps, isLoading } = useQuery({
queryKey: ['APPLICATIONS'],
queryFn: () => client.get<AppStatus[]>('/server-settings/applications'),
});
const runAction = async (id: string, action: 'install' | 'update') => {
setActionInProgress(id);
try {
await client.post<AppStatus>(`/server-settings/applications/${id}/${action}`);
await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] });
toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`);
} catch (err) {
const message = err instanceof Error ? err.message : `${action} failed`;
toast.error(message);
} finally {
setActionInProgress(null);
}
};
const getManualCommand = (app: AppStatus): string | null => {
if (!app.installed) return app.manualInstallCommand;
return app.manualUpdateCommand ?? app.manualInstallCommand;
};
const hasAutoAction = (app: AppStatus): boolean => {
if (!app.installed) return app.hasInstall && !app.manualInstallCommand;
return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand);
};
return (
<div className="h-full overflow-y-auto p-6">
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
{isLoading && <p className="text-sm text-duck-dark/50 dark:text-foreground/50">Checking applications...</p>}
{apps && (
<div className="flex flex-col gap-3">
{apps.map((app: AppStatus) => {
const manualCmd = getManualCommand(app);
const canAutoRun = hasAutoAction(app);
return (
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-duck-dark dark:text-foreground">{app.name}</span>
{app.installed && (
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
{app.version}
</span>
)}
{!app.installed && (
<span className="text-xs bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/40 dark:text-foreground/40 rounded-full px-2 py-0.5">
Not installed
</span>
)}
{app.running !== null && (
<Circle
className={`h-2.5 w-2.5 ${app.running ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
/>
)}
</div>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5">{app.description}</p>
</div>
<div className="shrink-0">
{canAutoRun && !app.installed && (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
disabled={actionInProgress === app.id}
onClick={() => runAction(app.id, 'install')}
>
{actionInProgress === app.id ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{actionInProgress === app.id ? 'Installing...' : 'Install'}
</Button>
)}
{canAutoRun && app.installed && (
<Button
size="sm"
variant="outline"
disabled={actionInProgress === app.id}
onClick={() => runAction(app.id, 'update')}
>
{actionInProgress === app.id ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{actionInProgress === app.id ? 'Updating...' : 'Update'}
</Button>
)}
</div>
</div>
{manualCmd && (
<div className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{app.installed ? 'Update' : 'Install'} manually:
<CopyCommand command={manualCmd} />
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -1,127 +0,0 @@
import { useState } from 'react';
import { Server, Plus, Check, X, Search } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { useResources, type ResourceSummary } from 'state/useResources';
export const ResourceSidebar = () => {
const { resources, isLoading, createResource } = useResources();
const [selectedName, setSelectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const query = search.toLowerCase();
const filtered = resources?.filter(
(r: ResourceSummary) =>
!query || r.name.toLowerCase().includes(query) || r.description?.toLowerCase().includes(query),
) ?? [];
const handleSelect = (dirName: string) => {
setSelectedName(dirName);
};
const handleCreate = async () => {
const name = newName.trim();
if (!name) return;
try {
const result = await createResource(name);
setCreating(false);
setNewName('');
setSelectedName(result.dirName);
} catch {
// error handled by client
}
};
return (
<div className="flex flex-col h-full">
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
<span className="text-sm font-medium text-duck-dark/70">Resources</span>
{!creating && (
<button
onClick={() => setCreating(true)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<Plus className="h-4 w-4 text-duck-dark/50" />
</button>
)}
</div>
{creating && (
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
<input
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleCreate();
}
if (ev.key === 'Escape') {
setCreating(false);
setNewName('');
}
}}
placeholder="Resource name..."
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
autoFocus
/>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
>
<Check className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
onClick={() => { setCreating(false); setNewName(''); }}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
</button>
</div>
)}
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search resources..."
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
/>
</div>
</div>
<div className="overflow-y-auto flex-1">
{isLoading && <p className="text-xs text-duck-dark/50 px-4 py-2">Loading...</p>}
{filtered.map((r: ResourceSummary) => (
<button
key={r.dirName}
onClick={() => handleSelect(r.dirName)}
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
selectedName === r.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
<div className="flex items-center gap-2">
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0" />
<span className="text-sm font-medium text-duck-dark truncate">{r.name}</span>
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
r.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
}`}
>
{r.scope}
</span>
</div>
{r.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{r.description}</p>}
</button>
))}
{!isLoading && resources && resources.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No resources found</p>
)}
{!isLoading && resources && resources.length > 0 && filtered.length === 0 && (
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
)}
</div>
</div>
);
};
@@ -1,349 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { toast } from 'sonner';
import { Pencil, Trash2, Plus, X, Loader2, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { usePiChat, EmbeddableChat } from 'officerdev';
import { useResources, type ResourceDetail, type PingResult } from 'state/useResources';
import { FrontmatterBlock } from '../../CapabilityPage';
const isSensitiveKey = (key: string) => /key|secret|password|token/i.test(key);
type ConfigEditorProps = {
resourceName: string;
config: Record<string, string>;
onSaved: () => void;
};
const ConfigEditor = ({ resourceName, config, onSaved }: ConfigEditorProps) => {
const { saveConfig, pingResource } = useResources();
const configEntries = Object.entries(config);
const [fields, setFields] = useState<[string, string][]>(configEntries);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [pinging, setPinging] = useState(false);
const [pingResult, setPingResult] = useState<PingResult | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
setFields(Object.entries(config));
}, [config]);
const hasUrl = fields.some(([k, v]) => k === 'url' && v);
const handleAddField = () => {
const key = newKey.trim();
if (!key || fields.some(([k]) => k === key)) return;
setFields([...fields, [key, newValue]]);
setNewKey('');
setNewValue('');
};
const handleRemoveField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
};
const handleFieldValue = (index: number, value: string) => {
setFields(fields.map((f, i) => (i === index ? [f[0]!, value] : f)));
};
const handlePing = async () => {
const url = fields.find(([k]) => k === 'url')?.[1];
if (!url) return;
setPinging(true);
setPingResult(null);
try {
const result = await pingResource(resourceName, url);
setPingResult(result);
} catch {
setPingResult({ reachable: false, latencyMs: null });
} finally {
setPinging(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const oldKeys = Object.keys(config);
const newKeys = new Set(fields.map(([k]) => k));
const patch: Record<string, string | null> = {};
for (const [key, value] of fields) {
patch[key] = value;
}
for (const key of oldKeys) {
if (!newKeys.has(key)) patch[key] = null;
}
await saveConfig(resourceName, patch);
onSaved();
toast.success('Configuration saved');
} catch {
toast.error('Failed to save configuration');
} finally {
setSaving(false);
}
};
return (
<div className="mb-6">
<h3 className="text-sm font-semibold text-duck-dark mb-3">Configuration</h3>
<div className="flex flex-col gap-2">
{fields.map(([key, value], index) => (
<div key={key} className="flex items-center gap-2">
<label className="text-xs text-duck-dark/50 w-28 shrink-0 truncate" title={key}>
{key}
</label>
<Input
value={value}
onChange={(ev) => handleFieldValue(index, ev.target.value)}
type={isSensitiveKey(key) ? 'password' : 'text'}
className="h-8 text-xs flex-1"
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-red-500 cursor-pointer"
onClick={() => handleRemoveField(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={newKey}
onChange={(ev) => setNewKey(ev.target.value)}
placeholder="key"
className="h-8 text-xs w-28 shrink-0"
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
/>
<Input
value={newValue}
onChange={(ev) => setNewValue(ev.target.value)}
placeholder="value"
className="h-8 text-xs flex-1"
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-duck-teal cursor-pointer"
onClick={handleAddField}
disabled={!newKey.trim()}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2 mt-1">
{hasUrl && (
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging} className="text-xs cursor-pointer">
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Test Connection
</Button>
)}
<Button size="sm" onClick={handleSave} disabled={saving} className="text-xs cursor-pointer">
{saving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
{pingResult && (
<span className={`text-xs ${pingResult.reachable ? 'text-green-600' : 'text-red-500'}`}>
{pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'}
</span>
)}
</div>
</div>
</div>
);
};
type ResourceChatProps = {
detail: ResourceDetail;
isNew?: boolean;
onResponseEnd: () => void;
};
const ResourceChat = ({ detail, isNew, onResponseEnd }: ResourceChatProps) => {
const promptFrontmatter = `<frontmatter>\nconfig file: ${detail.configPath}\nresource file: ${detail.filePath}\nguide: ${detail.guidePath}\n\nYou are helping configure a resource. Read the GUIDE.md for instructions on how to help. Read the RESOURCE.md for context about what this resource is. Write config values to the config.json file.\n</frontmatter>`;
const defaultInput = isNew
? 'Help me set up this new resource'
: 'Help me configure this resource';
const pi = usePiChat(undefined, undefined, { replaceUrl: false });
const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd;
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !pi.isGenerating) {
onResponseEndRef.current();
}
wasGenerating.current = pi.isGenerating;
}, [pi.isGenerating]);
return (
<EmbeddableChat
chat={pi}
defaultInput={defaultInput}
promptPrefix={promptFrontmatter}
className="h-full"
/>
);
};
export const Resources = () => {
const client = useClient();
const qc = useQueryClient();
const [selectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [editing, setEditing] = useState(false);
const [isNew, setIsNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const { data: detail, refetch } = useQuery<ResourceDetail>({
queryKey: ['RESOURCES', selectedName],
queryFn: () => client.get<ResourceDetail>(`/server-settings/resources/${selectedName}`),
enabled: !!selectedName,
});
useEffect(() => {
if (selectedName) {
setShowDetail(true);
setEditing(false);
}
}, [selectedName]);
const handleDelete = async () => {
if (!selectedName) return;
try {
await client.delete(`/server-settings/resources/${selectedName}`);
setDeleteConfirm(false);
setEditing(false);
await qc.invalidateQueries({ queryKey: ['RESOURCES'] });
} catch {
toast.error('Failed to delete resource');
}
};
const canDelete = detail && detail.scope === 'global' && !detail.filePath.includes('/seed/');
if (!selectedName || !detail) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/40">Select a resource to view its configuration</p>
</div>
);
}
return (
<>
<div className="flex flex-col h-full">
{/* Detail panel */}
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
<button
onClick={() => setShowDetail(false)}
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</button>
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail.name}</span>
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
detail.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
}`}
>
{detail.scope}
</span>
<button
onClick={() => setEditing((e) => !e)}
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
>
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
</button>
{canDelete && (
<button
onClick={() => setDeleteConfirm(true)}
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
</button>
)}
</div>
<div className="overflow-y-auto flex-1 p-6">
{detail.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
{detail.body && (
<article className="skill-md mb-6">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{detail.body}
</ReactMarkdown>
</article>
)}
<ConfigEditor
key={selectedName}
resourceName={selectedName}
config={detail.config}
onSaved={() => refetch()}
/>
</div>
</div>
{/* Chat panel */}
{editing && (
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
<button
onClick={() => setEditing(false)}
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
>
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</button>
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name} Chat</span>
<button
onClick={() => setEditing(false)}
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50" />
</button>
</div>
<ResourceChat
key={detail.filePath}
detail={detail}
isNew={isNew}
onResponseEnd={() => {
refetch();
qc.invalidateQueries({ queryKey: ['RESOURCES'] });
}}
/>
</div>
)}
</div>
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
<DialogContent className="sm:max-w-md z-[700]">
<DialogHeader>
<DialogTitle>Delete Resource</DialogTitle>
<DialogDescription>
Are you sure you want to delete &quot;{detail.name}&quot;? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,32 +0,0 @@
import { useMemo } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { Resources } from './Resources';
import { ResourceSidebar } from './ResourceSidebar';
const layout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 80 },
],
};
export const ResourceSettings = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'resources-left': ResourceSidebar,
'resources-right': Resources,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -44,7 +44,6 @@ const PI_PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode Zen', piId: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
@@ -1,7 +1,6 @@
export * from './ProfileSettings';
export * from './SystemSettings';
export * from './AISettings';
export * from './ResourceSettings';
export * from './UserSettings';
export * from './IntegrationsSettings';
export * from './AppsSettings';