[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
152
components/ai/AgentActivityGroup.tsx
Normal file
152
components/ai/AgentActivityGroup.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
FileDiff,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { AgentActivity, AgentUsage } from '../../domain/agentActivity';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
||||
|
||||
interface AgentActivityGroupProps {
|
||||
activities?: AgentActivity[];
|
||||
usage?: AgentUsage;
|
||||
isStreaming?: boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function statusLabel(status: 'running' | 'completed' | 'failed', t: AgentActivityGroupProps['t']): string {
|
||||
return t(`ai.chat.activity.status.${status}`);
|
||||
}
|
||||
|
||||
function formatTokens(value: number | undefined): string {
|
||||
return new Intl.NumberFormat().format(Math.max(0, value ?? 0));
|
||||
}
|
||||
|
||||
const AgentActivityGroup: React.FC<AgentActivityGroupProps> = ({
|
||||
activities = [],
|
||||
usage,
|
||||
isStreaming = false,
|
||||
t,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(isStreaming);
|
||||
}, [isStreaming]);
|
||||
|
||||
const visibleActivities = useMemo(
|
||||
() => activities.filter((activity) => activity.type !== 'web_search' || activity.query),
|
||||
[activities],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1.5 space-y-1.5 text-xs">
|
||||
{visibleActivities.length > 0 && (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors">
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<Activity size={13} />
|
||||
<span>{t('ai.chat.activity.title')}</span>
|
||||
<span className="ml-auto tabular-nums text-muted-foreground/70">{visibleActivities.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1 space-y-1 rounded-md border border-border/30 bg-muted/10 p-2">
|
||||
{visibleActivities.map((activity) => {
|
||||
if (activity.type === 'plan_update') {
|
||||
return (
|
||||
<div key={activity.id} className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 font-medium text-foreground/80">
|
||||
{activity.status === 'running'
|
||||
? <Loader2 size={12} className="animate-spin" />
|
||||
: <Check size={12} />}
|
||||
<span>{t('ai.chat.activity.plan')}</span>
|
||||
<span className="font-normal text-muted-foreground">
|
||||
· {statusLabel(activity.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 pl-0.5">
|
||||
{activity.items.map((item, index) => (
|
||||
<div key={`${activity.id}-${index}`} className="flex items-start gap-1.5 text-muted-foreground">
|
||||
{item.completed
|
||||
? <CheckCircle2 size={12} className="mt-0.5 shrink-0 text-emerald-500" />
|
||||
: <Circle size={12} className="mt-0.5 shrink-0" />}
|
||||
<span className={item.completed ? 'line-through opacity-70' : ''}>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.type === 'web_search') {
|
||||
return (
|
||||
<div key={activity.id} className="flex items-start gap-1.5 text-muted-foreground">
|
||||
{activity.status === 'running'
|
||||
? <Loader2 size={12} className="mt-0.5 shrink-0 animate-spin" />
|
||||
: <Search size={12} className="mt-0.5 shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-foreground/80">{t('ai.chat.activity.webSearch')}: </span>
|
||||
<span className="break-words">{activity.query}</span>
|
||||
<span className="ml-1 text-muted-foreground/70">
|
||||
· {statusLabel(activity.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.type === 'file_change') {
|
||||
return (
|
||||
<div key={activity.id} className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 font-medium text-foreground/80">
|
||||
<FileDiff size={12} />
|
||||
<span>{t('ai.chat.activity.fileChanges')}</span>
|
||||
<span className={activity.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
· {statusLabel(activity.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 pl-0.5">
|
||||
{activity.changes.map((change, index) => (
|
||||
<div key={`${activity.id}-${change.path}-${index}`} className="flex min-w-0 items-start gap-1.5">
|
||||
<span className="w-12 shrink-0 uppercase text-[10px] text-muted-foreground">
|
||||
{t(`ai.chat.activity.file.${change.kind}`)}
|
||||
</span>
|
||||
<code className="break-all text-[11px] text-foreground/75">{change.path}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="flex items-start gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
|
||||
<span className="break-words">{activity.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{usage && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 px-2 text-[10px] text-muted-foreground/70 tabular-nums">
|
||||
<span>{usage.estimated ? `${t('ai.chat.activity.usage')} ~` : `${t('ai.chat.activity.usage')} `}{formatTokens(usage.totalTokens)}</span>
|
||||
<span>{t('ai.chat.activity.usage.input')} {formatTokens(usage.inputTokens)}</span>
|
||||
<span>{t('ai.chat.activity.usage.output')} {formatTokens(usage.outputTokens)}</span>
|
||||
{!!usage.cachedInputTokens && <span>{t('ai.chat.activity.usage.cached')} {formatTokens(usage.cachedInputTokens)}</span>}
|
||||
{!!usage.reasoningTokens && <span>{t('ai.chat.activity.usage.reasoning')} {formatTokens(usage.reasoningTokens)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentActivityGroup;
|
||||
78
components/ai/AgentIconBadge.tsx
Normal file
78
components/ai/AgentIconBadge.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
AGENT_ICON_VISUALS,
|
||||
resolveAgentIconKey,
|
||||
type AgentIconKey,
|
||||
type AgentIconSource,
|
||||
} from '../../domain/agentIcon';
|
||||
|
||||
export type { AgentIconKey, AgentIconSource };
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentIconSource | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const iconKey = resolveAgentIconKey(agent);
|
||||
const visual = AGENT_ICON_VISUALS[iconKey];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
: size === 'sm'
|
||||
? 'h-7 w-7 rounded-lg'
|
||||
: size === 'lg'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-8 w-8 rounded-lg';
|
||||
const imageSize =
|
||||
size === 'xs'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'sm'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'lg'
|
||||
? 'h-5 w-5'
|
||||
: 'h-4 w-4';
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0', imageSize, className)}
|
||||
style={{
|
||||
maskImage: `url(${visual.src})`,
|
||||
WebkitMaskImage: `url(${visual.src})`,
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
backgroundColor: 'currentColor',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-agent-badge=""
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
visual.badgeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(imageSize, visual.imageClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentIconBadge;
|
||||
309
components/ai/AgentSelector.tsx
Normal file
309
components/ai/AgentSelector.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AgentSelector - Dropdown for switching between AI agents
|
||||
*
|
||||
* Dark, grouped agent menu with local SVG branding for built-in,
|
||||
* discovered, and external agents.
|
||||
*/
|
||||
|
||||
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
getExternalAgentSdkBackend,
|
||||
isSettingsManagedDiscoveredAgent,
|
||||
matchesManagedAgentConfig,
|
||||
} from '../../infrastructure/ai/managedAgents';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
currentAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
discoveredAgents?: DiscoveredAgent[];
|
||||
isDiscovering?: boolean;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
|
||||
onRediscover?: () => void;
|
||||
onManageAgents?: () => void;
|
||||
parked?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BUILTIN_AGENTS: AgentInfo[] = [
|
||||
{
|
||||
id: 'catty',
|
||||
name: 'Catty Agent',
|
||||
type: 'builtin',
|
||||
description: 'Built-in terminal assistant',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-1.5">
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
|
||||
{children}
|
||||
</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AgentMenuRow: React.FC<{
|
||||
agent: AgentInfo;
|
||||
isActive?: boolean;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
}> = ({ agent, isActive, subtitle, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
||||
isActive && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate">{agent.name}</span>
|
||||
{subtitle && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DiscoveredAgentRow: React.FC<{
|
||||
agent: DiscoveredAgent;
|
||||
onEnable: () => void;
|
||||
}> = ({ agent, onEnable }) => {
|
||||
const { t } = useI18n();
|
||||
const agentLike: AgentInfo = {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
available: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-9 w-full items-center gap-2.5 rounded px-3 text-xs">
|
||||
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-foreground/86">{agent.name}</span>
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">
|
||||
{agent.version || agent.path}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
currentAgentId,
|
||||
externalAgents,
|
||||
discoveredAgents = [],
|
||||
isDiscovering = false,
|
||||
onSelectAgent,
|
||||
onEnableDiscoveredAgent,
|
||||
onRediscover,
|
||||
onManageAgents,
|
||||
parked = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (parked || disabled) setOpen(false);
|
||||
}, [disabled, parked]);
|
||||
|
||||
const enabledExternalAgents = useMemo(
|
||||
() =>
|
||||
externalAgents
|
||||
.filter((agent) => agent.enabled && Boolean(getExternalAgentSdkBackend(agent)))
|
||||
.map(
|
||||
(agent): AgentInfo => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
args: agent.args,
|
||||
available: true,
|
||||
}),
|
||||
),
|
||||
[externalAgents],
|
||||
);
|
||||
|
||||
// Discovered agents not yet added to external agents
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => {
|
||||
if (isSettingsManagedDiscoveredAgent(da)) {
|
||||
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
|
||||
}
|
||||
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
|
||||
},
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
|
||||
const allAgents = useMemo(
|
||||
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
|
||||
[enabledExternalAgents],
|
||||
);
|
||||
|
||||
const currentAgent = useMemo(
|
||||
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
|
||||
[allAgents, currentAgentId],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(agentId: string) => {
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onSelectAgent],
|
||||
);
|
||||
|
||||
const handleEnableDiscovered = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
onEnableDiscoveredAgent?.(agent);
|
||||
// After enabling, auto-select it
|
||||
const agentId = `discovered_${agent.command}`;
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEnableDiscoveredAgent, onSelectAgent],
|
||||
);
|
||||
|
||||
const handleManageAgents = useCallback(() => {
|
||||
setOpen(false);
|
||||
onManageAgents?.();
|
||||
}, [onManageAgents]);
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="group flex h-6 min-w-0 max-w-[170px] items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<AgentIconBadge
|
||||
agent={currentAgent}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
className="h-3 w-3 opacity-78"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground/90">
|
||||
{currentAgent.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={10}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[256px] overflow-hidden rounded-md border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{enabledExternalAgents.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
|
||||
{enabledExternalAgents.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
subtitle={agent.command}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{unconfiguredDiscovered.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel
|
||||
action={
|
||||
onRediscover && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('ai.chat.detectedOnMachine')}
|
||||
</SectionLabel>
|
||||
{unconfiguredDiscovered.map((agent) => (
|
||||
<DiscoveredAgentRow
|
||||
key={agent.command}
|
||||
agent={agent}
|
||||
onEnable={() => handleEnableDiscovered(agent)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<button
|
||||
onClick={handleManageAgents}
|
||||
className="flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
||||
>
|
||||
<Settings size={14} className="opacity-72 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AgentSelector);
|
||||
352
components/ai/ChatInput.test.tsx
Normal file
352
components/ai/ChatInput.test.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import ChatInput from './ChatInput';
|
||||
import {
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
resolveChatInputAriaHeight,
|
||||
resolveChatInputMaxHeight,
|
||||
resolveChatInputResizeHeight,
|
||||
resolveVisibleChatInputHeight,
|
||||
resolveVisibleChatInputMaxHeight,
|
||||
} from './chatInputResize';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
|
||||
test('clamps composer dragging to the usable pane height', () => {
|
||||
assert.equal(resolveChatInputMaxHeight(900), CHAT_INPUT_MAX_HEIGHT);
|
||||
assert.equal(resolveChatInputMaxHeight(180), CHAT_INPUT_MIN_HEIGHT);
|
||||
assert.equal(resolveChatInputResizeHeight(128, 500, 420, 360), 208);
|
||||
assert.equal(resolveChatInputResizeHeight(128, 500, 700, 360), CHAT_INPUT_MIN_HEIGHT);
|
||||
assert.equal(resolveChatInputResizeHeight(300, 500, 300, 360), 360);
|
||||
});
|
||||
|
||||
test('keeps the requested composer height while the pane is temporarily hidden or constrained', () => {
|
||||
assert.equal(resolveVisibleChatInputMaxHeight(0), null);
|
||||
assert.equal(resolveVisibleChatInputMaxHeight(Number.NaN), null);
|
||||
assert.equal(resolveVisibleChatInputHeight(360, 220), 220);
|
||||
assert.equal(resolveVisibleChatInputHeight(360, 400), 360);
|
||||
assert.equal(resolveVisibleChatInputHeight(null, 400), null);
|
||||
});
|
||||
|
||||
test('reports an accessible composer height inside the available range', () => {
|
||||
assert.equal(resolveChatInputAriaHeight(null, CHAT_INPUT_MIN_HEIGHT), CHAT_INPUT_MIN_HEIGHT);
|
||||
assert.equal(resolveChatInputAriaHeight(360, 220), 220);
|
||||
assert.equal(resolveChatInputAriaHeight(160, 220), 160);
|
||||
});
|
||||
|
||||
test('renders an accessible composer resize handle', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput value="" onChange={() => {}} onSend={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /role="separator"/);
|
||||
assert.match(html, /aria-orientation="horizontal"/);
|
||||
assert.match(html, /aria-label="ai\.chat\.resizeInput"/);
|
||||
assert.match(html, /cursor-ns-resize/);
|
||||
});
|
||||
|
||||
test('expanded composer grows the text area while keeping controls at the bottom', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /data-section="ai-chat-input-body"/);
|
||||
assert.match(source, /composerHeight != null \? 'flex min-h-0 flex-1 flex-col'/);
|
||||
assert.match(source, /data-section="ai-chat-input-footer"/);
|
||||
assert.match(source, /className="shrink-0/);
|
||||
assert.doesNotMatch(source, /<Expand/);
|
||||
assert.doesNotMatch(source, /setExpanded/);
|
||||
});
|
||||
|
||||
test('parked composer closes body-portaled menus', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /parked = false/);
|
||||
assert.match(source, /if \(parked\) closeAllMenus\(\)/);
|
||||
assert.match(source, /becameParked/);
|
||||
});
|
||||
|
||||
test('first keystrokes stay local so IME and Chromium spellcheck cannot stall the composer', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /spellCheck=\{false\}/);
|
||||
assert.match(source, /autoCorrect="off"/);
|
||||
assert.match(source, /autoCapitalize="off"/);
|
||||
assert.match(source, /nativeEvent\.isComposing/);
|
||||
assert.match(source, /onCompositionEnd=/);
|
||||
assert.match(source, /onBlur=\{\(\) => commitComposerText\(readComposerText\(\)\)\}/);
|
||||
assert.match(source, /onChangeRef\.current\(textareaRef\.current\?\.value \?\? composerTextRef\.current\)/);
|
||||
assert.doesNotMatch(source, /onSend\(\);\s*commitComposerText\(''\);/);
|
||||
assert.match(source, /defaultValue=\{value\}/);
|
||||
assert.match(source, /field-sizing-fixed/);
|
||||
assert.doesNotMatch(source, /value=\{composerText\}/);
|
||||
assert.match(source, /createComposerHasTextStore/);
|
||||
assert.match(source, /ComposerSendUi/);
|
||||
assert.match(source, /chatInputPropsAreEqual/);
|
||||
});
|
||||
|
||||
test('composer resizing also ends when pointer capture is unexpectedly lost', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /resizeStartRef\.current = null;[\s\S]*releasePointerCapture/);
|
||||
assert.match(source, /onLostPointerCapture=\{handleComposerResizeEnd\}/);
|
||||
});
|
||||
|
||||
test('virtualizes the host mention list without changing its option contract', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
assert.match(source, /ref=\{atMentionListRef\}/);
|
||||
assert.match(source, /aria-activedescendant=\{hosts\[activeMenuIndex\] \? `at-mention-/);
|
||||
assert.match(source, /onMouseEnter=\{\(\) => setActiveMenuIndex\(idx\)\}/);
|
||||
assert.match(source, /onClick=\{\(\) => handleSelectAtMention\(host\)\}/);
|
||||
assert.match(source, /max-h-\[280px\]/);
|
||||
});
|
||||
|
||||
test('does not render a standalone slash command toolbar button', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
isStreaming={false}
|
||||
disabled={false}
|
||||
agentName="Catty Agent"
|
||||
quickMessages={[{
|
||||
id: 'qm-1',
|
||||
slug: 'hello',
|
||||
name: 'Hello',
|
||||
description: 'Greeting',
|
||||
content: 'Say hello',
|
||||
}]}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /textarea/);
|
||||
assert.doesNotMatch(html, /aria-label="ai\.chat\.slashCommands"/);
|
||||
});
|
||||
|
||||
test('renders separate steer and stop actions for a running Codex App Server turn', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value="change direction"
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
onSteer={() => {}}
|
||||
onStop={() => {}}
|
||||
isStreaming
|
||||
canSteer
|
||||
lockTurnConfiguration
|
||||
disabled={false}
|
||||
agentName="Codex"
|
||||
modelPresets={[{ id: 'gpt-test', name: 'GPT Test' }]}
|
||||
selectedModelId="gpt-test"
|
||||
onModelSelect={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
|
||||
assert.match(html, /placeholder="ai\.codex\.steer\.placeholder"/);
|
||||
assert.match(html, /aria-label="Stop"/);
|
||||
assert.match(html, /disabled=""[^>]*aria-label="ai\.chat\.selectModel"/);
|
||||
});
|
||||
|
||||
test('allows terminal-selection-only steering submissions', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
onSteer={() => {}}
|
||||
onStop={() => {}}
|
||||
isStreaming
|
||||
canSteer
|
||||
disabled={false}
|
||||
agentName="Codex"
|
||||
files={[{
|
||||
id: 'terminal-selection',
|
||||
filename: 'terminal-selection.txt',
|
||||
dataUrl: 'data:text/plain;base64,dGVzdA==',
|
||||
base64Data: 'dGVzdA==',
|
||||
mediaType: 'text/plain',
|
||||
terminalSelection: true,
|
||||
lineCount: 1,
|
||||
}]}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /<form[^>]*data-allow-empty-submit="true"/);
|
||||
assert.match(html, /aria-label="ai\.codex\.steer\.addInstruction"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/<button[^>]*disabled=""[^>]*aria-label="ai\.codex\.steer\.addInstruction"/,
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the Catty context usage ring after the model chip', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
contextUsage={{
|
||||
sessionId: 'session-1',
|
||||
inputTokens: 64_000,
|
||||
contextWindow: 128_000,
|
||||
estimated: true,
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /role="progressbar"/);
|
||||
assert.match(html, /stroke-dasharray=/);
|
||||
assert.match(html, /stroke-dashoffset=/);
|
||||
assert.match(html, /class="h-4 w-4"/);
|
||||
assert.doesNotMatch(html, /text-\[7px\]/);
|
||||
assert.match(html, /aria-valuenow="50"/);
|
||||
});
|
||||
|
||||
test('slash picker system commands clear the local composer', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
|
||||
});
|
||||
|
||||
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Codex"
|
||||
modelPresets={[{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
thinkingLevels: ['low', 'medium', 'high'],
|
||||
}]}
|
||||
selectedModelId="gpt-5.5/high"
|
||||
onModelSelect={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="ai\.chat\.selectModel"/);
|
||||
assert.match(html, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
assert.match(html, />High</);
|
||||
assert.doesNotMatch(html, /GPT-5\.5 \/ High/);
|
||||
});
|
||||
|
||||
test('Catty composer exposes a provider switcher without a mixed thinking submenu', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /ComposerModelPicker/);
|
||||
assert.match(source, /ComposerThinkingChip/);
|
||||
assert.match(source, /cattyReasoningLevelsForSelection/);
|
||||
assert.match(source, /if \(!thinkingLevel\) return;/);
|
||||
assert.doesNotMatch(source, /showThinkingLevels/);
|
||||
});
|
||||
|
||||
test('Catty thinking chip is hidden for OpenAI models that reject reasoningEffort', () => {
|
||||
const openai = {
|
||||
id: 'p1',
|
||||
providerId: 'openai' as const,
|
||||
name: 'OpenAI',
|
||||
enabled: true,
|
||||
};
|
||||
const gpt4o = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-4o',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.doesNotMatch(gpt4o, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
|
||||
const gpt51Chat = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-5.1-chat-latest',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.doesNotMatch(gpt51Chat, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
|
||||
const gpt55 = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<ChatInput
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSend={() => {}}
|
||||
agentName="Catty Agent"
|
||||
thinkingLevel="high"
|
||||
onThinkingLevelChange={() => {}}
|
||||
providerSwitcher={{
|
||||
providers: [openai],
|
||||
selectedProviderId: 'p1',
|
||||
selectedModelId: 'gpt-5.5',
|
||||
onSelect: () => {},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
assert.match(gpt55, /aria-label="ai\.chat\.thinkingLevel"/);
|
||||
});
|
||||
|
||||
test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /getSystemSlashCommand/);
|
||||
assert.match(source, /systemCommand === 'compact'/);
|
||||
assert.match(source, /canCompact/);
|
||||
assert.match(source, /onCompact\?\.\(\)/);
|
||||
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
|
||||
});
|
||||
|
||||
test('renders a Mention Note picker entry with a searchable note list', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
|
||||
assert.match(source, /ai\.chat\.menuMentionNote/);
|
||||
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
|
||||
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
|
||||
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
|
||||
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
|
||||
});
|
||||
|
||||
test('renders vault note attachment chips with the note title', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /file\.vaultNoteId \? \(/);
|
||||
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
|
||||
});
|
||||
1820
components/ai/ChatInput.tsx
Normal file
1820
components/ai/ChatInput.tsx
Normal file
File diff suppressed because it is too large
Load Diff
142
components/ai/ChatJumpNav.tsx
Normal file
142
components/ai/ChatJumpNav.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Floating jump list for long AI chat sessions (user-turn TOC).
|
||||
*/
|
||||
|
||||
import { ListTree, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatJumpEntry } from '../../domain/chatJumpNav';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface ChatJumpNavProps {
|
||||
entries: ChatJumpEntry[];
|
||||
activeMessageId: string | null;
|
||||
/** When true, ignore transient isAtBottom flips from streaming resize. */
|
||||
isStreaming?: boolean;
|
||||
onSelect: (messageId: string) => void;
|
||||
/** Fired after the user leaves the jump target and returns to the bottom. */
|
||||
onReleasePin?: () => void;
|
||||
}
|
||||
|
||||
const ChatJumpNav: React.FC<ChatJumpNavProps> = ({
|
||||
entries,
|
||||
activeMessageId,
|
||||
isStreaming = false,
|
||||
onSelect,
|
||||
onReleasePin,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { stopScroll, isAtBottom } = useStickToBottomContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
// Only release after the viewport has left the bottom; avoids clearing a pin
|
||||
// on the same tick as select while isAtBottom is still true.
|
||||
const leftBottomWhilePinnedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && rootRef.current && !rootRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeMessageId) {
|
||||
leftBottomWhilePinnedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!isAtBottom) {
|
||||
leftBottomWhilePinnedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (leftBottomWhilePinnedRef.current) {
|
||||
// Streaming content growth / smooth resize can flip isAtBottom without the
|
||||
// user intending to leave the jump target; keep the pin until streaming ends
|
||||
// or they explicitly scroll to bottom via the scroll button.
|
||||
if (isStreaming) return;
|
||||
leftBottomWhilePinnedRef.current = false;
|
||||
onReleasePin?.();
|
||||
return;
|
||||
}
|
||||
// Jump target was already in the bottom window, so the viewport never left
|
||||
// isAtBottom. Clear the pin after scrollIntoView has had a chance to run.
|
||||
if (isStreaming) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
onReleasePin?.();
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeMessageId, isAtBottom, isStreaming, onReleasePin]);
|
||||
|
||||
const handleSelect = useCallback((messageId: string) => {
|
||||
stopScroll();
|
||||
onSelect(messageId);
|
||||
setOpen(false);
|
||||
}, [onSelect, stopScroll]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="absolute top-3 right-3 z-20 flex flex-col items-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center shadow-sm',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
open && 'text-foreground bg-muted',
|
||||
)}
|
||||
aria-label={t('ai.chat.jumpNav')}
|
||||
aria-expanded={open}
|
||||
title={t('ai.chat.jumpNav')}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <X size={14} /> : <ListTree size={14} />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-[min(220px,calc(100vw-2rem))] max-h-[min(320px,50vh)] overflow-y-auto',
|
||||
'rounded-md border border-border/50 bg-background/95 backdrop-blur-sm shadow-md',
|
||||
'py-1',
|
||||
)}
|
||||
role="listbox"
|
||||
aria-label={t('ai.chat.jumpNav')}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const selected = entry.messageId === activeMessageId;
|
||||
return (
|
||||
<button
|
||||
key={entry.messageId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 px-2.5 py-1.5 text-left text-[12px] leading-snug',
|
||||
'hover:bg-muted/70 transition-colors cursor-pointer',
|
||||
selected
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-foreground/80',
|
||||
)}
|
||||
onClick={() => handleSelect(entry.messageId)}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/70 w-4 text-right">
|
||||
{entry.index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatJumpNav;
|
||||
587
components/ai/ChatMessageList.test.tsx
Normal file
587
components/ai/ChatMessageList.test.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import type { ApprovalRequest } from "../../infrastructure/ai/shared/approvalGate.ts";
|
||||
import ChatMessageList, {
|
||||
buildCodexApprovalRenderPlan,
|
||||
pruneResolvedApprovals,
|
||||
shouldProvideVaultArtifactNavigation,
|
||||
shouldRenderAssistantAsPlainText,
|
||||
} from "./ChatMessageList.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
|
||||
const makeMessage = (index: number): ChatMessage => ({
|
||||
id: `msg-${index}`,
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index}`,
|
||||
timestamp: index,
|
||||
});
|
||||
|
||||
test("resolved approval state retains only tool calls still present in messages", () => {
|
||||
const previous = new Map<string, boolean>();
|
||||
for (let index = 0; index < 1_000; index += 1) previous.set(`old-${index}`, true);
|
||||
previous.set("live-call", false);
|
||||
const messages: ChatMessage[] = [{
|
||||
id: "assistant-live",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolCalls: [{ id: "live-call", name: "terminal_execute", arguments: {} }],
|
||||
}];
|
||||
|
||||
const next = pruneResolvedApprovals(previous, messages);
|
||||
assert.deepEqual([...next], [["live-call", false]]);
|
||||
});
|
||||
|
||||
test("assistant content stays plain only when markdown is hidden", () => {
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: false,
|
||||
}), false);
|
||||
assert.equal(shouldRenderAssistantAsPlainText({
|
||||
hideMarkdown: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Streamdown for the streaming assistant message", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "hello",
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "streaming-body",
|
||||
timestamp: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages, isStreaming: true }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /streaming-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
});
|
||||
|
||||
test("streaming assistant content keeps Streamdown live with isAnimating", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /isAnimating=\{!!isThisStreaming\}/);
|
||||
assert.doesNotMatch(source, /isStreaming: !!isThisStreaming/);
|
||||
});
|
||||
|
||||
test("ChatMessageList hydrates markdown after streaming settles", () => {
|
||||
const messages: ChatMessage[] = [{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "settled-body",
|
||||
timestamp: 1,
|
||||
}];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages, isStreaming: false }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /data-ai-content="markdown"/);
|
||||
assert.match(markup, /settled-body/);
|
||||
assert.doesNotMatch(markup, /data-ai-content="plain"/);
|
||||
});
|
||||
|
||||
test("ChatMessageList only renders the recent message batch by default", () => {
|
||||
const messages = Array.from({ length: 60 }, (_value, index) => makeMessage(index));
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /Load earlier messages \(10 more\)/);
|
||||
assert.doesNotMatch(markup, /message-0/);
|
||||
assert.match(markup, /message-10/);
|
||||
assert.match(markup, /message-59/);
|
||||
});
|
||||
|
||||
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
|
||||
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
|
||||
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
|
||||
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
|
||||
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
|
||||
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Jump to message"/);
|
||||
assert.match(markup, /id="ai-chat-msg-u1"/);
|
||||
assert.match(markup, /id="ai-chat-msg-u3"/);
|
||||
});
|
||||
|
||||
test("jump pin release does not reset the loaded message tail", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
const releaseHandler = source.match(
|
||||
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
|
||||
)?.[0] ?? "";
|
||||
|
||||
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
|
||||
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
|
||||
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
|
||||
});
|
||||
|
||||
test("load earlier advances from the effective pinned tail", () => {
|
||||
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
|
||||
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
|
||||
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(jumpSource, /isStreaming\?: boolean/);
|
||||
assert.match(jumpSource, /if \(isStreaming\) return;/);
|
||||
assert.match(jumpSource, /window\.setTimeout/);
|
||||
assert.match(listSource, /isStreaming=\{!!isStreaming\}/);
|
||||
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Codex activities and actual usage", () => {
|
||||
const messages: ChatMessage[] = [{
|
||||
id: "assistant-activity",
|
||||
role: "assistant",
|
||||
content: "Done",
|
||||
timestamp: 1,
|
||||
agentActivities: [
|
||||
{
|
||||
id: "plan-1",
|
||||
type: "plan_update",
|
||||
status: "running",
|
||||
items: [{ text: "Map Codex events", completed: false }],
|
||||
},
|
||||
{
|
||||
id: "search-1",
|
||||
type: "web_search",
|
||||
status: "completed",
|
||||
query: "Codex SDK event types",
|
||||
},
|
||||
{
|
||||
id: "patch-1",
|
||||
type: "file_change",
|
||||
status: "completed",
|
||||
changes: [{ path: "src/app.ts", kind: "update" }],
|
||||
},
|
||||
{
|
||||
id: "warning-1",
|
||||
type: "warning",
|
||||
status: "completed",
|
||||
message: "A recoverable warning",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
cachedInputTokens: 40,
|
||||
outputTokens: 25,
|
||||
reasoningTokens: 10,
|
||||
totalTokens: 125,
|
||||
},
|
||||
}];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages, isStreaming: true }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /Agent activity/);
|
||||
assert.match(markup, /Map Codex events/);
|
||||
assert.match(markup, /Codex SDK event types/);
|
||||
assert.match(markup, /src\/app\.ts/);
|
||||
assert.match(markup, /A recoverable warning/);
|
||||
assert.match(markup, /Tokens 125/);
|
||||
assert.match(markup, /cached 40/);
|
||||
assert.match(markup, /reasoning 10/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders external MCP vault tool results as artifact cards", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "external-call-1",
|
||||
toolName: "mcp__netcatty__vault_notes_create",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
note: { id: "note-1", title: "Deploy Runbook", group: "ops" },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /Deploy Runbook/);
|
||||
assert.match(markup, /ops/);
|
||||
assert.doesNotMatch(markup, /external-call-1/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Netcatty CLI vault results as artifact cards", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "cli-call-1",
|
||||
name: "shell",
|
||||
arguments: {
|
||||
command: `/bin/zsh -lc '"/Applications/Netcatty.app/netcatty-tool-cli" vault host get --host-id host_1 --json'`,
|
||||
},
|
||||
},
|
||||
],
|
||||
executionStatus: "completed",
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 2,
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "cli-call-1",
|
||||
toolName: "shell",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
host: { id: "host_1", label: "Prod", hostname: "prod.example.com", port: 22 },
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /Prod/);
|
||||
assert.match(markup, /prod\.example\.com:22/);
|
||||
assert.doesNotMatch(markup, /cli-call-1/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Claude MCP list envelopes as summary cards", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolCalls: [
|
||||
{
|
||||
id: "notes-call-1",
|
||||
name: "mcp__netcatty-remote-hosts__vault_notes_list",
|
||||
arguments: {},
|
||||
},
|
||||
],
|
||||
executionStatus: "completed",
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 2,
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "notes-call-1",
|
||||
content: JSON.stringify([
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
notes: Array.from({ length: 8 }, (_value, index) => ({
|
||||
id: `note-${index}`,
|
||||
title: `Note ${index}`,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /8 notes in Vault/);
|
||||
assert.doesNotMatch(markup, /notes-call-1/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders OpenCode MCP-prefixed vault results as artifact cards", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "opencode-call-1",
|
||||
toolName: "netcatty-remote-hosts_vault_notes_get",
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
note: {
|
||||
id: "note-1",
|
||||
title: "2026-06-25 Host Import Report",
|
||||
group: "infra/imports",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /2026-06-25 Host Import Report/);
|
||||
assert.match(markup, /infra\/imports/);
|
||||
assert.doesNotMatch(markup, /opencode-call-1/);
|
||||
});
|
||||
|
||||
test("ChatMessageList renders Copilot MCP-prefixed wrapped vault results as artifact cards", () => {
|
||||
const payload = {
|
||||
ok: true,
|
||||
notes: Array.from({ length: 8 }, (_value, index) => ({
|
||||
id: `note-${index}`,
|
||||
title: `Note ${index}`,
|
||||
})),
|
||||
};
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "copilot-call-1",
|
||||
toolName: "netcatty-remote-hosts-vault_notes_list",
|
||||
content: JSON.stringify({
|
||||
content: JSON.stringify(payload),
|
||||
detailedContent: JSON.stringify(payload),
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(payload),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /8 notes in Vault/);
|
||||
assert.doesNotMatch(markup, /copilot-call-1/);
|
||||
});
|
||||
|
||||
test('ChatMessageList renders script create tool results as artifact cards', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: 'tool-script-1',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: 'call-script-1',
|
||||
toolName: 'scripts_create',
|
||||
content: {
|
||||
ok: true,
|
||||
script: {
|
||||
id: 'script-1',
|
||||
label: 'Disk cleanup',
|
||||
language: 'javascript',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: 'en' },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(ChatMessageList, { messages }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(markup, /Disk cleanup/);
|
||||
assert.match(markup, /javascript script/);
|
||||
assert.doesNotMatch(markup, /call-script-1/);
|
||||
});
|
||||
|
||||
test("ChatMessageList wires vault artifact navigation when only note open is available", () => {
|
||||
assert.equal(shouldProvideVaultArtifactNavigation({
|
||||
onOpenVaultNote: () => {},
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("ChatMessageList leaves vault artifact navigation disabled without open actions", () => {
|
||||
assert.equal(shouldProvideVaultArtifactNavigation({}), false);
|
||||
});
|
||||
|
||||
test("Codex approval render plan preserves every approval for the same item", () => {
|
||||
const codexRequest = (
|
||||
toolCallId: string,
|
||||
itemId: string,
|
||||
command: string,
|
||||
chatSessionId = "chat-1",
|
||||
): ApprovalRequest => ({
|
||||
toolCallId,
|
||||
itemId,
|
||||
toolName: "codex.command",
|
||||
args: { command },
|
||||
chatSessionId,
|
||||
source: "codex-app-server",
|
||||
approvalType: "command",
|
||||
allowSession: false,
|
||||
});
|
||||
const pendingApprovals = new Map<string, ApprovalRequest>([
|
||||
["approval-1", codexRequest("approval-1", "item-1", "echo one")],
|
||||
["approval-2", codexRequest("approval-2", "item-1", "echo two")],
|
||||
["approval-3", codexRequest("approval-3", "item-2", "echo standalone")],
|
||||
["approval-other-session", codexRequest("approval-other-session", "item-1", "echo hidden", "chat-2")],
|
||||
["regular-approval", {
|
||||
toolCallId: "regular-approval",
|
||||
toolName: "terminal_execute",
|
||||
args: { command: "pwd" },
|
||||
chatSessionId: "chat-1",
|
||||
}],
|
||||
]);
|
||||
|
||||
const plan = buildCodexApprovalRenderPlan(
|
||||
pendingApprovals,
|
||||
new Set(["item-1"]),
|
||||
"chat-1",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
plan.byItemId.get("item-1")?.map(({ approvalId }) => approvalId),
|
||||
["approval-1", "approval-2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
plan.standalone.map(({ approvalId }) => approvalId),
|
||||
["approval-3"],
|
||||
);
|
||||
});
|
||||
1075
components/ai/ChatMessageList.tsx
Normal file
1075
components/ai/ChatMessageList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
296
components/ai/CodebuddyElicitationCard.test.tsx
Normal file
296
components/ai/CodebuddyElicitationCard.test.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { CodebuddyElicitationCard } from './CodebuddyElicitationCard';
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
test('CodebuddyElicitationCard renders MCP form fields and response actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId: 'el-1',
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
message: 'Choose deployment settings',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
environment: {
|
||||
type: 'string',
|
||||
title: 'Environment',
|
||||
enum: ['staging', 'production'],
|
||||
default: 'staging',
|
||||
},
|
||||
dryRun: {
|
||||
type: 'boolean',
|
||||
title: 'Dry run',
|
||||
},
|
||||
},
|
||||
required: ['environment'],
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async () => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /CodeBuddy needs your input/);
|
||||
assert.match(markup, /Choose deployment settings/);
|
||||
assert.match(markup, /Environment \*/);
|
||||
assert.match(markup, /staging/);
|
||||
assert.match(markup, /Dry run/);
|
||||
assert.match(markup, /Decline/);
|
||||
assert.match(markup, /Continue/);
|
||||
assert.doesNotMatch(markup, /role="alert"/);
|
||||
});
|
||||
|
||||
test('CodebuddyElicitationCard renders constraint errors and blocks submission', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId: 'el-invalid',
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
message: 'Choose valid settings',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
retries: {
|
||||
type: 'integer',
|
||||
title: 'Retries',
|
||||
minimum: 10,
|
||||
default: 1,
|
||||
},
|
||||
regions: {
|
||||
type: 'array',
|
||||
title: 'Regions',
|
||||
minItems: 2,
|
||||
default: ['us-east'],
|
||||
items: {
|
||||
enum: ['us-east', 'eu-west'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['retries', 'regions'],
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async () => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Retries must be at least 10\./);
|
||||
assert.match(markup, /Select at least 2 options for Regions\./);
|
||||
assert.match(markup, /aria-invalid="true"/);
|
||||
assert.match(markup, /<button[^>]*disabled=""[^>]*>Continue<\/button>/);
|
||||
});
|
||||
|
||||
test('CodebuddyElicitationCard uses unique error ids across concurrent cards', () => {
|
||||
const makeCard = (elicitationId: string) => (
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId,
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
retries: {
|
||||
type: 'integer',
|
||||
minimum: 2,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async () => {}}
|
||||
/>
|
||||
);
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
{makeCard('el-1')}
|
||||
{makeCard('el-2')}
|
||||
</I18nProvider>,
|
||||
);
|
||||
const ids = Array.from(markup.matchAll(/id="([^"]+-field-0-error)"/g), (match) => match[1]);
|
||||
const describedByIds = Array.from(
|
||||
markup.matchAll(/aria-describedby="([^"]+-field-0-error)"/g),
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
assert.equal(ids.length, 2);
|
||||
assert.equal(new Set(ids).size, 2);
|
||||
assert.deepEqual(describedByIds, ids);
|
||||
});
|
||||
|
||||
test('CodebuddyElicitationCard shows "must be whole number" for decimal integer input', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId: 'el-integer',
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
message: 'Pick an integer',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
retries: {
|
||||
type: 'integer',
|
||||
title: 'Retries',
|
||||
default: 3.5,
|
||||
},
|
||||
},
|
||||
required: ['retries'],
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async () => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Retries must be a whole number\./);
|
||||
assert.match(markup, /aria-invalid="true"/);
|
||||
});
|
||||
|
||||
test('CodebuddyElicitationCard can clear optional constrained values back to omitted', async () => {
|
||||
const responses: Array<{
|
||||
action: string;
|
||||
content?: Record<string, unknown>;
|
||||
}> = [];
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId: 'el-optional',
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
contact: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
},
|
||||
regions: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: { enum: ['us-east'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async (action, content) => {
|
||||
responses.push({ action, content });
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const emailInput = renderer!.root.findByProps({ type: 'email' });
|
||||
const regionInput = renderer!.root.findByProps({ type: 'checkbox' });
|
||||
await act(async () => {
|
||||
emailInput.props.onChange({ target: { value: 'cat@example.com' } });
|
||||
regionInput.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await act(async () => {
|
||||
emailInput.props.onChange({ target: { value: '' } });
|
||||
regionInput.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const continueButton = renderer!.root
|
||||
.findAllByType('button')
|
||||
.find((button) => button.children.join('') === 'ai.codebuddy.elicitation.accept');
|
||||
assert.ok(continueButton);
|
||||
assert.equal(continueButton.props.disabled, false);
|
||||
|
||||
await act(async () => {
|
||||
continueButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.deepEqual(responses, [{ action: 'accept', content: {} }]);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
test('CodebuddyElicitationCard submits required empty values when no size constraint forbids them', async () => {
|
||||
const responses: Array<{
|
||||
action: string;
|
||||
content?: Record<string, unknown>;
|
||||
}> = [];
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<CodebuddyElicitationCard
|
||||
elicitation={{
|
||||
elicitationId: 'el-required-empty',
|
||||
chatSessionId: 'chat-1',
|
||||
request: {
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
note: {
|
||||
type: 'string',
|
||||
default: 'temporary',
|
||||
},
|
||||
regions: {
|
||||
type: 'array',
|
||||
default: ['us-east'],
|
||||
items: { enum: ['us-east'] },
|
||||
},
|
||||
},
|
||||
required: ['note', 'regions'],
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRespond={async (action, content) => {
|
||||
responses.push({ action, content });
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const noteInput = renderer!.root.findByProps({ type: 'text' });
|
||||
const regionInput = renderer!.root.findByProps({ type: 'checkbox' });
|
||||
await act(async () => {
|
||||
noteInput.props.onChange({ target: { value: '' } });
|
||||
regionInput.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const continueButton = renderer!.root
|
||||
.findAllByType('button')
|
||||
.find((button) => button.children.join('') === 'ai.codebuddy.elicitation.accept');
|
||||
assert.ok(continueButton);
|
||||
assert.equal(continueButton.props.disabled, false);
|
||||
|
||||
await act(async () => {
|
||||
continueButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.deepEqual(responses, [{
|
||||
action: 'accept',
|
||||
content: {
|
||||
note: '',
|
||||
regions: [],
|
||||
},
|
||||
}]);
|
||||
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
});
|
||||
});
|
||||
273
components/ai/CodebuddyElicitationCard.tsx
Normal file
273
components/ai/CodebuddyElicitationCard.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { MessageCircleQuestion } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
buildCodebuddyElicitationContent,
|
||||
initialCodebuddyElicitationValues,
|
||||
parseCodebuddyElicitationFields,
|
||||
selectedCodebuddyOptionKey,
|
||||
toggleCodebuddyArrayOption,
|
||||
validateCodebuddyElicitationValues,
|
||||
type CodebuddyElicitationField,
|
||||
type CodebuddyElicitationValidationIssue,
|
||||
} from '../../domain/codebuddyElicitationForm';
|
||||
import type {
|
||||
CodebuddyElicitation,
|
||||
CodebuddyElicitationAction,
|
||||
} from '../../infrastructure/ai/shared/codebuddyElicitations';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
function validationMessage(
|
||||
validationIssue: CodebuddyElicitationValidationIssue,
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
): string {
|
||||
const values = {
|
||||
field: validationIssue.fieldTitle,
|
||||
limit: validationIssue.limit,
|
||||
format: validationIssue.format,
|
||||
};
|
||||
return t(`ai.codebuddy.elicitation.validation.${validationIssue.code}`, values);
|
||||
}
|
||||
|
||||
function inputType(field: CodebuddyElicitationField): React.HTMLInputTypeAttribute {
|
||||
if (field.type === 'number' || field.type === 'integer') return 'number';
|
||||
if (field.format === 'email') return 'email';
|
||||
if (field.format === 'uri') return 'url';
|
||||
if (field.format === 'date') return 'date';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export const CodebuddyElicitationCard: React.FC<{
|
||||
elicitation: CodebuddyElicitation;
|
||||
onRespond: (
|
||||
action: CodebuddyElicitationAction,
|
||||
content?: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}> = ({ elicitation, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const formId = useId();
|
||||
const fields = useMemo(
|
||||
() => parseCodebuddyElicitationFields(elicitation.request.requestedSchema),
|
||||
[elicitation.request.requestedSchema],
|
||||
);
|
||||
const [values, setValues] = useState<Record<string, unknown>>(
|
||||
() => initialCodebuddyElicitationValues(fields),
|
||||
);
|
||||
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const validationIssues = validateCodebuddyElicitationValues(fields, values);
|
||||
const validationByField = new Map(
|
||||
validationIssues.map((validationIssue) => [validationIssue.fieldId, validationIssue]),
|
||||
);
|
||||
const complete = validationIssues.length === 0;
|
||||
|
||||
const markFieldTouched = useCallback((fieldId: string) => {
|
||||
setTouchedFields((current) => {
|
||||
if (current.has(fieldId)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(fieldId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const respond = async (
|
||||
action: CodebuddyElicitationAction,
|
||||
content?: Record<string, unknown>,
|
||||
) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await onRespond(action, content);
|
||||
} catch (responseError) {
|
||||
setError(responseError instanceof Error ? responseError.message : String(responseError));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-card/70 p-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircleQuestion size={16} className="mt-0.5 shrink-0 text-blue-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t('ai.codebuddy.elicitation.title')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">
|
||||
{elicitation.request.message || t('ai.codebuddy.elicitation.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fields.map((field, fieldIndex) => {
|
||||
const validationIssue = validationByField.get(field.id);
|
||||
const visibleValidationIssue = validationIssue
|
||||
&& (values[field.id] !== undefined || touchedFields.has(field.id))
|
||||
? validationIssue
|
||||
: undefined;
|
||||
const errorId = visibleValidationIssue
|
||||
? `${formId}-field-${fieldIndex}-error`
|
||||
: undefined;
|
||||
return (
|
||||
<div key={field.id} className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">
|
||||
{field.title}{field.required ? ' *' : ''}
|
||||
</span>
|
||||
{field.description ? (
|
||||
<span className="block text-[11px] text-muted-foreground">{field.description}</span>
|
||||
) : null}
|
||||
{field.type === 'array' && field.options.length > 0 ? (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
role="group"
|
||||
aria-invalid={Boolean(visibleValidationIssue)}
|
||||
aria-describedby={errorId}
|
||||
>
|
||||
{field.options.map((option) => {
|
||||
const selected = Array.isArray(values[field.id])
|
||||
? values[field.id] as unknown[]
|
||||
: [];
|
||||
const checked = selected.some((value) => Object.is(value, option.value));
|
||||
const maximumReached = field.maxItems !== undefined
|
||||
&& selected.length >= field.maxItems;
|
||||
return (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex items-center gap-2 rounded-md border border-border/50 px-2.5 py-2 text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={submitting || (!checked && maximumReached)}
|
||||
onChange={(event) => {
|
||||
markFieldTouched(field.id);
|
||||
setValues((current) => {
|
||||
const next = toggleCodebuddyArrayOption(
|
||||
current[field.id],
|
||||
option.value,
|
||||
event.target.checked,
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
[field.id]: next.length === 0 && !field.required
|
||||
? undefined
|
||||
: next,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : field.options.length > 0 ? (
|
||||
<select
|
||||
value={selectedCodebuddyOptionKey(field.options, values[field.id])}
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(visibleValidationIssue)}
|
||||
aria-describedby={errorId}
|
||||
onChange={(event) => {
|
||||
markFieldTouched(field.id);
|
||||
const option = field.options.find(
|
||||
(candidate) => candidate.key === event.target.value,
|
||||
);
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.id]: option?.value,
|
||||
}));
|
||||
}}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">{t('ai.codebuddy.elicitation.select')}</option>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.key} value={option.key}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'boolean' ? (
|
||||
<select
|
||||
value={values[field.id] === undefined ? '' : String(values[field.id])}
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(visibleValidationIssue)}
|
||||
aria-describedby={errorId}
|
||||
onChange={(event) => {
|
||||
markFieldTouched(field.id);
|
||||
const value = event.target.value === ''
|
||||
? undefined
|
||||
: event.target.value === 'true';
|
||||
setValues((current) => ({ ...current, [field.id]: value }));
|
||||
}}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">{t('ai.codebuddy.elicitation.select')}</option>
|
||||
<option value="true">{t('ai.codebuddy.elicitation.yes')}</option>
|
||||
<option value="false">{t('ai.codebuddy.elicitation.no')}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={inputType(field)}
|
||||
min={field.minimum}
|
||||
max={field.maximum}
|
||||
minLength={field.type === 'string' ? field.minLength : undefined}
|
||||
maxLength={field.type === 'string' ? field.maxLength : undefined}
|
||||
step={field.type === 'integer' ? 1 : undefined}
|
||||
value={String(values[field.id] ?? '')}
|
||||
disabled={submitting}
|
||||
aria-invalid={Boolean(visibleValidationIssue)}
|
||||
aria-describedby={errorId}
|
||||
onChange={(event) => {
|
||||
markFieldTouched(field.id);
|
||||
const raw = event.target.value;
|
||||
const value = raw === ''
|
||||
? field.type === 'string' && field.required
|
||||
? ''
|
||||
: undefined
|
||||
: field.type === 'number' || field.type === 'integer'
|
||||
? Number(raw)
|
||||
: raw;
|
||||
setValues((current) => ({ ...current, [field.id]: value }));
|
||||
}}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
{visibleValidationIssue ? (
|
||||
<p id={errorId} role="alert" className="text-[11px] text-destructive">
|
||||
{validationMessage(visibleValidationIssue, t)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() => void respond('cancel')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={() => void respond('decline')}
|
||||
>
|
||||
{t('ai.codebuddy.elicitation.decline')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={submitting || !complete}
|
||||
onClick={() => void respond(
|
||||
'accept',
|
||||
buildCodebuddyElicitationContent(values, fields),
|
||||
)}
|
||||
>
|
||||
{t('ai.codebuddy.elicitation.accept')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
40
components/ai/CodexUserInputCard.test.tsx
Normal file
40
components/ai/CodexUserInputCard.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { CodexUserInputCard } from './CodexUserInputCard';
|
||||
|
||||
test('CodexUserInputCard renders options, free-form input, and auto-resolution guidance', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: 'en' },
|
||||
React.createElement(CodexUserInputCard, {
|
||||
interaction: {
|
||||
interactionId: 'input-1',
|
||||
source: 'codex-app-server',
|
||||
kind: 'user-input',
|
||||
requestId: 'request-1',
|
||||
chatSessionId: 'chat-1',
|
||||
autoResolutionMs: 60_000,
|
||||
questions: [{
|
||||
id: 'mode',
|
||||
header: 'Mode',
|
||||
question: 'Choose a mode',
|
||||
isOther: true,
|
||||
isSecret: false,
|
||||
options: [{ label: 'Safe', description: 'Use the safe path' }],
|
||||
}],
|
||||
},
|
||||
onSubmit: () => {},
|
||||
onSkip: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.match(markup, /Codex needs your input/);
|
||||
assert.match(markup, /Choose a mode/);
|
||||
assert.match(markup, /Use the safe path/);
|
||||
assert.match(markup, /Enter another answer/);
|
||||
assert.match(markup, /continue automatically/);
|
||||
});
|
||||
109
components/ai/CodexUserInputCard.tsx
Normal file
109
components/ai/CodexUserInputCard.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { MessageCircleQuestion } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import type { CodexAppServerInteraction } from '../../infrastructure/ai/shared/codexAppServerInteractions';
|
||||
|
||||
type UserInputInteraction = Extract<CodexAppServerInteraction, { kind: 'user-input' }>;
|
||||
|
||||
export const CodexUserInputCard: React.FC<{
|
||||
interaction: UserInputInteraction;
|
||||
onSubmit: (answers: Record<string, { answers: string[] }>) => void;
|
||||
onSkip: () => void;
|
||||
}> = ({ interaction, onSubmit, onSkip }) => {
|
||||
const { t } = useI18n();
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const questions = useMemo(() => interaction.questions || [], [interaction.questions]);
|
||||
const complete = useMemo(
|
||||
() => questions.every((question) => String(values[question.id] || '').trim().length > 0),
|
||||
[questions, values],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
if (!complete) return;
|
||||
const answers: Record<string, { answers: string[] }> = {};
|
||||
for (const question of questions) {
|
||||
const value = String(values[question.id] || '');
|
||||
answers[question.id] = { answers: [question.isSecret ? value : value.trim()] };
|
||||
}
|
||||
onSubmit(answers);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircleQuestion size={16} className="mt-0.5 shrink-0 text-blue-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t('ai.codex.appServer.userInput.title')}</div>
|
||||
<div className="text-xs text-muted-foreground leading-5">
|
||||
{t('ai.codex.appServer.userInput.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{questions.map((question) => (
|
||||
<fieldset key={question.id} className="space-y-2">
|
||||
<legend className="text-xs font-medium">
|
||||
{question.header ? `${question.header}: ` : ''}{question.question}
|
||||
</legend>
|
||||
{question.options?.length ? (
|
||||
<div className="space-y-1.5">
|
||||
{question.options.map((option) => (
|
||||
<label
|
||||
key={option.label}
|
||||
className="flex cursor-pointer items-start gap-2 rounded-md border border-border/50 px-2.5 py-2 text-xs hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${interaction.interactionId}:${question.id}`}
|
||||
value={option.label}
|
||||
checked={values[question.id] === option.label}
|
||||
onChange={() => setValues((current) => ({ ...current, [question.id]: option.label }))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="block text-muted-foreground leading-5">{option.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{question.isOther ? (
|
||||
<input
|
||||
type={question.isSecret ? 'password' : 'text'}
|
||||
value={question.options.some((option) => option.label === values[question.id]) ? '' : (values[question.id] || '')}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [question.id]: event.target.value }))}
|
||||
placeholder={t('ai.codex.appServer.userInput.other')}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={question.isSecret ? 'password' : 'text'}
|
||||
value={values[question.id] || ''}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [question.id]: event.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
))}
|
||||
|
||||
{interaction.autoResolutionMs ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('ai.codex.appServer.userInput.autoResolve')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onSkip}>
|
||||
{t('ai.codex.appServer.userInput.skip')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!complete} onClick={submit}>
|
||||
{t('ai.codex.appServer.userInput.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
components/ai/ComposerModelPicker.test.tsx
Normal file
68
components/ai/ComposerModelPicker.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ComposerModelPicker } from './ComposerModelPicker';
|
||||
|
||||
test('Catty picker keeps a single model list and hides other providers until the submenu opens', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComposerModelPicker
|
||||
providers={[
|
||||
{
|
||||
id: 'p1',
|
||||
providerId: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
defaultModel: 'deepseek-v4-pro',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
providerId: 'openai',
|
||||
name: 'OpenAI',
|
||||
defaultModel: 'gpt-5.5',
|
||||
enabled: true,
|
||||
},
|
||||
]}
|
||||
selectedProviderId="p1"
|
||||
selectedModelId="deepseek-v4-pro"
|
||||
prefs={{ recent: [], pinned: [] }}
|
||||
onSelectProviderModel={() => {}}
|
||||
onTogglePinned={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /DeepSeek/);
|
||||
assert.match(html, /placeholder="ai\.chat\.searchModels"/);
|
||||
assert.match(html, /deepseek-v4-pro/);
|
||||
assert.match(html, /aria-label="ai\.chat\.selectProvider"/);
|
||||
assert.doesNotMatch(html, /OpenAI/);
|
||||
assert.doesNotMatch(html, /w-\[128px\]/);
|
||||
});
|
||||
|
||||
test('external agent picker lists presets without a provider column', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ComposerModelPicker
|
||||
modelPresets={[
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5' },
|
||||
{ id: 'gpt-5.4', name: 'GPT-5.4' },
|
||||
]}
|
||||
selectedModelId="gpt-5.5"
|
||||
prefs={{ recent: [{ modelId: 'gpt-5.5' }], pinned: [] }}
|
||||
onSelectModel={() => {}}
|
||||
onTogglePinned={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /GPT-5\.5/);
|
||||
assert.match(html, /GPT-5\.4/);
|
||||
assert.match(html, /ai\.chat\.recent/);
|
||||
assert.doesNotMatch(html, /ai\.chat\.providers/);
|
||||
});
|
||||
|
||||
test('custom model action is only offered in Catty provider-switcher mode', () => {
|
||||
const source = readFileSync(new URL('./ComposerModelPicker.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /const showCustom = Boolean\(\s*hasProviders/s);
|
||||
assert.match(source, /resolveComposerEnterModelId/);
|
||||
});
|
||||
321
components/ai/ComposerModelPicker.tsx
Normal file
321
components/ai/ComposerModelPicker.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
filterComposerModels,
|
||||
resolveComposerEnterModelId,
|
||||
resolvePinnedAndRecentModels,
|
||||
type ComposerModelPrefEntry,
|
||||
type ComposerModelPrefs,
|
||||
type ComposerPickerModel,
|
||||
} from '../../infrastructure/ai/composerPicker';
|
||||
import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types';
|
||||
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { useProviderModelCatalog } from './useProviderModelCatalog';
|
||||
|
||||
export const COMPOSER_PROVIDER_PICKER_WIDTH = 260;
|
||||
export const COMPOSER_MODEL_PICKER_WIDTH = 260;
|
||||
|
||||
export interface ComposerModelPickerProps {
|
||||
providers?: ProviderConfig[];
|
||||
selectedProviderId?: string;
|
||||
selectedModelId?: string;
|
||||
modelPresets?: AgentModelPreset[];
|
||||
prefs: ComposerModelPrefs;
|
||||
onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
|
||||
}
|
||||
|
||||
const rowClassName =
|
||||
'flex h-8 w-full items-center gap-2 px-2.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer';
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] tracking-wide text-muted-foreground/45">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ModelRow: React.FC<{
|
||||
model: ComposerPickerModel;
|
||||
selected: boolean;
|
||||
pinned: boolean;
|
||||
onSelect: () => void;
|
||||
onTogglePinned: () => void;
|
||||
pinLabel: string;
|
||||
unpinLabel: string;
|
||||
}> = ({ model, selected, pinned, onSelect, onTogglePinned, pinLabel, unpinLabel }) => (
|
||||
<div className="group/row relative">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={onSelect}
|
||||
className={rowClassName}
|
||||
>
|
||||
{selected
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />}
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={pinned ? unpinLabel : pinLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onTogglePinned();
|
||||
}}
|
||||
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 transition-opacity ${
|
||||
pinned
|
||||
? 'text-amber-400/90 opacity-100'
|
||||
: 'text-muted-foreground/45 opacity-0 group-hover/row:opacity-100 hover:text-foreground/70'
|
||||
}`}
|
||||
>
|
||||
<Star size={11} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
||||
providers = [],
|
||||
selectedProviderId,
|
||||
selectedModelId,
|
||||
modelPresets = [],
|
||||
prefs,
|
||||
onSelectProviderModel,
|
||||
onSelectModel,
|
||||
onTogglePinned,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasProviders = providers.length > 0;
|
||||
const [previewProviderId, setPreviewProviderId] = useState(
|
||||
selectedProviderId || providers[0]?.id || '',
|
||||
);
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState<'models' | 'providers'>('models');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProviderId) setPreviewProviderId(selectedProviderId);
|
||||
}, [selectedProviderId]);
|
||||
|
||||
const previewProvider = hasProviders
|
||||
? providers.find((provider) => provider.id === previewProviderId) ?? providers[0]
|
||||
: undefined;
|
||||
const catalog = useProviderModelCatalog(previewProvider, hasProviders);
|
||||
|
||||
const models = useMemo<ComposerPickerModel[]>(() => {
|
||||
if (hasProviders) return catalog.models;
|
||||
return modelPresets.map((preset) => ({
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
}));
|
||||
}, [catalog.models, hasProviders, modelPresets]);
|
||||
|
||||
const filtered = useMemo(() => filterComposerModels(models, query), [models, query]);
|
||||
const grouped = useMemo(
|
||||
() => resolvePinnedAndRecentModels({
|
||||
models: filtered,
|
||||
prefs,
|
||||
providerId: previewProvider?.id,
|
||||
allowMissing: Boolean(previewProvider?.id) && !query.trim(),
|
||||
}),
|
||||
[filtered, prefs, previewProvider?.id, query],
|
||||
);
|
||||
const pinnedKeys = useMemo(
|
||||
() => new Set(
|
||||
prefs.pinned
|
||||
.filter((entry) => !previewProvider || !entry.providerId || entry.providerId === previewProvider.id)
|
||||
.map((entry) => entry.modelId),
|
||||
),
|
||||
[prefs.pinned, previewProvider],
|
||||
);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const showCustom = Boolean(
|
||||
hasProviders
|
||||
&& trimmedQuery
|
||||
&& !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const selectModel = (modelId: string) => {
|
||||
const contextWindow = models.find((model) => model.id === modelId)?.contextWindow;
|
||||
if (hasProviders && previewProvider) {
|
||||
onSelectProviderModel?.(previewProvider.id, modelId, contextWindow);
|
||||
return;
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
};
|
||||
|
||||
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
|
||||
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
|
||||
);
|
||||
|
||||
if (hasProviders && view === 'providers') {
|
||||
return (
|
||||
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('models')}
|
||||
className={rowClassName}
|
||||
>
|
||||
<ChevronLeft size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/70">
|
||||
{t('ai.chat.providers')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mx-2 my-1 border-t border-border/40" />
|
||||
{providers.map((provider) => {
|
||||
const isBound = provider.id === selectedProviderId;
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isBound}
|
||||
onClick={() => {
|
||||
setPreviewProviderId(provider.id);
|
||||
setQuery('');
|
||||
setView('models');
|
||||
}}
|
||||
className={rowClassName}
|
||||
>
|
||||
<ProviderIconBadge provider={provider} size="xs" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/88">{provider.name}</span>
|
||||
{isBound && <Check size={11} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
|
||||
{hasProviders && previewProvider && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ai.chat.selectProvider')}
|
||||
onClick={() => setView('providers')}
|
||||
className={rowClassName}
|
||||
>
|
||||
<ProviderIconBadge provider={previewProvider} size="xs" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/88">{previewProvider.name}</span>
|
||||
<ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="px-2 pb-1">
|
||||
<div className="flex h-7 items-center gap-1.5 rounded-md bg-muted/40 px-2">
|
||||
<Search size={11} className="text-muted-foreground/50 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && trimmedQuery) {
|
||||
event.preventDefault();
|
||||
const nextId = resolveComposerEnterModelId({
|
||||
query: trimmedQuery,
|
||||
models,
|
||||
grouped,
|
||||
filtered,
|
||||
showCustom,
|
||||
});
|
||||
if (nextId) selectModel(nextId);
|
||||
}
|
||||
}}
|
||||
placeholder={t('ai.chat.searchModels')}
|
||||
className="h-full w-full bg-transparent text-[12px] text-foreground/88 outline-none placeholder:text-muted-foreground/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto">
|
||||
{catalog.loading && (
|
||||
<div className="flex h-8 items-center gap-1.5 px-2.5 text-[11px] text-muted-foreground/55">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
{t('ai.chat.loadingModels')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectModel(trimmedQuery)}
|
||||
className={rowClassName}
|
||||
>
|
||||
<Pin size={11} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="min-w-0 truncate text-foreground/85">
|
||||
{t('ai.chat.useCustomModel').replace('{id}', trimmedQuery)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{grouped.pinned.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>{t('ai.chat.pinned')}</SectionLabel>
|
||||
{grouped.pinned.map((model) => (
|
||||
<ModelRow
|
||||
key={`pin-${model.id}`}
|
||||
model={model}
|
||||
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
||||
pinned
|
||||
onSelect={() => selectModel(model.id)}
|
||||
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
||||
pinLabel={t('ai.chat.pinModel')}
|
||||
unpinLabel={t('ai.chat.unpinModel')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{grouped.recent.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>{t('ai.chat.recent')}</SectionLabel>
|
||||
{grouped.recent.map((model) => (
|
||||
<ModelRow
|
||||
key={`recent-${model.id}`}
|
||||
model={model}
|
||||
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
||||
pinned={pinnedKeys.has(model.id)}
|
||||
onSelect={() => selectModel(model.id)}
|
||||
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
||||
pinLabel={t('ai.chat.pinModel')}
|
||||
unpinLabel={t('ai.chat.unpinModel')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(grouped.pinned.length > 0 || grouped.recent.length > 0) && grouped.rest.length > 0 && (
|
||||
<SectionLabel>{t('ai.chat.models')}</SectionLabel>
|
||||
)}
|
||||
|
||||
{grouped.rest.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
||||
pinned={pinnedKeys.has(model.id)}
|
||||
onSelect={() => selectModel(model.id)}
|
||||
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
||||
pinLabel={t('ai.chat.pinModel')}
|
||||
unpinLabel={t('ai.chat.unpinModel')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!catalog.loading && filtered.length === 0 && !showCustom && (
|
||||
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">
|
||||
{catalog.error || t('ai.chat.noMatchingModels')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ComposerModelPicker);
|
||||
104
components/ai/ComposerThinkingChip.tsx
Normal file
104
components/ai/ComposerThinkingChip.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Brain, Check, ChevronDown } from 'lucide-react';
|
||||
import React, { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { formatComposerThinkingLabel } from '../../infrastructure/ai/composerPicker';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface ComposerThinkingChipProps {
|
||||
levels: readonly string[];
|
||||
selectedLevel?: string;
|
||||
disabled?: boolean;
|
||||
open: boolean;
|
||||
menuPos: { left: number; bottom: number } | null;
|
||||
onToggle: (rect: DOMRect | undefined) => void;
|
||||
onSelect: (level: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
|
||||
export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
|
||||
levels,
|
||||
selectedLevel,
|
||||
disabled = false,
|
||||
open,
|
||||
menuPos,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const formatLevel = (level: string) => (
|
||||
level === 'off' ? t('ai.chat.thinkingOff') : formatComposerThinkingLabel(level)
|
||||
);
|
||||
const label = selectedLevel
|
||||
? formatLevel(selectedLevel)
|
||||
: t('ai.chat.thinkingLevel');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(btnRef.current?.getBoundingClientRect())}
|
||||
className={`${chipClassName} shrink-0 ${
|
||||
disabled
|
||||
? 'opacity-60'
|
||||
: 'cursor-pointer hover:bg-muted/24 transition-colors'
|
||||
}`}
|
||||
aria-label={t('ai.chat.thinkingLevel')}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Brain size={11} className="text-violet-400/75" />
|
||||
<span className="truncate max-w-[56px]">{label}</span>
|
||||
{!disabled && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.thinkingLevel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{open && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={onClose} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t('ai.chat.thinkingLevel')}
|
||||
className="fixed z-[1000] min-w-[148px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{levels.map((level) => {
|
||||
const isSelected = selectedLevel === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!isSelected) onSelect(level);
|
||||
else onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />}
|
||||
<Brain size={12} className="text-violet-400/70 shrink-0" />
|
||||
<span className="text-foreground/85">{formatLevel(level)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ComposerThinkingChip);
|
||||
87
components/ai/ConversationExport.tsx
Normal file
87
components/ai/ConversationExport.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* ConversationExport - Dropdown button for exporting chat sessions
|
||||
*
|
||||
* Small download icon button with a dropdown offering Markdown, JSON,
|
||||
* and Plain Text export formats.
|
||||
*/
|
||||
|
||||
import { Download, FileJson, FileText, FileType } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface ConversationExportProps {
|
||||
session: AISession | null;
|
||||
onExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
{ format: 'md' as const, labelKey: 'ai.chat.exportMarkdown' as const, icon: FileText },
|
||||
{ format: 'json' as const, labelKey: 'ai.chat.exportJSON' as const, icon: FileJson },
|
||||
{ format: 'txt' as const, labelKey: 'ai.chat.exportPlainText' as const, icon: FileType },
|
||||
];
|
||||
|
||||
const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
session,
|
||||
onExport,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const handleExport = useCallback(
|
||||
(format: 'md' | 'json' | 'txt') => {
|
||||
onExport(format);
|
||||
},
|
||||
[onExport],
|
||||
);
|
||||
|
||||
const hasMessages = session && session.messages.length > 0;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-40 rounded-xl border border-border/60 bg-popover p-1.5 text-popover-foreground shadow-lg supports-[backdrop-filter]:bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/70">
|
||||
{t('ai.chat.exportAs')}
|
||||
</div>
|
||||
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => handleExport(format)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-muted-foreground" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConversationExport);
|
||||
113
components/ai/ExternalMcpApprovalsHost.tsx
Normal file
113
components/ai/ExternalMcpApprovalsHost.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Always-mounted host for External MCP approval cards.
|
||||
* Confirm-mode write tools from Codex/Claude/Cursor/Grok must be approvable
|
||||
* even when the Catty AI side panel has never been opened.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import {
|
||||
onApprovalCleared,
|
||||
onApprovalRequest,
|
||||
replayPendingApprovals,
|
||||
resolveApproval,
|
||||
type ApprovalRequest,
|
||||
} from '../../infrastructure/ai/shared/approvalGate';
|
||||
import {
|
||||
buildGrantsFromApproval,
|
||||
resolveCapabilityId,
|
||||
} from '../../infrastructure/ai/harness/permissionGrants';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
|
||||
|
||||
function isExternalMcpApproval(request: ApprovalRequest): boolean {
|
||||
return request.toolCallId.startsWith('mcp_approval_')
|
||||
&& request.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID;
|
||||
}
|
||||
|
||||
export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
if (!isExternalMcpApproval(request)) return;
|
||||
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
|
||||
};
|
||||
const unsub = onApprovalRequest(handler);
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of clearedIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApproveOnce = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, true);
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(toolCallId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAlwaysAllow = useCallback((toolCallId: string, request: ApprovalRequest) => {
|
||||
const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName);
|
||||
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId);
|
||||
resolveApproval(toolCallId, { approved: true, persistGrants });
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(toolCallId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleReject = useCallback((toolCallId: string) => {
|
||||
resolveApproval(toolCallId, false);
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(toolCallId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const entries = Array.from(pendingApprovals.entries());
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex w-[min(420px,calc(100vw-2rem))] flex-col gap-2"
|
||||
data-testid="external-mcp-approvals-host"
|
||||
>
|
||||
<div className="rounded-lg border border-border/60 bg-background/95 p-3 shadow-lg backdrop-blur-sm">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{t('ai.externalMcp.title')}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([id, req]) => (
|
||||
<ToolCall
|
||||
key={id}
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
192
components/ai/SlashCommandPicker.tsx
Normal file
192
components/ai/SlashCommandPicker.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Command, MessageSquare, Package } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import type {
|
||||
AIQuickMessage,
|
||||
SlashCommandItem,
|
||||
SystemSlashCommand,
|
||||
UserSkillSlashOption,
|
||||
} from '../../infrastructure/ai/quickMessages';
|
||||
import { getSlashCommandItemId } from '../../infrastructure/ai/quickMessages';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface SlashCommandPickerProps {
|
||||
listboxId: string;
|
||||
ariaLabel: string;
|
||||
quickMessages: AIQuickMessage[];
|
||||
systemCommands: SystemSlashCommand[];
|
||||
userSkills: UserSkillSlashOption[];
|
||||
slashCommandItems: SlashCommandItem[];
|
||||
activeMenuIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onSelectQuickMessage: (message: AIQuickMessage) => void;
|
||||
onSelectSystemCommand: (command: SystemSlashCommand) => void;
|
||||
systemCommandsSectionLabel: string;
|
||||
systemCommandDescription: (command: SystemSlashCommand) => string;
|
||||
onSelectSkill: (skill: UserSkillSlashOption) => void;
|
||||
quickMessagesSectionLabel: string;
|
||||
userSkillsSectionLabel: string;
|
||||
noResultsLabel: string;
|
||||
emptyHintLabel?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
listRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const SlashCommandPicker: React.FC<SlashCommandPickerProps> = ({
|
||||
listboxId,
|
||||
ariaLabel,
|
||||
quickMessages,
|
||||
systemCommands,
|
||||
userSkills,
|
||||
slashCommandItems,
|
||||
activeMenuIndex,
|
||||
onActiveIndexChange,
|
||||
onSelectQuickMessage,
|
||||
onSelectSystemCommand,
|
||||
systemCommandsSectionLabel,
|
||||
systemCommandDescription,
|
||||
onSelectSkill,
|
||||
quickMessagesSectionLabel,
|
||||
userSkillsSectionLabel,
|
||||
noResultsLabel,
|
||||
emptyHintLabel,
|
||||
className,
|
||||
style,
|
||||
listRef,
|
||||
}) => {
|
||||
const activeItem = slashCommandItems[activeMenuIndex];
|
||||
const activeDescendantId = activeItem ? `${listboxId}-${getSlashCommandItemId(activeItem)}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={ariaLabel}
|
||||
aria-activedescendant={activeDescendantId}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{slashCommandItems.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center space-y-1">
|
||||
<p className="text-[12px] text-muted-foreground/70">{noResultsLabel}</p>
|
||||
{emptyHintLabel ? (
|
||||
<p className="text-[11px] text-muted-foreground/45 leading-relaxed">{emptyHintLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{systemCommands.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{systemCommandsSectionLabel}
|
||||
</div>
|
||||
{systemCommands.map((command) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'system' && item.command.slug === command.slug,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${command.slug}`}
|
||||
key={command.slug}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectSystemCommand(command)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] min-w-0">
|
||||
<Command size={12} className="text-primary/60 shrink-0" />
|
||||
<span className="text-foreground/90">/{command.slug}</span>
|
||||
</div>
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62">
|
||||
{systemCommandDescription(command)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{quickMessages.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{quickMessagesSectionLabel}
|
||||
</div>
|
||||
{quickMessages.map((message) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'quickMessage' && item.message.id === message.id,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${message.id}`}
|
||||
key={message.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectQuickMessage(message)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] min-w-0">
|
||||
<MessageSquare size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90 truncate">{message.name}</span>
|
||||
<span className="text-muted-foreground/45 font-mono shrink-0">/{message.slug}</span>
|
||||
</div>
|
||||
{(message.description || message.content) ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{message.description || message.content}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{userSkills.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground/40 tracking-wide">
|
||||
{userSkillsSectionLabel}
|
||||
</div>
|
||||
{userSkills.map((skill) => {
|
||||
const idx = slashCommandItems.findIndex(
|
||||
(item) => item.kind === 'skill' && item.skill.id === skill.id,
|
||||
);
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`${listboxId}-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => onActiveIndexChange(idx)}
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
components/ai/ThinkingBlock.tsx
Normal file
138
components/ai/ThinkingBlock.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ThinkingBlock - Collapsible thinking/reasoning display
|
||||
*
|
||||
* - While streaming: expanded, "Thinking" label with shimmer + elapsed time
|
||||
* - When done: auto-collapses to "Thought for Xs", click to expand
|
||||
* - Content area has max-height with scroll and top gradient fade
|
||||
*/
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}m ${remaining}s`;
|
||||
}
|
||||
|
||||
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
||||
content,
|
||||
isStreaming,
|
||||
durationMs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = useState(isStreaming);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const wasStreamingRef = useRef(false);
|
||||
const startRef = useRef(Date.now());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-collapse when streaming ends
|
||||
useEffect(() => {
|
||||
if (wasStreamingRef.current && !isStreaming) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
wasStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
// Expand when streaming starts
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
setIsExpanded(true);
|
||||
startRef.current = Date.now();
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Elapsed time ticker
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Date.now() - startRef.current);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Auto-scroll to bottom while streaming
|
||||
useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [content, isStreaming, isExpanded]);
|
||||
|
||||
const toggle = useCallback(() => setIsExpanded(e => !e), []);
|
||||
|
||||
const displayDuration = durationMs || elapsed;
|
||||
const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
|
||||
|
||||
return (
|
||||
<div className="mb-0.5">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="thinking-block-content"
|
||||
className="group flex items-center gap-1.5 py-0.5 px-1 cursor-pointer text-left w-full rounded hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/50 transition-transform duration-200',
|
||||
isExpanded && 'rotate-90',
|
||||
!isExpanded && 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-muted-foreground/70 whitespace-nowrap shrink-0">
|
||||
{isStreaming ? (
|
||||
<span className="thinking-shimmer">{t('ai.chat.thinking')}</span>
|
||||
) : (
|
||||
displayDuration > 0
|
||||
? t('ai.chat.thoughtFor', { duration: formatDuration(displayDuration) })
|
||||
: t('ai.chat.thought')
|
||||
)}
|
||||
</span>
|
||||
{isStreaming && elapsed > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/40 tabular-nums shrink-0">
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
)}
|
||||
{!isExpanded && !isStreaming && preview && (
|
||||
<span className="text-[11px] text-muted-foreground/40 truncate min-w-0">
|
||||
{preview}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && content && (
|
||||
<div id="thinking-block-content" className="relative">
|
||||
{/* Top gradient fade */}
|
||||
{isStreaming && (
|
||||
<div className="absolute inset-x-0 top-0 h-4 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'px-5 text-[12px] text-muted-foreground/60 leading-relaxed whitespace-pre-wrap break-words',
|
||||
isStreaming && 'overflow-y-auto scrollbar-hide max-h-36',
|
||||
!isStreaming && 'max-h-36 overflow-y-auto scrollbar-hide',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ThinkingBlock);
|
||||
65
components/ai/ToolCallGroup.tsx
Normal file
65
components/ai/ToolCallGroup.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* ToolCallGroup - Collapsible container for grouped tool calls.
|
||||
*
|
||||
* Groups consecutive tool-call messages into a single collapsible section
|
||||
* (Codex-style). While the agent is still working the group stays expanded;
|
||||
* once the assistant responds it auto-collapses to "Used N tools".
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ToolCallGroupProps {
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
/** When true the group starts expanded (e.g. while streaming). */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
const ToolCallGroup: React.FC<ToolCallGroupProps> = ({
|
||||
count,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const prevDefault = useRef(defaultExpanded);
|
||||
|
||||
// Auto-collapse when the group transitions from "active" to "resolved"
|
||||
useEffect(() => {
|
||||
if (prevDefault.current && !defaultExpanded) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prevDefault.current = defaultExpanded;
|
||||
}, [defaultExpanded]);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-md border border-border/20 bg-muted/5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer',
|
||||
'hover:bg-muted/20 transition-colors select-none',
|
||||
)}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/50 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
|
||||
}
|
||||
<span className="text-muted-foreground/70 font-medium">
|
||||
{t('ai.chat.usedTools', { n: count })}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20 p-1.5 space-y-1.5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolCallGroup;
|
||||
53
components/ai/agentSendEligibility.test.ts
Normal file
53
components/ai/agentSendEligibility.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './agentSendEligibility';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'enabled-agent',
|
||||
name: 'Enabled Agent',
|
||||
command: '/usr/local/bin/enabled-agent',
|
||||
sdkBackend: 'codex',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'disabled-agent',
|
||||
name: 'Disabled Agent',
|
||||
command: '/usr/local/bin/disabled-agent',
|
||||
sdkBackend: 'codex',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'missing-backend-agent',
|
||||
name: 'Missing Backend Agent',
|
||||
command: '/usr/local/bin/missing-backend-agent',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'unavailable-agent',
|
||||
name: 'Unavailable Agent',
|
||||
command: '/usr/local/bin/unavailable-agent',
|
||||
sdkBackend: 'cursor',
|
||||
enabled: true,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
test('canSendWithAgent allows Catty and enabled external agents', () => {
|
||||
assert.equal(canSendWithAgent('catty', agents), true);
|
||||
assert.equal(canSendWithAgent('enabled-agent', agents), true);
|
||||
});
|
||||
|
||||
test('canSendWithAgent blocks missing or disabled external agents', () => {
|
||||
assert.equal(canSendWithAgent('disabled-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-backend-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('unavailable-agent', agents), false);
|
||||
assert.equal(canSendWithAgent('missing-agent', agents), false);
|
||||
});
|
||||
|
||||
test('findEnabledExternalAgent ignores disabled external agents', () => {
|
||||
assert.equal(findEnabledExternalAgent(agents, 'enabled-agent')?.name, 'Enabled Agent');
|
||||
assert.equal(findEnabledExternalAgent(agents, 'disabled-agent'), undefined);
|
||||
});
|
||||
20
components/ai/agentSendEligibility.ts
Normal file
20
components/ai/agentSendEligibility.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ExternalAgentConfig } from "../../infrastructure/ai/types";
|
||||
import { getExternalAgentSdkBackend } from "../../infrastructure/ai/managedAgents";
|
||||
|
||||
export function findEnabledExternalAgent(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentId: string,
|
||||
): ExternalAgentConfig | undefined {
|
||||
return agents.find((agent) =>
|
||||
agent.id === agentId &&
|
||||
agent.enabled &&
|
||||
agent.available !== false &&
|
||||
Boolean(getExternalAgentSdkBackend(agent)));
|
||||
}
|
||||
|
||||
export function canSendWithAgent(
|
||||
agentId: string,
|
||||
agents: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
return agentId === "catty" || Boolean(findEnabledExternalAgent(agents, agentId));
|
||||
}
|
||||
87
components/ai/aiMarkdownWarmup.test.ts
Normal file
87
components/ai/aiMarkdownWarmup.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
AI_COMPOSER_IDLE_MS,
|
||||
AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
isAiComposerBusy,
|
||||
isAiComposerTarget,
|
||||
markAiComposerActivity,
|
||||
resolveAiMarkdownWarmupDelay,
|
||||
shouldDeferAiMarkdownWarmup,
|
||||
} from './aiMarkdownWarmup';
|
||||
|
||||
test('defers markdown warmup while the composer is focused, composing, or recently active', () => {
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({}), false);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ composerFocused: true }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ isComposing: true }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ recentlyActive: true }), true);
|
||||
});
|
||||
|
||||
test('recent composer activity counts as busy', () => {
|
||||
markAiComposerActivity();
|
||||
assert.equal(isAiComposerBusy(), true);
|
||||
assert.ok(AI_COMPOSER_IDLE_MS >= 2000);
|
||||
});
|
||||
|
||||
test('recognizes the chat composer as a busy warmup target', () => {
|
||||
const body = { closest: (sel: string) => (sel.includes('ai-chat-input-body') ? {} : null) };
|
||||
assert.equal(isAiComposerTarget(body as unknown as EventTarget), true);
|
||||
assert.equal(isAiComposerTarget(null), false);
|
||||
});
|
||||
|
||||
test('composer-idle IPC waits the same expand grace as history markdown', () => {
|
||||
const warmup = readFileSync(new URL('./aiMarkdownWarmup.ts', import.meta.url), 'utf8');
|
||||
assert.match(warmup, /initialDelayMs:\s*options\?\.initialDelayMs \?\? AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS/);
|
||||
assert.match(warmup, /isBusy: isAiComposerBusy/);
|
||||
assert.match(warmup, /markAiComposerActivity\(\);\n\s*arm\(\);/);
|
||||
assert.match(warmup, /if \(isAiComposerTyping\(\)\) \{\s*hydrateScheduled = true;/s);
|
||||
});
|
||||
|
||||
test('history markdown waits after expand, then resumes quickly after blur', () => {
|
||||
assert.equal(AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS, 4000);
|
||||
assert.equal(AI_MARKDOWN_WARMUP_RESUME_DELAY_MS, 600);
|
||||
assert.equal(resolveAiMarkdownWarmupDelay({
|
||||
hasArmed: false,
|
||||
initialDelayMs: AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
}), 4000);
|
||||
assert.equal(resolveAiMarkdownWarmupDelay({
|
||||
hasArmed: true,
|
||||
initialDelayMs: AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
}), 600);
|
||||
});
|
||||
|
||||
test('AI panel no longer starts Streamdown just because it became visible', () => {
|
||||
const panel = readFileSync(new URL('../AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(panel, /scheduleAiMarkdownWarmup/);
|
||||
assert.match(panel, /warmAiMarkdownRenderer/);
|
||||
assert.doesNotMatch(panel, /timeout:\s*2500/);
|
||||
assert.doesNotMatch(panel, /import\('\.\/ai-elements\/messageResponse'\)/);
|
||||
assert.doesNotMatch(panel, /@streamdown\/code/);
|
||||
});
|
||||
|
||||
test('chat history defers Streamdown until warmup is already done', () => {
|
||||
const list = readFileSync(new URL('./ChatMessageList.tsx', import.meta.url), 'utf8');
|
||||
assert.match(list, /deferUntilWarm/);
|
||||
assert.match(list, /scheduleAiMarkdownWarmup/);
|
||||
assert.match(list, /isAiComposerTyping/);
|
||||
assert.match(list, /AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS/);
|
||||
assert.match(list, /AI_MARKDOWN_WARMUP_RESUME_DELAY_MS/);
|
||||
});
|
||||
|
||||
test('composer focus alone does not count as typing', () => {
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ composerFocused: true }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ composerFocused: true, isComposing: false, recentlyActive: false }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ isComposing: false, recentlyActive: false }), false);
|
||||
});
|
||||
|
||||
test('LazyMessageResponse keeps plaintext while chat asks to defer', () => {
|
||||
const source = readFileSync(new URL('../ai-elements/LazyMessageResponse.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /deferUntilWarm/);
|
||||
assert.match(source, /enqueueChatMarkdownHydrate/);
|
||||
assert.match(source, /isAiMarkdownRendererReady/);
|
||||
});
|
||||
274
components/ai/aiMarkdownWarmup.ts
Normal file
274
components/ai/aiMarkdownWarmup.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
const AI_COMPOSER_FOCUS_SELECTOR =
|
||||
'[data-section="ai-chat-input-body"], [data-section="ai-chat-panel"] textarea';
|
||||
|
||||
/** Wait after expand before history markdown can start — covers the type-a-few-chars window. */
|
||||
export const AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS = 4000;
|
||||
/** After the composer blurs, a short pause is enough to know typing stopped. */
|
||||
export const AI_MARKDOWN_WARMUP_RESUME_DELAY_MS = 600;
|
||||
/** Treat recent keystrokes as busy even if focus already moved. */
|
||||
export const AI_COMPOSER_IDLE_MS = 2000;
|
||||
let composerComposing = false;
|
||||
const CHAT_MARKDOWN_HYDRATE_BATCH = 2;
|
||||
|
||||
let markdownWarmupPromise: Promise<unknown> | null = null;
|
||||
let markdownWarmupResolved = false;
|
||||
let lastComposerActivityAt = 0;
|
||||
const readyListeners = new Set<() => void>();
|
||||
const hydrateQueue: Array<() => void> = [];
|
||||
let hydrateScheduled = false;
|
||||
|
||||
function notifyAiMarkdownRendererReady(): void {
|
||||
markdownWarmupResolved = true;
|
||||
for (const listener of readyListeners) listener();
|
||||
readyListeners.clear();
|
||||
pumpChatMarkdownHydrate();
|
||||
}
|
||||
|
||||
export function isAiComposerTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof (target as Element).closest !== 'function') return false;
|
||||
return Boolean((target as Element).closest(AI_COMPOSER_FOCUS_SELECTOR));
|
||||
}
|
||||
|
||||
export function markAiComposerActivity(): void {
|
||||
lastComposerActivityAt = Date.now();
|
||||
}
|
||||
|
||||
export function setAiComposerComposing(next: boolean): void {
|
||||
composerComposing = next;
|
||||
if (next) markAiComposerActivity();
|
||||
}
|
||||
|
||||
export function isAiComposerRecentlyActive(now = Date.now()): boolean {
|
||||
return lastComposerActivityAt > 0 && now - lastComposerActivityAt < AI_COMPOSER_IDLE_MS;
|
||||
}
|
||||
|
||||
export function shouldDeferAiMarkdownWarmup(input: {
|
||||
composerFocused?: boolean;
|
||||
isComposing?: boolean;
|
||||
recentlyActive?: boolean;
|
||||
}): boolean {
|
||||
return Boolean(input.composerFocused || input.isComposing || input.recentlyActive);
|
||||
}
|
||||
|
||||
export function isAiComposerTyping(): boolean {
|
||||
return shouldDeferAiMarkdownWarmup({
|
||||
isComposing: composerComposing,
|
||||
recentlyActive: isAiComposerRecentlyActive(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isAiComposerBusy(): boolean {
|
||||
const active = typeof document === 'undefined' ? null : document.activeElement;
|
||||
return shouldDeferAiMarkdownWarmup({
|
||||
composerFocused: isAiComposerTarget(active),
|
||||
isComposing: composerComposing,
|
||||
recentlyActive: isAiComposerRecentlyActive(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isAiMarkdownRendererReady(): boolean {
|
||||
return markdownWarmupResolved;
|
||||
}
|
||||
|
||||
export function subscribeAiMarkdownRendererReady(listener: () => void): () => void {
|
||||
if (markdownWarmupResolved) {
|
||||
listener();
|
||||
return () => {};
|
||||
}
|
||||
readyListeners.add(listener);
|
||||
return () => {
|
||||
readyListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAiMarkdownWarmupDelay(input: {
|
||||
hasArmed: boolean;
|
||||
initialDelayMs: number;
|
||||
resumeDelayMs: number;
|
||||
}): number {
|
||||
return input.hasArmed ? input.resumeDelayMs : input.initialDelayMs;
|
||||
}
|
||||
|
||||
/** Prefetch Streamdown off the first-keystroke path. Safe to call repeatedly. */
|
||||
export function warmAiMarkdownRenderer(): Promise<unknown> {
|
||||
markdownWarmupPromise ??= import('../ai-elements/messageResponse').then((module) => {
|
||||
notifyAiMarkdownRendererReady();
|
||||
return module;
|
||||
}, (error) => {
|
||||
markdownWarmupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return markdownWarmupPromise;
|
||||
}
|
||||
|
||||
function pumpChatMarkdownHydrate(): void {
|
||||
if (hydrateScheduled) return;
|
||||
hydrateScheduled = true;
|
||||
const run = () => {
|
||||
hydrateScheduled = false;
|
||||
if (hydrateQueue.length === 0) return;
|
||||
if (!markdownWarmupResolved) return;
|
||||
// Focused but idle is fine: Streamdown+CJK is light. Only pause while
|
||||
// the user is actually typing so history does not stay raw forever.
|
||||
if (isAiComposerTyping()) {
|
||||
hydrateScheduled = true;
|
||||
window.setTimeout(() => {
|
||||
hydrateScheduled = false;
|
||||
pumpChatMarkdownHydrate();
|
||||
}, AI_COMPOSER_IDLE_MS);
|
||||
return;
|
||||
}
|
||||
const batch = hydrateQueue.splice(0, CHAT_MARKDOWN_HYDRATE_BATCH);
|
||||
for (const task of batch) task();
|
||||
if (hydrateQueue.length > 0) {
|
||||
pumpChatMarkdownHydrate();
|
||||
}
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(run);
|
||||
return;
|
||||
}
|
||||
queueMicrotask(run);
|
||||
}
|
||||
|
||||
/** Upgrade deferred chat rows a few at a time, never while the composer is busy. */
|
||||
export function enqueueChatMarkdownHydrate(onReady: () => void): () => void {
|
||||
let cancelled = false;
|
||||
const task = () => {
|
||||
if (!cancelled) onReady();
|
||||
};
|
||||
hydrateQueue.push(task);
|
||||
pumpChatMarkdownHydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const index = hydrateQueue.indexOf(task);
|
||||
if (index >= 0) hydrateQueue.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` only when the composer is idle. Import/IPC cannot be cancelled
|
||||
* once started, so this must not fire during expand → first type.
|
||||
*/
|
||||
export function scheduleWhenAiComposerIdle(
|
||||
task: () => void,
|
||||
options?: {
|
||||
initialDelayMs?: number;
|
||||
resumeDelayMs?: number;
|
||||
},
|
||||
): () => void {
|
||||
return scheduleAiMarkdownWarmup({
|
||||
load: task,
|
||||
isBusy: isAiComposerBusy,
|
||||
initialDelayMs: options?.initialDelayMs ?? AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: options?.resumeDelayMs ?? AI_COMPOSER_IDLE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load markdown only when the browser is idle and the composer is not focused.
|
||||
* Import() cannot be cancelled, so this must not start during expand → first type.
|
||||
*/
|
||||
export function scheduleAiMarkdownWarmup(options?: {
|
||||
isBusy?: () => boolean;
|
||||
load?: () => void;
|
||||
initialDelayMs?: number;
|
||||
resumeDelayMs?: number;
|
||||
}): () => void {
|
||||
const isBusy = options?.isBusy ?? isAiComposerBusy;
|
||||
const load = options?.load ?? (() => {
|
||||
void warmAiMarkdownRenderer();
|
||||
});
|
||||
const initialDelayMs = options?.initialDelayMs ?? 0;
|
||||
const resumeDelayMs = options?.resumeDelayMs ?? 0;
|
||||
|
||||
let idleId: number | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
let cancelled = false;
|
||||
let hasArmed = false;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (idleId != null && typeof cancelIdleCallback === 'function') {
|
||||
cancelIdleCallback(idleId);
|
||||
}
|
||||
idleId = null;
|
||||
if (timeoutId != null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryLoad = () => {
|
||||
idleId = null;
|
||||
timeoutId = null;
|
||||
if (cancelled) return;
|
||||
if (isBusy()) {
|
||||
timeoutId = window.setTimeout(armIdle, Math.max(resumeDelayMs, AI_COMPOSER_IDLE_MS));
|
||||
return;
|
||||
}
|
||||
load();
|
||||
};
|
||||
|
||||
const armIdle = () => {
|
||||
if (cancelled) return;
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
idleId = requestIdleCallback(tryLoad);
|
||||
return;
|
||||
}
|
||||
timeoutId = window.setTimeout(tryLoad, 2000);
|
||||
};
|
||||
|
||||
const arm = () => {
|
||||
if (cancelled) return;
|
||||
clearTimers();
|
||||
const delay = resolveAiMarkdownWarmupDelay({
|
||||
hasArmed,
|
||||
initialDelayMs,
|
||||
resumeDelayMs,
|
||||
});
|
||||
hasArmed = true;
|
||||
if (delay > 0) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
timeoutId = null;
|
||||
armIdle();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
armIdle();
|
||||
};
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (isAiComposerTarget(event.target)) {
|
||||
markAiComposerActivity();
|
||||
arm();
|
||||
}
|
||||
};
|
||||
const onFocusOut = (event: FocusEvent) => {
|
||||
if (isAiComposerTarget(event.target)) arm();
|
||||
};
|
||||
const onComposerEvent = (event: Event) => {
|
||||
if (!isAiComposerTarget(event.target)) return;
|
||||
markAiComposerActivity();
|
||||
arm();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('focusin', onFocusIn);
|
||||
window.addEventListener('focusout', onFocusOut);
|
||||
window.addEventListener('keydown', onComposerEvent, true);
|
||||
window.addEventListener('compositionstart', onComposerEvent, true);
|
||||
window.addEventListener('compositionupdate', onComposerEvent, true);
|
||||
}
|
||||
arm();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimers();
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('focusin', onFocusIn);
|
||||
window.removeEventListener('focusout', onFocusOut);
|
||||
window.removeEventListener('keydown', onComposerEvent, true);
|
||||
window.removeEventListener('compositionstart', onComposerEvent, true);
|
||||
window.removeEventListener('compositionupdate', onComposerEvent, true);
|
||||
};
|
||||
}
|
||||
68
components/ai/aiPanelDiagnostics.test.ts
Normal file
68
components/ai/aiPanelDiagnostics.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const localStorageStub = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: localStorageStub,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: localStorageStub,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
|
||||
AI_PANEL_FORCE_HIDE_SHELL,
|
||||
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
|
||||
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
isAIPanelDiagnosticsProfilingEnabled,
|
||||
} = await import('./aiPanelDiagnostics.ts');
|
||||
|
||||
test('AI panel diagnostics does not hide content by default', () => {
|
||||
window.localStorage.removeItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
|
||||
assert.equal(AI_PANEL_FORCE_HIDE_ALL_CONTENT, false);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('header'), false);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('input'), false);
|
||||
});
|
||||
|
||||
test('AI panel diagnostics does not hide the side panel shell by default', () => {
|
||||
assert.equal(AI_PANEL_FORCE_HIDE_SHELL, false);
|
||||
});
|
||||
|
||||
test('AI panel diagnostics parses hidden parts from local storage', () => {
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY, ' messages, input ,markdown ');
|
||||
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
assert.equal(hiddenParts.has('messages'), true);
|
||||
assert.equal(hiddenParts.has('input'), true);
|
||||
assert.equal(hiddenParts.has('markdown'), true);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('messages', hiddenParts), true);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts), false);
|
||||
});
|
||||
|
||||
test('AI panel diagnostics supports hiding everything at once', () => {
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY, 'all');
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('header', hiddenParts), true);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('input', hiddenParts), true);
|
||||
});
|
||||
|
||||
test('AI panel profiling accepts common enabled values', () => {
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_PROFILE_KEY, 'on');
|
||||
assert.equal(isAIPanelDiagnosticsProfilingEnabled(), true);
|
||||
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_PROFILE_KEY, '0');
|
||||
assert.equal(isAIPanelDiagnosticsProfilingEnabled(), false);
|
||||
});
|
||||
13
components/ai/aiPanelDiagnostics.ts
Normal file
13
components/ai/aiPanelDiagnostics.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
|
||||
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
|
||||
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
|
||||
AI_PANEL_FORCE_HIDE_SHELL,
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
isAIPanelDiagnosticsProfilingEnabled,
|
||||
logAIPanelProfiler,
|
||||
profileAIPanelCalculation,
|
||||
type AIPanelDiagnosticPart,
|
||||
} from '../../application/state/aiPanelDiagnostics';
|
||||
270
components/ai/aiPanelViewState.test.ts
Normal file
270
components/ai/aiPanelViewState.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
shouldForceDraftViewSync,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: `Session ${id}`,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "terminal-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("panelViewsEqual treats draft views as equal even when refs differ", () => {
|
||||
assert.equal(
|
||||
panelViewsEqual({ mode: "draft" }, { mode: "draft" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("panelViewsEqual distinguishes session targets", () => {
|
||||
assert.equal(
|
||||
panelViewsEqual(
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("draft view never falls back to most recent history", () => {
|
||||
const panelView: AIPanelView = { mode: "draft" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("session view returns the selected session", () => {
|
||||
const selectedSession = createSession("session-2");
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: selectedSession.id };
|
||||
const sessions = [createSession("session-1"), selectedSession];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), selectedSession);
|
||||
});
|
||||
|
||||
test("missing session target resolves to null instead of history fallback", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("missing session target normalizes back to draft view", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync keeps explicit session while it is still in scoped history", () => {
|
||||
const explicit: AIPanelView = { mode: "session", sessionId: "just-created" };
|
||||
const normalized: AIPanelView = { mode: "draft" };
|
||||
// Predicate mirrors scoped historySessions (not the global store).
|
||||
const scopedHistory = new Set(["just-created"]);
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, (id) => scopedHistory.has(id)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync forces draft when explicit session is gone", () => {
|
||||
const explicit: AIPanelView = { mode: "session", sessionId: "deleted" };
|
||||
const normalized: AIPanelView = { mode: "draft" };
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, () => false),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync demotes when session is only in global store, not scoped history", () => {
|
||||
const explicit: AIPanelView = { mode: "session", sessionId: "other-scope" };
|
||||
const normalized: AIPanelView = { mode: "draft" };
|
||||
// Global store still has it; scoped history (what normalize uses) does not.
|
||||
const globalStore = new Set(["other-scope"]);
|
||||
const scopedHistory = new Set<string>();
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, (id) => scopedHistory.has(id)),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(explicit, normalized, (id) => globalStore.has(id)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceDraftViewSync is a no-op when views already match", () => {
|
||||
const view: AIPanelView = { mode: "session", sessionId: "session-1" };
|
||||
|
||||
assert.equal(
|
||||
shouldForceDraftViewSync(view, view, () => true),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("persisted session id that no longer exists in history falls back to newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("null persisted session id falls back to newest history entry", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal scope without explicit view always starts from draft even when history exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view prefers the draft when unsent input exists without an active chat", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions, null, "workspace"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace unsent draft keeps the persisted active chat after merge seed", () => {
|
||||
// Merge often seeds activeSessionIdMap without writing panelView. Typing a
|
||||
// follow-up must not demote to draft or send will createSession().
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit new-chat draft still wins over a stale persisted id", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(
|
||||
{ mode: "draft" },
|
||||
true,
|
||||
sessions,
|
||||
"session-1",
|
||||
"workspace",
|
||||
),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("draft state is used when there is no implicit history to resume", () => {
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, [], null, "workspace"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("history selection switches to the chosen session without touching draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyHistorySessionSelection("session-2", {
|
||||
showSessionView: (sessionId) => {
|
||||
calls.push(`view:${sessionId}`);
|
||||
},
|
||||
setActiveSessionId: (sessionId) => {
|
||||
calls.push(`active:${sessionId}`);
|
||||
},
|
||||
closeHistory: () => {
|
||||
calls.push("close-history");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"view:session-2",
|
||||
"active:session-2",
|
||||
"close-history",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
"show-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry can preserve the current session view while ensuring draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
preserveSessionView: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
]);
|
||||
});
|
||||
143
components/ai/aiPanelViewState.ts
Normal file
143
components/ai/aiPanelViewState.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
|
||||
|
||||
export function panelViewsEqual(
|
||||
left: AIPanelView,
|
||||
right: AIPanelView,
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (left.mode !== right.mode) {
|
||||
return false;
|
||||
}
|
||||
if (left.mode === "session" && right.mode === "session") {
|
||||
return left.sessionId === right.sessionId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface HistorySessionSelectionActions {
|
||||
showSessionView: (sessionId: string) => void;
|
||||
setActiveSessionId: (sessionId: string) => void;
|
||||
closeHistory?: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntrySelectionActions {
|
||||
ensureDraft: () => void;
|
||||
showDraftView: () => void;
|
||||
preserveSessionView?: boolean;
|
||||
}
|
||||
|
||||
export function resolveDisplayedPanelView(
|
||||
panelView: AIPanelView | undefined,
|
||||
hasDraft: boolean,
|
||||
sessions: AISession[],
|
||||
persistedSessionId?: string | null,
|
||||
scopeType: "terminal" | "workspace" = "workspace",
|
||||
): AIPanelView {
|
||||
if (panelView) {
|
||||
return normalizePanelView(panelView, sessions);
|
||||
}
|
||||
|
||||
// New terminal sessions should always start from a blank draft. History is
|
||||
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||||
// Explicit panelView above is the only way a terminal scope shows a session
|
||||
// (e.g. after dissolve handoff writes mode:session).
|
||||
if (scopeType === "terminal") {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// Workspace: keep the inherited/persisted active chat when the user starts
|
||||
// typing a follow-up. Merge seed often writes activeSessionIdMap only, with
|
||||
// no explicit panelView — if unsent draft outranked that selection, send
|
||||
// would createSession() while the main area still looked like the old chat.
|
||||
// Explicit "New Chat" clears the active map and writes mode:draft, so it
|
||||
// still wins via the panelView branch above.
|
||||
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||||
return { mode: "session", sessionId: persistedSessionId };
|
||||
}
|
||||
|
||||
if (hasDraft) {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
if (sessions[0]) {
|
||||
return { mode: "session", sessionId: sessions[0].id };
|
||||
}
|
||||
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function normalizePanelView(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AIPanelView {
|
||||
if (panelView.mode !== "session") {
|
||||
return panelView;
|
||||
}
|
||||
|
||||
return sessions.some((session) => session.id === panelView.sessionId)
|
||||
? panelView
|
||||
: DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the panel should force `showDraftView` when the normalized view
|
||||
* differs from the explicit store view.
|
||||
*
|
||||
* Explicit session views must stay put while the session is still present in
|
||||
* the same scoped history list that `normalizePanelView` uses — otherwise a
|
||||
* one-frame history lag after draft send demotes the new chat into history
|
||||
* and reopens a blank draft (especially under StrictMode).
|
||||
*
|
||||
* `sessionExists` must NOT consult the global store alone: a session that
|
||||
* exists but is out of this scope's history must demote so the panel does not
|
||||
* stick on a blank draft with a ghost active-map entry.
|
||||
*/
|
||||
export function shouldForceDraftViewSync(
|
||||
explicitPanelView: AIPanelView | undefined,
|
||||
normalizedPanelView: AIPanelView,
|
||||
sessionExists: (sessionId: string) => boolean,
|
||||
): boolean {
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) {
|
||||
return false;
|
||||
}
|
||||
if (explicitPanelView.mode === "session" && sessionExists(explicitPanelView.sessionId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveDisplayedSession(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AISession | null {
|
||||
if (panelView.mode !== "session") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function applyHistorySessionSelection(
|
||||
sessionId: string,
|
||||
actions: HistorySessionSelectionActions,
|
||||
): void {
|
||||
actions.showSessionView(sessionId);
|
||||
actions.setActiveSessionId(sessionId);
|
||||
actions.closeHistory?.();
|
||||
}
|
||||
|
||||
export function applyDraftEntrySelection(
|
||||
actions: DraftEntrySelectionActions,
|
||||
): void {
|
||||
actions.ensureDraft();
|
||||
if (!actions.preserveSessionView) {
|
||||
actions.showDraftView();
|
||||
}
|
||||
}
|
||||
278
components/ai/cattyHistoryReplay.test.ts
Normal file
278
components/ai/cattyHistoryReplay.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
|
||||
|
||||
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildHistoricalToolReplayMaps,
|
||||
buildHistoricalToolResultReplayText,
|
||||
buildHistoricalUserReplayContent,
|
||||
} from "./cattyHistoryReplay.ts";
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical image data with a placeholder", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "A".repeat(100_000),
|
||||
mediaType: "image/png",
|
||||
filename: "screenshot.png",
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("inspect this", [attachment]);
|
||||
|
||||
assert.match(result, /inspect this/);
|
||||
assert.match(result, /Historical image attachment omitted from replay/);
|
||||
assert.match(result, /filename=screenshot\.png/);
|
||||
assert.doesNotMatch(result, /AAAAA/);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent preserves historical file path metadata", () => {
|
||||
const content = buildHistoricalUserReplayContent("inspect this file", [{
|
||||
base64Data: "A".repeat(200),
|
||||
mediaType: "text/plain",
|
||||
filename: "deploy.log",
|
||||
filePath: "/tmp/netcatty/deploy.log",
|
||||
}]);
|
||||
|
||||
assert.match(content, /Historical file attachment omitted from replay/);
|
||||
assert.match(content, /filename=deploy\.log/);
|
||||
assert.match(content, /path=\/tmp\/netcatty\/deploy\.log/);
|
||||
assert.doesNotMatch(content, /AAAAAAAA/);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical terminal selections with metadata only", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "VGhpcyBpcyBhIGxvbmcgdGVybWluYWwgc2VsZWN0aW9u",
|
||||
mediaType: "text/plain",
|
||||
filename: "terminal-selection.log",
|
||||
terminalSelection: true,
|
||||
previewText: "npm run build failed on vite",
|
||||
lineCount: 42,
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("", [attachment]);
|
||||
|
||||
assert.match(result, /Historical terminal selection omitted from replay/);
|
||||
assert.match(result, /filename=terminal-selection\.log/);
|
||||
assert.match(result, /lines=42/);
|
||||
assert.match(result, /preview=npm run build failed on vite/);
|
||||
assert.doesNotMatch(result, /long terminal selection/);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText keeps bounded historical terminal evidence", () => {
|
||||
const toolCall: ToolCall = {
|
||||
id: "call-1",
|
||||
name: "terminal_execute",
|
||||
arguments: { command: "npm run build" },
|
||||
};
|
||||
const result: ToolResult = {
|
||||
toolCallId: "call-1",
|
||||
content: "BUILD ".repeat(20_000),
|
||||
isError: true,
|
||||
};
|
||||
|
||||
const replay = buildHistoricalToolResultReplayText(result, toolCall);
|
||||
|
||||
assert.match(replay, /Historical terminal output omitted from replay/);
|
||||
assert.match(replay, /command=npm run build/);
|
||||
assert.match(replay, /status=error/);
|
||||
assert.match(replay, /BUILD BUILD BUILD/);
|
||||
assert.match(replay, /shortened for replay/);
|
||||
assert.ok(replay.length < 4_600);
|
||||
assert.doesNotMatch(replay, /Re-run terminal_execute/);
|
||||
assert.match(replay, /do not execute the command again/i);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText bounds terminal poll output and keeps its job pointer", () => {
|
||||
const replay = buildHistoricalToolResultReplayText({
|
||||
toolCallId: "poll-1",
|
||||
content: "streamed output".repeat(5_000),
|
||||
}, {
|
||||
id: "poll-1",
|
||||
name: "terminal_poll",
|
||||
arguments: { jobId: "job-1", offset: 100 },
|
||||
});
|
||||
|
||||
assert.match(replay, /Historical terminal output omitted from replay/);
|
||||
assert.match(replay, /streamed output/);
|
||||
assert.match(replay, /jobId=job-1/);
|
||||
assert.ok(replay.length < 4_600);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText preserves small output that has no saved handle", () => {
|
||||
const replay = buildHistoricalToolResultReplayText({
|
||||
toolCallId: "small-1",
|
||||
content: "exit 1: configuration file is missing",
|
||||
isError: true,
|
||||
}, {
|
||||
id: "small-1",
|
||||
name: "terminal_execute",
|
||||
arguments: { command: "deploy" },
|
||||
});
|
||||
|
||||
assert.match(replay, /configuration file is missing/);
|
||||
assert.match(replay, /Only the bounded historical output below is available/);
|
||||
assert.doesNotMatch(replay, /saved output/i);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText preserves a large output handle from the tail", () => {
|
||||
const handleId = "tool-output-stable-handle-123";
|
||||
const replay = buildHistoricalToolResultReplayText({
|
||||
toolCallId: "large-1",
|
||||
content: `${"build line\n".repeat(2_000)}[output handle: stdout truncated for model context handleId=${handleId}]`,
|
||||
}, {
|
||||
id: "large-1",
|
||||
name: "terminal_execute",
|
||||
arguments: { command: "npm run build" },
|
||||
});
|
||||
|
||||
assert.match(replay, new RegExp(`tool_output_read with handleId=${handleId}`));
|
||||
assert.match(replay, new RegExp(`handleId=${handleId}`));
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText keeps non-terminal tool results intact", () => {
|
||||
const toolCall: ToolCall = {
|
||||
id: "call-1",
|
||||
name: "web_search",
|
||||
arguments: { query: "Vercel AI SDK" },
|
||||
};
|
||||
const result: ToolResult = {
|
||||
toolCallId: "call-1",
|
||||
content: "search result summary",
|
||||
};
|
||||
|
||||
assert.equal(buildHistoricalToolResultReplayText(result, toolCall), "search result summary");
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText can preserve terminal output for 413 retries", () => {
|
||||
const toolCall: ToolCall = {
|
||||
id: "call-1",
|
||||
name: "terminal_execute",
|
||||
arguments: { command: "npm test" },
|
||||
};
|
||||
const result: ToolResult = {
|
||||
toolCallId: "call-1",
|
||||
content: "real terminal output",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
buildHistoricalToolResultReplayText(result, toolCall, { preserveTerminalOutput: true }),
|
||||
"real terminal output",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText redacts credentials from omitted command details", () => {
|
||||
const replay = buildHistoricalToolResultReplayText(
|
||||
{ toolCallId: "call-secret", content: "output" },
|
||||
{ id: "call-secret", name: "terminal_execute", arguments: { command: "curl --password swordfish -H 'Authorization: Bearer secret_token_123456'" } },
|
||||
);
|
||||
assert.doesNotMatch(replay, /swordfish|secret_token/);
|
||||
assert.match(replay, /REDACTED/);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest preceding call", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolCalls: [{ id: "call1", name: "url_fetch", arguments: { url: "https://example.com" } }],
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: "call1", content: "PAGE" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 3,
|
||||
toolCalls: [{ id: "call1", name: "terminal_execute", arguments: { command: "cat /tmp/log" } }],
|
||||
},
|
||||
{
|
||||
id: "tool-2",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 4,
|
||||
toolResults: [{ toolCallId: "call1", content: "TERMINAL BYTES" }],
|
||||
},
|
||||
];
|
||||
|
||||
const maps = buildHistoricalToolReplayMaps(messages);
|
||||
const secondResult = messages[3].toolResults?.[0];
|
||||
assert.ok(secondResult);
|
||||
const pairedCall = maps.toolCallByToolResult.get(secondResult);
|
||||
|
||||
assert.equal(pairedCall?.name, "terminal_execute");
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[0])?.has(messages[0].toolCalls![0]), true);
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "A".repeat(5_000),
|
||||
mediaType: "text/markdown",
|
||||
filename: "runbook.md",
|
||||
vaultNoteId: "note-123",
|
||||
vaultNoteTitle: "Runbook",
|
||||
previewText: "restart nginx",
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
|
||||
|
||||
assert.match(result, /check this note/);
|
||||
assert.match(result, /Vault note reference/);
|
||||
assert.match(result, /"noteId":"note-123"/);
|
||||
assert.match(result, /"title":"Runbook"/);
|
||||
assert.doesNotMatch(result, /AAAAAA/);
|
||||
});
|
||||
|
||||
test("external recovery retains note identity before truncating a long user request", () => {
|
||||
const history = buildExternalBridgeContextMessages([{
|
||||
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
|
||||
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
|
||||
}]);
|
||||
const replay = history.find((message) => message.role === "user")!;
|
||||
assert.match(replay.content, /"noteId":"note-123"/);
|
||||
assert.match(replay.content, /vault_notes_get/);
|
||||
assert.match(replay.content, /truncated/);
|
||||
assert.ok(replay.content.length <= 2000);
|
||||
});
|
||||
|
||||
test("external recovery keeps multiple note IDs and a short user constraint", () => {
|
||||
const attachments = Array.from({length: 10}, (_, index) => ({
|
||||
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
|
||||
}));
|
||||
const history = buildExternalBridgeContextMessages([{
|
||||
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
|
||||
}]);
|
||||
const replay = history.find((message) => message.role === "user")!;
|
||||
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
|
||||
assert.match(replay.content, /Only compare; do not edit/);
|
||||
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
|
||||
});
|
||||
|
||||
for (const terminalSelection of [false, true]) {
|
||||
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
|
||||
const noteId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const request = "Only summarize yesterday's deployments; do not edit.";
|
||||
const messages: ChatMessage[] = [{
|
||||
id: 'old-note', role: 'user', timestamp: 1, content: request,
|
||||
attachments: [
|
||||
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
|
||||
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
|
||||
],
|
||||
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
|
||||
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
|
||||
}))];
|
||||
const compact = buildExternalBridgeContextMessages(messages)[0].content;
|
||||
assert.ok(compact.includes(noteId));
|
||||
assert.ok(compact.includes(request));
|
||||
assert.ok(compact.includes('other.txt'));
|
||||
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
|
||||
});
|
||||
}
|
||||
180
components/ai/cattyHistoryReplay.ts
Normal file
180
components/ai/cattyHistoryReplay.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
|
||||
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
|
||||
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
|
||||
|
||||
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
||||
const MAX_TOOL_COMMAND_CHARS = 220;
|
||||
const MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS = 4_000;
|
||||
const HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS = 2_800;
|
||||
|
||||
function truncateInline(value: string, maxChars: number): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
|
||||
return `${attachment.base64Data.length} base64 chars`;
|
||||
}
|
||||
|
||||
function formatTerminalSelectionPlaceholder(
|
||||
attachment: ChatMessageAttachment,
|
||||
index: number,
|
||||
): string {
|
||||
const details = [
|
||||
`filename=${attachment.filename || `terminal-selection-${index + 1}.log`}`,
|
||||
attachment.lineCount != null ? `lines=${attachment.lineCount}` : undefined,
|
||||
attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
|
||||
describeAttachmentSize(attachment),
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
return `[Historical terminal selection omitted from replay: ${details}]`;
|
||||
}
|
||||
|
||||
function formatAttachmentPlaceholder(
|
||||
attachment: ChatMessageAttachment,
|
||||
index: number,
|
||||
): string {
|
||||
const label = attachment.mediaType.startsWith("image/") ? "image" : "file";
|
||||
const details = [
|
||||
attachment.filename ? `filename=${attachment.filename}` : undefined,
|
||||
attachment.filePath ? `path=${attachment.filePath}` : undefined,
|
||||
`mediaType=${attachment.mediaType}`,
|
||||
describeAttachmentSize(attachment),
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
return `[Historical ${label} attachment omitted from replay: ${details || `attachment-${index + 1}`}]`;
|
||||
}
|
||||
|
||||
export function buildHistoricalUserReplayContent(
|
||||
content: string,
|
||||
attachments: ChatMessageAttachment[] = [],
|
||||
): string {
|
||||
if (!attachments.length) return content;
|
||||
// Keep note identities ahead of prose so bounded external history retains them.
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
|
||||
.map((attachment, index) => (
|
||||
isTerminalSelectionAttachment(attachment)
|
||||
? formatTerminalSelectionPlaceholder(attachment, index)
|
||||
: formatAttachmentPlaceholder(attachment, index)
|
||||
));
|
||||
|
||||
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
|
||||
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
||||
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
|
||||
}
|
||||
|
||||
function getToolCommand(toolCall?: ToolCall): string | undefined {
|
||||
const args = toolCall?.arguments ?? {};
|
||||
if (typeof args.command === "string") return args.command;
|
||||
const serialized = JSON.stringify(args);
|
||||
return serialized && serialized !== "{}" ? serialized : undefined;
|
||||
}
|
||||
|
||||
function fitHistoricalTerminalOutput(content: string): string {
|
||||
const safeContent = redactSecretsForModel(content);
|
||||
if (safeContent.length <= MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS) return safeContent;
|
||||
const marker = "\n\n[... historical terminal output shortened for replay ...]\n\n";
|
||||
const tailChars = Math.max(
|
||||
0,
|
||||
MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS - HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS - marker.length,
|
||||
);
|
||||
return `${safeContent.slice(0, HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS)}${marker}${safeContent.slice(-tailChars)}`;
|
||||
}
|
||||
|
||||
function findOutputHandleId(content: string): string | undefined {
|
||||
return content.match(/\bhandleId=(tool-output-[A-Za-z0-9-]+)/)?.[1];
|
||||
}
|
||||
|
||||
export function buildHistoricalToolReplayMaps(messages: ChatMessage[]): {
|
||||
resolvedToolCallsByAssistant: Map<ChatMessage, Set<ToolCall>>;
|
||||
toolCallByToolResult: Map<ToolResult, ToolCall>;
|
||||
} {
|
||||
const resolvedToolCallsByAssistant = new Map<ChatMessage, Set<ToolCall>>();
|
||||
const toolCallByToolResult = new Map<ToolResult, ToolCall>();
|
||||
const pendingToolCalls: Array<{ message: ChatMessage; toolCall: ToolCall }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
pendingToolCalls.push({ message, toolCall });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role !== "tool" || !message.toolResults?.length) continue;
|
||||
|
||||
for (const result of message.toolResults) {
|
||||
const pendingIndex = findLastIndex(
|
||||
pendingToolCalls,
|
||||
({ toolCall }) => toolCall.id === result.toolCallId,
|
||||
);
|
||||
if (pendingIndex < 0) continue;
|
||||
|
||||
const [paired] = pendingToolCalls.splice(pendingIndex, 1);
|
||||
toolCallByToolResult.set(result, paired.toolCall);
|
||||
|
||||
const resolved = resolvedToolCallsByAssistant.get(paired.message) ?? new Set<ToolCall>();
|
||||
resolved.add(paired.toolCall);
|
||||
resolvedToolCallsByAssistant.set(paired.message, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolvedToolCallsByAssistant, toolCallByToolResult };
|
||||
}
|
||||
|
||||
function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (predicate(items[index])) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function buildHistoricalToolResultReplayText(
|
||||
result: ToolResult,
|
||||
toolCall?: ToolCall,
|
||||
{
|
||||
preserveTerminalOutput = false,
|
||||
}: {
|
||||
preserveTerminalOutput?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const toolName = toolCall?.name ?? "unknown";
|
||||
if (!isTerminalToolName(toolName) || preserveTerminalOutput) {
|
||||
return result.content;
|
||||
}
|
||||
|
||||
const details = [
|
||||
`toolCallId=${result.toolCallId}`,
|
||||
getToolCommand(toolCall) ? `command=${truncateInline(redactSecretsForModel(getToolCommand(toolCall) ?? ""), MAX_TOOL_COMMAND_CHARS)}` : undefined,
|
||||
`outputChars=${result.content.length}`,
|
||||
result.isError ? "status=error" : "status=success",
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
const fittedOutput = fitHistoricalTerminalOutput(result.content);
|
||||
const handleId = findOutputHandleId(fittedOutput);
|
||||
const jobId = typeof toolCall?.arguments?.jobId === "string"
|
||||
? toolCall.arguments.jobId
|
||||
: undefined;
|
||||
const recovery = handleId
|
||||
? `Use tool_output_read with handleId=${handleId} for omitted details.`
|
||||
: jobId
|
||||
? `Poll the existing jobId=${jobId} if it is still running and more detail is needed.`
|
||||
: "Only the bounded historical output below is available.";
|
||||
|
||||
return [
|
||||
`[Historical terminal output omitted from replay beyond the bounded evidence below: ${details}. This tool call already completed; do not execute the command again. ${recovery}]`,
|
||||
fittedOutput || "[No terminal output was returned.]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isTerminalToolName(toolName: string): boolean {
|
||||
return toolName === "terminal"
|
||||
|| toolName === "terminal_exec"
|
||||
|| toolName === "terminal_execute"
|
||||
|| toolName === "terminal_start"
|
||||
|| toolName === "terminal_poll"
|
||||
|| toolName === "terminal_read_context";
|
||||
}
|
||||
45
components/ai/chatInputResize.ts
Normal file
45
components/ai/chatInputResize.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const CHAT_INPUT_MIN_HEIGHT = 112;
|
||||
export const CHAT_INPUT_MAX_HEIGHT = 420;
|
||||
export const CHAT_INPUT_DEFAULT_HEIGHT = 128;
|
||||
export const CHAT_INPUT_PANEL_RESERVE = 112;
|
||||
|
||||
export function resolveChatInputMaxHeight(panelHeight: number): number {
|
||||
return Math.max(
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
Math.min(CHAT_INPUT_MAX_HEIGHT, panelHeight - CHAT_INPUT_PANEL_RESERVE),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveVisibleChatInputMaxHeight(panelHeight: number): number | null {
|
||||
if (!Number.isFinite(panelHeight) || panelHeight <= 0) return null;
|
||||
return resolveChatInputMaxHeight(panelHeight);
|
||||
}
|
||||
|
||||
export function resolveVisibleChatInputHeight(
|
||||
desiredHeight: number | null,
|
||||
maxHeight: number,
|
||||
): number | null {
|
||||
return desiredHeight == null ? null : Math.min(desiredHeight, maxHeight);
|
||||
}
|
||||
|
||||
export function resolveChatInputAriaHeight(
|
||||
height: number | null,
|
||||
maxHeight: number,
|
||||
): number {
|
||||
return Math.max(
|
||||
CHAT_INPUT_MIN_HEIGHT,
|
||||
Math.min(maxHeight, height ?? CHAT_INPUT_DEFAULT_HEIGHT),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChatInputResizeHeight(
|
||||
startHeight: number,
|
||||
startPointerY: number,
|
||||
pointerY: number,
|
||||
maxHeight: number,
|
||||
): number {
|
||||
return Math.min(
|
||||
maxHeight,
|
||||
Math.max(CHAT_INPUT_MIN_HEIGHT, startHeight + startPointerY - pointerY),
|
||||
);
|
||||
}
|
||||
88
components/ai/claudeConfigEnv.test.ts
Normal file
88
components/ai/claudeConfigEnv.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
splitClaudeEnv,
|
||||
buildClaudeEnv,
|
||||
parseEnvLines,
|
||||
serializeEnvLines,
|
||||
} from "../settings/tabs/ai/claudeConfigEnv";
|
||||
|
||||
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
|
||||
const result = splitClaudeEnv({
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(result.configDir, "/cfg");
|
||||
assert.equal(result.settingsPath, "");
|
||||
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
});
|
||||
|
||||
test("splitClaudeEnv handles undefined env", () => {
|
||||
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", settingsPath: "", envText: "" });
|
||||
});
|
||||
|
||||
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
|
||||
assert.deepEqual(
|
||||
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
|
||||
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
|
||||
);
|
||||
});
|
||||
|
||||
test("serializeEnvLines is the inverse for simple entries", () => {
|
||||
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
|
||||
});
|
||||
|
||||
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
|
||||
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
|
||||
const next = buildClaudeEnv(prev, "/cfg", "", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
|
||||
assert.equal(buildClaudeEnv(undefined, " ", " ", ""), undefined);
|
||||
});
|
||||
|
||||
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
|
||||
const next = buildClaudeEnv(
|
||||
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
|
||||
"/cfg",
|
||||
"",
|
||||
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nNETCATTY_CLAUDE_SETTINGS=/evil/settings.json\nANTHROPIC_API_KEY=sk-x",
|
||||
);
|
||||
assert.deepEqual(next, {
|
||||
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
});
|
||||
|
||||
test("splitClaudeEnv + buildClaudeEnv round-trip the settings marker (NETCATTY_CLAUDE_SETTINGS)", () => {
|
||||
const split = splitClaudeEnv({
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
NETCATTY_CLAUDE_SETTINGS: "/team/settings.json",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
assert.equal(split.settingsPath, "/team/settings.json");
|
||||
assert.equal(split.configDir, "/cfg");
|
||||
// the marker is kept out of the free-text env editor
|
||||
assert.equal(split.envText, "ANTHROPIC_API_KEY=sk-x");
|
||||
|
||||
// config dir + settings coexist (settings is additive, not a replacement for CLAUDE_CONFIG_DIR)
|
||||
const rebuilt = buildClaudeEnv(undefined, "/cfg", "/team/settings.json", "ANTHROPIC_API_KEY=sk-x");
|
||||
assert.deepEqual(rebuilt, {
|
||||
CLAUDE_CONFIG_DIR: "/cfg",
|
||||
NETCATTY_CLAUDE_SETTINGS: "/team/settings.json",
|
||||
ANTHROPIC_API_KEY: "sk-x",
|
||||
});
|
||||
|
||||
// settings alone (no config dir) is allowed
|
||||
assert.deepEqual(buildClaudeEnv(undefined, "", "/only/settings.json", ""), {
|
||||
NETCATTY_CLAUDE_SETTINGS: "/only/settings.json",
|
||||
});
|
||||
});
|
||||
56
components/ai/draftSendGate.test.ts
Normal file
56
components/ai/draftSendGate.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
endSend,
|
||||
endSendForKey,
|
||||
tryBeginDraftSend,
|
||||
tryBeginSend,
|
||||
tryBeginSendForKey,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
const gate = { current: false };
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
assert.equal(tryBeginDraftSend(gate), false);
|
||||
|
||||
endDraftSend(gate);
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
|
||||
test("send gate aliases cover session-mode re-entry the same way", () => {
|
||||
const gate = { current: false };
|
||||
assert.equal(tryBeginSend(gate), true);
|
||||
assert.equal(tryBeginSend(gate), false);
|
||||
endSend(gate);
|
||||
assert.equal(tryBeginSend(gate), true);
|
||||
});
|
||||
|
||||
test("module send latch survives across independent gate objects (remount-safe)", () => {
|
||||
const key = `test-send-${Date.now()}-${Math.random()}`;
|
||||
assert.equal(tryBeginSendForKey(key), true);
|
||||
assert.equal(tryBeginSendForKey(key), false);
|
||||
endSendForKey(key);
|
||||
assert.equal(tryBeginSendForKey(key), true);
|
||||
endSendForKey(key);
|
||||
});
|
||||
|
||||
test("AIChatSidePanel gates every send including session mode", () => {
|
||||
const source = readFileSync(new URL("../AIChatSidePanel.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /tryBeginSendForKey\(sendGateKey\)/);
|
||||
assert.match(source, /isAIChatSessionStreaming\(sessionId\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/isDraftMode && !tryBeginDraftSend/,
|
||||
"session-mode sends must share the sync re-entry gate",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/sendInFlightRef/,
|
||||
"component refs reset on StrictMode remount; use module key latch",
|
||||
);
|
||||
});
|
||||
28
components/ai/draftSendGate.ts
Normal file
28
components/ai/draftSendGate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
if (gate.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
gate.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
|
||||
export const tryBeginSend = tryBeginDraftSend;
|
||||
export const endSend = endDraftSend;
|
||||
|
||||
const sendInFlightByKey = new Set<string>();
|
||||
|
||||
export function tryBeginSendForKey(key: string): boolean {
|
||||
if (!key || sendInFlightByKey.has(key)) return false;
|
||||
sendInFlightByKey.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endSendForKey(key: string): void {
|
||||
if (!key) return;
|
||||
sendInFlightByKey.delete(key);
|
||||
}
|
||||
679
components/ai/externalAgentHistory.test.ts
Normal file
679
components/ai/externalAgentHistory.test.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import { createTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment.ts";
|
||||
import {
|
||||
buildExternalAgentHistoryMessages,
|
||||
buildExternalAgentHistoryMessagesForBridge,
|
||||
} from "./externalAgentHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildExternalAgentHistoryMessages compacts older external agent context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 SDK agent history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "search",
|
||||
content: `error: ${"large output ".repeat(500)}`,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message("u4", "user", "好的"),
|
||||
message("a4", "assistant", "准备实现"),
|
||||
message("u5", "user", "继续"),
|
||||
message("a5", "assistant", "继续处理"),
|
||||
message("u6", "user", "现在提交"),
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
assert.match(result[0].content, /最小改动/);
|
||||
assert.match(result[0].content, /pwsh\.exe/);
|
||||
assert.match(result[0].content, /PR #738/);
|
||||
assert.ok(result[0].content.length <= 3000);
|
||||
|
||||
assert.ok(result.length <= 7);
|
||||
assert.deepEqual(
|
||||
result.slice(1).map((entry) => entry.content),
|
||||
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
|
||||
);
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessagesForBridge keeps fallback history available for stale SDK agent session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildExternalAgentHistoryMessagesForBridge([], "sdk-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildExternalAgentHistoryMessagesForBridge(messages, "sdk-session-1"),
|
||||
buildExternalAgentHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages replaces historical terminal selection attachments with placeholders", () => {
|
||||
const terminalSelection = createTerminalSelectionAttachment("docker ps -a\npermission denied");
|
||||
assert.ok(terminalSelection);
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "", {
|
||||
attachments: [terminalSelection],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Historical terminal selection omitted from replay/);
|
||||
assert.match(result[0].content, /docker ps -a/);
|
||||
assert.doesNotMatch(result[0].content, /permission denied/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
assert.deepEqual(
|
||||
result.slice(-6).map((entry) => entry.content),
|
||||
[
|
||||
"filler user message 11",
|
||||
"filler assistant message 11",
|
||||
"filler user message 12",
|
||||
"filler assistant message 12",
|
||||
"filler user message 13",
|
||||
"filler assistant message 13",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
// 不要提交 line lands at priority=2 (always preferred). The check below
|
||||
// doesn't assert that the ordinary words are absent from the compact
|
||||
// section — they may legitimately survive when budget allows; that's
|
||||
// intentional after we stopped blanket-dropping short user messages.
|
||||
// What we DO verify: the priority-2 line is selected, which is only
|
||||
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
|
||||
// from the surrounding short ordinary-word turns.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
message("u2", "user", "approach"),
|
||||
message("a2", "assistant", "ack"),
|
||||
message("u3", "user", "improve"),
|
||||
message("a3", "assistant", "ack"),
|
||||
message("u4", "user", "prepare"),
|
||||
message("a4", "assistant", "ack"),
|
||||
];
|
||||
|
||||
for (let index = 5; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
|
||||
message("a13", "assistant", "Understood."),
|
||||
);
|
||||
|
||||
for (let index = 14; index <= 18; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Use ssh2"),
|
||||
message("a1", "assistant", "Got it."),
|
||||
message("u2", "user", "中文输出"),
|
||||
message("a2", "assistant", "明白"),
|
||||
];
|
||||
|
||||
// Push enough later turns so u1/u2 fall outside the recent raw window
|
||||
// and have to survive via the durable-user compaction path.
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "继续"),
|
||||
message("a2", "assistant", "继续处理"),
|
||||
];
|
||||
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
if (result.length > 0 && result[0].role === "user") {
|
||||
assert.doesNotMatch(result[0].content, /User request: ok\b/);
|
||||
assert.doesNotMatch(result[0].content, /User request: 继续/);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages keeps bounded recent terminal evidence", () => {
|
||||
// Historical terminal output stays self-describing and useful while
|
||||
// remaining bounded on every follow-up.
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: bigToolOutput, isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): \[Historical terminal output omitted from replay/);
|
||||
assert.match(flat, /outputChars=1500/);
|
||||
assert.match(flat, /DATA DATA DATA/);
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length < 2_100,
|
||||
`expected terminal result evidence to stay bounded, got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
// AI sees opaque bytes and "use that output" becomes ambiguous.
|
||||
const messages: ChatMessage[] = [
|
||||
// Early filler to push the tool_call off the raw window
|
||||
message("u0", "user", "prior chatter"),
|
||||
message("a0", "assistant", "prior reply"),
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [
|
||||
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
|
||||
],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
message("a2", "assistant", "acknowledged"),
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
// args, so even if a1 was pushed out of the raw window, the result is
|
||||
// self-describing.
|
||||
assert.match(flat, /Tool result \[from terminal_exec/);
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
// only messages within the recent user-turn window contribute
|
||||
// durable candidates.
|
||||
const messages: ChatMessage[] = [];
|
||||
// An ancient high-priority constraint that MUST be aged out.
|
||||
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
|
||||
messages.push(message("old-ack", "assistant", "收到"));
|
||||
|
||||
// 300 filler turns between the ancient constraint and the window —
|
||||
// well past MAX_DURABLE_SCAN_TURNS (100).
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
messages.push(
|
||||
message(`u${i}`, "user", `filler user message ${i}`),
|
||||
message(`a${i}`, "assistant", `filler assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
// A recent constraint that should survive.
|
||||
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
messages.push(
|
||||
message(`t${i}`, "user", `tail user message ${i}`),
|
||||
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
assert.match(flat, /recent-marker-abc/);
|
||||
// Ancient one past the scan window is dropped — proof the bound holds.
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
// results + follow-up assistant), so 200 messages might be only
|
||||
// ~40 user turns. An instruction like "不要提交" from turn 5 would
|
||||
// fall out of the scan before the turn count justified aging it out.
|
||||
//
|
||||
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
|
||||
// chat with only 30 user turns but many messages per turn — the
|
||||
// early constraint must still survive.
|
||||
const messages: ChatMessage[] = [];
|
||||
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
|
||||
messages.push(message("early-ack", "assistant", "收到"));
|
||||
|
||||
// 35 additional turns, each with 6 messages (bloats the total
|
||||
// message count to >200 without exceeding 100 user turns).
|
||||
for (let turn = 1; turn < 36; turn += 1) {
|
||||
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
|
||||
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
|
||||
toolCalls: [
|
||||
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
|
||||
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
|
||||
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
|
||||
],
|
||||
}));
|
||||
messages.push(message(`t${turn}a`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}b`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}c`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
|
||||
}
|
||||
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
// under the turn-based bound it survives.
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
// satisfied none of those and were silently dropped. After a stale-
|
||||
// session recovery, "do what you suggested earlier" would then replay
|
||||
// only the user's question without the assistant's actual decision.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "which client should I use"),
|
||||
message("a1", "assistant", "Use ssh2"),
|
||||
message("u2", "user", "output language?"),
|
||||
message("a2", "assistant", "中文输出"),
|
||||
message("u3", "user", "merge or rebase?"),
|
||||
message("a3", "assistant", "rebase instead"),
|
||||
];
|
||||
|
||||
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
|
||||
// must flow through the durable-assistant compact pass.
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
assert.match(flat, /中文输出/);
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "prompt 1"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "prompt 2"),
|
||||
message("a2", "assistant", "明白"),
|
||||
message("u3", "user", "prompt 3"),
|
||||
message("a3", "assistant", "got it"),
|
||||
];
|
||||
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `more filler ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: got it\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
// With multiple older tool outputs, all surfacing as identical
|
||||
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
|
||||
// output" have no way to map to the right call.
|
||||
const messages: ChatMessage[] = [
|
||||
// Two distinct tool interactions, both pushed well outside the
|
||||
// recent raw window by later turns.
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
|
||||
}),
|
||||
message("u2", "user", "show resolv.conf"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool2", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
|
||||
}),
|
||||
// Important user text so summarizeMessage picks these up via the
|
||||
// important-text branch; tool results themselves are always
|
||||
// summarized regardless of IMPORTANT_PATTERNS.
|
||||
message("u3", "user", "fallback plan"),
|
||||
];
|
||||
|
||||
// Filler to push the early tool results out of the 6-item raw window
|
||||
// and into the compact summary section (scanned = last 20).
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
// follow-up can disambiguate them.
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
// section — doubling the budget cost of important user turns / large
|
||||
// tool output and crowding out older durable context.
|
||||
//
|
||||
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
|
||||
// (so it can be asserted absent from raw), then a distinctive "raw
|
||||
// only" marker that should appear only in the last-6 raw slice.
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
messages.push(
|
||||
message(`uf${index}`, "user", `filler user ${index}`),
|
||||
message(`af${index}`, "assistant", `filler assistant ${index}`),
|
||||
);
|
||||
}
|
||||
// These are the last 4 user/assistant messages — guaranteed to be in
|
||||
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
|
||||
// also get summarized into the compact section, duplicating the cost.
|
||||
messages.push(
|
||||
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
|
||||
message("a-rec1", "assistant", "", {
|
||||
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
|
||||
}),
|
||||
message("tool-rec", "tool", "", {
|
||||
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
|
||||
}),
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
|
||||
// Both markers belong to messages inside the raw window — they must
|
||||
// not be summarized into compact (which would double-bill them).
|
||||
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
|
||||
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
|
||||
|
||||
// Raw section still carries them verbatim.
|
||||
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
const rawFlat = raw.map((m) => m.content).join("\n");
|
||||
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
// with the wrong command (e.g. a /etc/hosts result labeled as
|
||||
// /etc/resolv.conf). Now each tool_result is indexed by its own
|
||||
// messageId + toolCallId and resolved to the most recent preceding
|
||||
// call with that id.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool-hosts", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
|
||||
}),
|
||||
// A later assistant turn reuses the id "call1" for a different call.
|
||||
message("u2", "user", "show resolv"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool-resolv", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
|
||||
}),
|
||||
message("u3", "user", "ok"),
|
||||
];
|
||||
|
||||
// Pad so the first interaction lands in the compact summary pass.
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
// args — not whichever assistant tool_call happened to win the
|
||||
// last-write on the shared id.
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/hosts/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/resolv\.conf/);
|
||||
assert.match(flat, /HOSTS_BYTES/);
|
||||
assert.match(flat, /RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 7; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
|
||||
message(`a${index}`, "assistant", "ack"),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
});
|
||||
18
components/ai/externalAgentHistory.ts
Normal file
18
components/ai/externalAgentHistory.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
buildExternalBridgeContextMessages,
|
||||
} from '../../infrastructure/ai/harness/externalBridgeContext';
|
||||
|
||||
export type ExternalAgentHistoryMessage = { role: 'user' | 'assistant'; content: string };
|
||||
|
||||
export function buildExternalAgentHistoryMessages(messages: ChatMessage[]): ExternalAgentHistoryMessage[] {
|
||||
return buildExternalBridgeContextMessages(messages);
|
||||
}
|
||||
|
||||
export function buildExternalAgentHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): ExternalAgentHistoryMessage[] | undefined {
|
||||
const historyMessages = buildExternalAgentHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
2
components/ai/hooks/aiChatStreamingSupport.ts
Normal file
2
components/ai/hooks/aiChatStreamingSupport.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from `@/infrastructure/ai/aiChatStreamingSupport` instead. */
|
||||
export * from '../../../infrastructure/ai/aiChatStreamingSupport';
|
||||
8
components/ai/hooks/useAIChatStreaming.ts
Normal file
8
components/ai/hooks/useAIChatStreaming.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @deprecated Import from `@/application/state/useAIChatStreaming` instead. */
|
||||
export {
|
||||
useAIChatStreaming,
|
||||
isAIChatSessionStreaming,
|
||||
getNetcattyBridge,
|
||||
type ActiveCompactionUi,
|
||||
type DefaultTargetSessionHint,
|
||||
} from "../../../application/state/useAIChatStreaming";
|
||||
9
components/ai/hooks/useAgentCompactionUi.ts
Normal file
9
components/ai/hooks/useAgentCompactionUi.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @deprecated Import from `@/application/state/useAgentCompactionUi` instead. */
|
||||
export {
|
||||
useAgentCompactionUi,
|
||||
useAgentContextUsage,
|
||||
compactionStatusText,
|
||||
resolveCompactionStatusText,
|
||||
type ActiveCompactionUi,
|
||||
type AgentContextUsage,
|
||||
} from "../../../application/state/useAgentCompactionUi";
|
||||
76
components/ai/hooks/useConversationExport.ts
Normal file
76
components/ai/hooks/useConversationExport.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* useConversationExport — Encapsulates conversation export logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Export in markdown, JSON, and plain text formats
|
||||
* - Object URL lifecycle management (creation, revocation, cleanup on unmount)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { AISession } from '../../../infrastructure/ai/types';
|
||||
import { exportAsMarkdown, exportAsJSON, exportAsPlainText, getExportFilename } from '../../../infrastructure/ai/conversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseConversationExportReturn {
|
||||
/** Trigger a download of the active session in the given format. */
|
||||
handleExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
/** Ref to active object URLs for cleanup on unmount (exposed for the parent cleanup effect). */
|
||||
activeObjectUrlsRef: React.MutableRefObject<Set<string>>;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useConversationExport(
|
||||
activeSession: AISession | null,
|
||||
): UseConversationExportReturn {
|
||||
// Ref to track active object URLs for cleanup on unmount (Issue #19)
|
||||
const activeObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Clean up object URLs on unmount
|
||||
useEffect(() => {
|
||||
const urls = activeObjectUrlsRef.current;
|
||||
return () => {
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback((format: 'md' | 'json' | 'txt') => {
|
||||
if (!activeSession) return;
|
||||
let content: string;
|
||||
switch (format) {
|
||||
case 'md': content = exportAsMarkdown(activeSession); break;
|
||||
case 'json': content = exportAsJSON(activeSession); break;
|
||||
case 'txt': content = exportAsPlainText(activeSession); break;
|
||||
}
|
||||
const filename = getExportFilename(activeSession, format);
|
||||
// Create a download blob
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// Track URL for cleanup on unmount (Issue #19)
|
||||
activeObjectUrlsRef.current.add(url);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke after a generous delay to ensure download completes, then remove from tracking set
|
||||
const revokeTimeout = setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
activeObjectUrlsRef.current.delete(url);
|
||||
}, 60_000); // 60 seconds to be safe for large files
|
||||
// If component unmounts before timeout, cleanup effect will revoke it
|
||||
void revokeTimeout; // suppress unused warning
|
||||
}, [activeSession]);
|
||||
|
||||
return {
|
||||
handleExport,
|
||||
activeObjectUrlsRef,
|
||||
};
|
||||
}
|
||||
494
components/ai/managedAgentState.test.ts
Normal file
494
components/ai/managedAgentState.test.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildManagedAgentState,
|
||||
getInitialManagedAgentPaths,
|
||||
updateCodebuddyManagedEnv,
|
||||
updateCodebuddyManagedOptions,
|
||||
} from '../settings/tabs/ai/managedAgentState';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
test('buildManagedAgentState removes stale managed agents when path detection fails', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_codex',
|
||||
name: 'Codex CLI',
|
||||
command: '/usr/local/bin/codex',
|
||||
enabled: true,
|
||||
sdkBackend: 'codex',
|
||||
},
|
||||
{
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
command: '/usr/local/bin/custom-agent',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_codex',
|
||||
'codex',
|
||||
{ path: '/usr/local/bin/codex', version: null, available: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.agents.map((agent) => agent.id),
|
||||
['custom-agent'],
|
||||
);
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState keeps unrelated defaults when removing stale managed agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_claude',
|
||||
name: 'Claude Code',
|
||||
command: '/usr/local/bin/claude',
|
||||
enabled: true,
|
||||
sdkBackend: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'custom-agent',
|
||||
name: 'Custom Agent',
|
||||
command: '/usr/local/bin/custom-agent',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'custom-agent',
|
||||
'claude',
|
||||
{ path: '/usr/local/bin/claude', version: null, available: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.agents.map((agent) => agent.id),
|
||||
['custom-agent'],
|
||||
);
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores the system Claude executable for SDK runs', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'claude',
|
||||
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
|
||||
assert.equal(state.agents[0].sdkBackend, 'claude');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores SDK backend keys for discovered managed agents', () => {
|
||||
const codexState = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'codex',
|
||||
{ path: '/opt/homebrew/bin/codex', version: '1.0.0', available: true },
|
||||
);
|
||||
const copilotState = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'copilot',
|
||||
{ path: '/opt/homebrew/bin/copilot', version: '1.0.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(codexState.agents[0].sdkBackend, 'codex');
|
||||
assert.equal(codexState.agents[0].cliVersion, '1.0.0');
|
||||
assert.equal(copilotState.agents[0].sdkBackend, 'copilot');
|
||||
assert.equal(copilotState.agents[0].acpArgs, undefined);
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves the experimental Codex runtime across path refreshes', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[{
|
||||
id: 'discovered_codex',
|
||||
name: 'Codex CLI',
|
||||
command: '/old/codex',
|
||||
enabled: true,
|
||||
sdkBackend: 'codex',
|
||||
codexRuntime: 'app-server',
|
||||
}],
|
||||
'discovered_codex',
|
||||
'codex',
|
||||
{ path: '/new/codex', version: '0.144.3', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].codexRuntime, 'app-server');
|
||||
assert.equal(state.agents[0].command, '/new/codex');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves Grok runtime across path refreshes', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[{
|
||||
id: 'discovered_grok',
|
||||
name: 'Grok Build',
|
||||
command: '/old/grok',
|
||||
enabled: true,
|
||||
sdkBackend: 'grok',
|
||||
grokRuntime: 'streaming-json',
|
||||
}],
|
||||
'discovered_grok',
|
||||
'grok',
|
||||
{ path: '/new/grok', version: '0.2.118', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].grokRuntime, 'streaming-json');
|
||||
assert.equal(state.agents[0].command, '/new/grok');
|
||||
assert.equal(state.agents[0].sdkBackend, 'grok');
|
||||
});
|
||||
|
||||
test('getInitialManagedAgentPaths ignores auto-detected command paths', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'codex',
|
||||
{ path: '/opt/homebrew/bin/codex', version: '1.0.0', available: true },
|
||||
'auto',
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].commandSource, 'auto');
|
||||
assert.equal(getInitialManagedAgentPaths(state.agents).codex, '');
|
||||
});
|
||||
|
||||
test('getInitialManagedAgentPaths keeps manual and legacy command paths', () => {
|
||||
const manualState = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'codex',
|
||||
{ path: '/opt/homebrew/bin/codex', version: '1.0.0', available: true },
|
||||
'manual',
|
||||
);
|
||||
|
||||
assert.equal(getInitialManagedAgentPaths(manualState.agents).codex, '/opt/homebrew/bin/codex');
|
||||
assert.equal(getInitialManagedAgentPaths([{
|
||||
id: 'discovered_codex',
|
||||
name: 'Codex CLI',
|
||||
command: '/legacy/bin/codex',
|
||||
enabled: true,
|
||||
available: true,
|
||||
sdkBackend: 'codex',
|
||||
}]).codex, '/legacy/bin/codex');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores SDK backend key for discovered Cursor', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'cursor',
|
||||
{ path: 'cursor', version: 'Cursor SDK 1.0.18', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].id, 'discovered_cursor');
|
||||
assert.equal(state.agents[0].name, 'Cursor');
|
||||
assert.equal(state.agents[0].command, 'cursor');
|
||||
assert.equal(state.agents[0].sdkBackend, 'cursor');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves a saved Cursor API key when SDK is not ready', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_cursor',
|
||||
name: 'Cursor',
|
||||
command: 'cursor',
|
||||
enabled: true,
|
||||
available: true,
|
||||
sdkBackend: 'cursor',
|
||||
apiKey: 'enc:v1:test',
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_cursor',
|
||||
'cursor',
|
||||
{ path: 'cursor', version: 'Cursor SDK', available: false, installed: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].id, 'discovered_cursor');
|
||||
assert.equal(state.agents[0].apiKey, 'enc:v1:test');
|
||||
// Keep enabled so a later mode/path that becomes available is not sticky-disabled.
|
||||
assert.equal(state.agents[0].enabled, true);
|
||||
assert.equal(state.agents[0].available, false);
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves enabled when CLI login probe is temporarily unavailable', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_cursor',
|
||||
name: 'Cursor',
|
||||
command: '/bin/cursor-agent',
|
||||
enabled: true,
|
||||
available: true,
|
||||
sdkBackend: 'cursor',
|
||||
cursorAuthMode: 'cli-login',
|
||||
apiKey: 'enc:v1:test',
|
||||
},
|
||||
];
|
||||
|
||||
const unavailable = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_cursor',
|
||||
'cursor',
|
||||
{
|
||||
path: 'cursor',
|
||||
version: 'Cursor Agent CLI',
|
||||
available: false,
|
||||
installed: true,
|
||||
cliLoginOk: false,
|
||||
apiKeyOk: true,
|
||||
sdkInstalled: true,
|
||||
},
|
||||
);
|
||||
assert.equal(unavailable.agents[0].enabled, true);
|
||||
assert.equal(unavailable.agents[0].available, false);
|
||||
assert.equal(unavailable.agents[0].apiKey, 'enc:v1:test');
|
||||
assert.equal(unavailable.agents[0].cursorAuthMode, 'cli-login');
|
||||
|
||||
// When CLI login returns, mode-aware available becomes true and enabled stays on.
|
||||
const recovered = buildManagedAgentState(
|
||||
unavailable.agents,
|
||||
'catty',
|
||||
'cursor',
|
||||
{
|
||||
path: '/bin/cursor-agent',
|
||||
cliBinPath: '/bin/cursor-agent',
|
||||
version: 'Cursor Agent CLI',
|
||||
available: true,
|
||||
installed: true,
|
||||
cliLoginOk: true,
|
||||
apiKeyOk: true,
|
||||
sdkInstalled: true,
|
||||
authSource: 'cli-login',
|
||||
},
|
||||
);
|
||||
assert.equal(recovered.agents[0].enabled, true);
|
||||
assert.equal(recovered.agents[0].available, true);
|
||||
assert.equal(recovered.agents[0].cursorAuthMode, 'cli-login');
|
||||
assert.equal(recovered.agents[0].command, '/bin/cursor-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState keeps API key when Cursor stays on CLI login mode', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'discovered_cursor',
|
||||
name: 'Cursor',
|
||||
command: '/bin/cursor-agent',
|
||||
enabled: true,
|
||||
available: true,
|
||||
sdkBackend: 'cursor',
|
||||
cursorAuthMode: 'cli-login',
|
||||
apiKey: 'enc:v1:keep-me',
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_cursor',
|
||||
'cursor',
|
||||
{
|
||||
path: '/bin/cursor-agent',
|
||||
cliBinPath: '/bin/cursor-agent',
|
||||
version: 'Cursor Agent CLI',
|
||||
available: true,
|
||||
installed: true,
|
||||
cliLoginOk: true,
|
||||
apiKeyOk: true,
|
||||
sdkInstalled: true,
|
||||
authSource: 'cli-login',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(state.agents[0].apiKey, 'enc:v1:keep-me');
|
||||
assert.equal(state.agents[0].cursorAuthMode, 'cli-login');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores CODEBUDDY_CODE_PATH for codebuddy', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'codebuddy',
|
||||
{ path: '/opt/homebrew/bin/codebuddy', version: '0.1.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/codebuddy');
|
||||
assert.equal(state.agents[0].sdkBackend, 'codebuddy');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CODEBUDDY_CODE_PATH: '/opt/homebrew/bin/codebuddy',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores OPENCODE_BIN for opencode', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'opencode',
|
||||
{ path: '/opt/homebrew/bin/opencode', version: '1.0.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].id, 'discovered_opencode');
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/opencode');
|
||||
assert.equal(state.agents[0].sdkBackend, 'opencode');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
OPENCODE_BIN: '/opt/homebrew/bin/opencode',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedEnv creates a disabled managed entry before CLI detection', () => {
|
||||
const state = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
|
||||
|
||||
assert.equal(state.length, 1);
|
||||
assert.equal(state[0].id, 'discovered_codebuddy');
|
||||
assert.equal(state[0].command, 'codebuddy');
|
||||
assert.equal(state[0].enabled, false);
|
||||
assert.deepEqual(state[0].env, {
|
||||
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
|
||||
CODEBUDDY_API_KEY: 'secret',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves disabled CodeBuddy config when path detection fails', () => {
|
||||
const agents = updateCodebuddyManagedEnv([], 'ioa', 'CODEBUDDY_AUTH_TOKEN=token');
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_codebuddy',
|
||||
'codebuddy',
|
||||
{ path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].id, 'discovered_codebuddy');
|
||||
assert.equal(state.agents[0].enabled, false);
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CODEBUDDY_INTERNET_ENVIRONMENT: 'ioa',
|
||||
CODEBUDDY_AUTH_TOKEN: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState enables preconfigured CodeBuddy when path detection succeeds', () => {
|
||||
const agents = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'catty',
|
||||
'codebuddy',
|
||||
{ path: '/opt/homebrew/bin/codebuddy', version: '0.1.0', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].enabled, true);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/codebuddy');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CODEBUDDY_INTERNET_ENVIRONMENT: 'internal',
|
||||
CODEBUDDY_API_KEY: 'secret',
|
||||
CODEBUDDY_CODE_PATH: '/opt/homebrew/bin/codebuddy',
|
||||
});
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedEnv removes an empty pre-detection placeholder', () => {
|
||||
const agents = updateCodebuddyManagedEnv([], 'internal', 'CODEBUDDY_API_KEY=secret');
|
||||
const cleared = updateCodebuddyManagedEnv(agents, '', '');
|
||||
|
||||
assert.deepEqual(cleared, []);
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedOptions persists settings before CLI detection', () => {
|
||||
const state = updateCodebuddyManagedOptions([], {
|
||||
effort: 'high',
|
||||
enableFileCheckpointing: true,
|
||||
});
|
||||
|
||||
assert.equal(state.length, 1);
|
||||
assert.equal(state[0].id, 'discovered_codebuddy');
|
||||
assert.equal(state[0].command, 'codebuddy');
|
||||
assert.equal(state[0].enabled, false);
|
||||
assert.deepEqual(state[0].codebuddyOptions, {
|
||||
effort: 'high',
|
||||
enableFileCheckpointing: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState preserves advanced CodeBuddy config when detection fails', () => {
|
||||
const agents = updateCodebuddyManagedOptions([], { effort: 'high' });
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'discovered_codebuddy',
|
||||
'codebuddy',
|
||||
{ path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
assert.equal(state.defaultAgentId, 'catty');
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].enabled, false);
|
||||
assert.equal(state.agents[0].available, false);
|
||||
assert.deepEqual(state.agents[0].codebuddyOptions, { effort: 'high' });
|
||||
});
|
||||
|
||||
test('updateCodebuddyManagedOptions removes an empty pre-detection placeholder', () => {
|
||||
const agents = updateCodebuddyManagedOptions([], { effort: 'high' });
|
||||
const cleared = updateCodebuddyManagedOptions(agents, undefined);
|
||||
|
||||
assert.deepEqual(cleared, []);
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'my-claude-wrapper',
|
||||
name: 'My Claude Wrapper',
|
||||
command: '/usr/local/bin/claude',
|
||||
enabled: true,
|
||||
sdkBackend: 'claude',
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'my-claude-wrapper',
|
||||
'claude',
|
||||
{ path: '/usr/local/bin/claude', version: null, available: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(state.agents, agents);
|
||||
assert.equal(state.defaultAgentId, 'my-claude-wrapper');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState only rewrites settings-managed discovered agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
id: 'my-codex-wrapper',
|
||||
name: 'My Codex Wrapper',
|
||||
command: '/usr/local/bin/codex',
|
||||
enabled: true,
|
||||
sdkBackend: 'codex',
|
||||
},
|
||||
];
|
||||
|
||||
const state = buildManagedAgentState(
|
||||
agents,
|
||||
'my-codex-wrapper',
|
||||
'codex',
|
||||
{ path: '/opt/netcatty/codex', version: 'Bundled legacy adapter', available: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
state.agents.map((agent) => agent.id),
|
||||
['my-codex-wrapper', 'discovered_codex'],
|
||||
);
|
||||
assert.equal(state.agents[0], agents[0]);
|
||||
assert.equal(state.defaultAgentId, 'my-codex-wrapper');
|
||||
});
|
||||
224
components/ai/scopedHistorySessions.test.ts
Normal file
224
components/ai/scopedHistorySessions.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
_getScopedHistoryCacheSizeForTests,
|
||||
getScopedHistorySessions,
|
||||
} from "./scopedHistorySessions.ts";
|
||||
|
||||
function createSession(
|
||||
id: string,
|
||||
scope: AISession["scope"],
|
||||
updatedAt: number,
|
||||
): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: "catty",
|
||||
scope,
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
test("workspace history remains visible after the original workspace target is gone", () => {
|
||||
const staleWorkspaceSession = createSession(
|
||||
"workspace-stale",
|
||||
{ type: "workspace", targetId: "workspace-before-restart" },
|
||||
2,
|
||||
);
|
||||
|
||||
const sessions = [
|
||||
staleWorkspaceSession,
|
||||
createSession("terminal-session", { type: "terminal", targetId: "terminal-1" }, 3),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
getScopedHistorySessions(
|
||||
sessions,
|
||||
"workspace",
|
||||
"workspace-after-restart",
|
||||
undefined,
|
||||
new Set(),
|
||||
),
|
||||
[staleWorkspaceSession],
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace history includes member-terminal chats and ranks them above stale workspaces", () => {
|
||||
const memberTerminalSession = createSession(
|
||||
"terminal-a-chat",
|
||||
{ type: "terminal", targetId: "terminal-a" },
|
||||
1,
|
||||
);
|
||||
const staleWorkspaceSession = createSession(
|
||||
"workspace-stale",
|
||||
{ type: "workspace", targetId: "workspace-before-restart" },
|
||||
99,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getScopedHistorySessions(
|
||||
[staleWorkspaceSession, memberTerminalSession],
|
||||
"workspace",
|
||||
"workspace-merged",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
new Set(["terminal-a", "terminal-b"]),
|
||||
).map((session) => session.id),
|
||||
["terminal-a-chat", "workspace-stale"],
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal history without host ids remains visible after the original terminal target is gone", () => {
|
||||
const staleLocalSession = createSession(
|
||||
"terminal-local-stale",
|
||||
{ type: "terminal", targetId: "terminal-before-restart" },
|
||||
2,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getScopedHistorySessions(
|
||||
[staleLocalSession],
|
||||
"terminal",
|
||||
"terminal-after-restart",
|
||||
undefined,
|
||||
new Set(),
|
||||
),
|
||||
[staleLocalSession],
|
||||
);
|
||||
});
|
||||
|
||||
test("scoped history orders exact, host-matched, then older same-scope sessions", () => {
|
||||
const staleSameScopeSession = createSession(
|
||||
"same-scope-stale",
|
||||
{ type: "terminal", targetId: "terminal-closed" },
|
||||
100,
|
||||
);
|
||||
const hostMatchedSession = createSession(
|
||||
"host-match",
|
||||
{ type: "terminal", targetId: "terminal-other", hostIds: ["host-a"] },
|
||||
2,
|
||||
);
|
||||
const exactSession = createSession(
|
||||
"exact",
|
||||
{ type: "terminal", targetId: "terminal-current" },
|
||||
1,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getScopedHistorySessions(
|
||||
[staleSameScopeSession, hostMatchedSession, exactSession],
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
).map((session) => session.id),
|
||||
["exact", "host-match", "same-scope-stale"],
|
||||
);
|
||||
});
|
||||
|
||||
test("same-scope fallback excludes sessions already displayed by another terminal", () => {
|
||||
const displayedElsewhere = createSession(
|
||||
"displayed-elsewhere",
|
||||
{ type: "terminal", targetId: "terminal-before-restart" },
|
||||
2,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getScopedHistorySessions(
|
||||
[displayedElsewhere],
|
||||
"terminal",
|
||||
"terminal-after-restart",
|
||||
undefined,
|
||||
new Set(["displayed-elsewhere"]),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace cache ignores unrelated active terminal churn", () => {
|
||||
const sessions = [createSession("workspace", { type: "workspace", targetId: "workspace-1" }, 1)];
|
||||
const first = getScopedHistorySessions(
|
||||
sessions,
|
||||
"workspace",
|
||||
"workspace-1",
|
||||
undefined,
|
||||
new Set(["terminal-0"]),
|
||||
);
|
||||
for (let index = 1; index < 1_000; index += 1) {
|
||||
assert.equal(getScopedHistorySessions(
|
||||
sessions,
|
||||
"workspace",
|
||||
"workspace-1",
|
||||
undefined,
|
||||
new Set([`terminal-${index}`]),
|
||||
), first);
|
||||
}
|
||||
assert.equal(_getScopedHistoryCacheSizeForTests(sessions), 1);
|
||||
});
|
||||
|
||||
test("terminal scoped history cache has a hard LRU bound", () => {
|
||||
const sessions = [createSession("terminal", { type: "terminal", targetId: "terminal-current" }, 1)];
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
getScopedHistorySessions(
|
||||
sessions,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
undefined,
|
||||
new Set([`other-session-${index}`]),
|
||||
);
|
||||
}
|
||||
assert.ok(_getScopedHistoryCacheSizeForTests(sessions) <= 64);
|
||||
});
|
||||
|
||||
test("workspace history retains chats resumed by members from older terminals", () => {
|
||||
const resumed = createSession("resumed", {
|
||||
type: "terminal", targetId: "closed-terminal", hostIds: ["host-a"],
|
||||
}, 1);
|
||||
const unrelated = createSession("unrelated", {
|
||||
type: "terminal", targetId: "another-closed-terminal", hostIds: ["host-a"],
|
||||
}, 2);
|
||||
const staleWorkspace = createSession("stale-workspace", {
|
||||
type: "workspace", targetId: "old-workspace",
|
||||
}, 99);
|
||||
const sessions = [staleWorkspace, unrelated, resumed];
|
||||
const members = new Set(["terminal-a", "terminal-b"]);
|
||||
const selected = {
|
||||
"terminal:terminal-a": "resumed",
|
||||
"terminal:terminal-outside": "unrelated",
|
||||
};
|
||||
|
||||
assert.ok(getScopedHistorySessions(
|
||||
sessions, "terminal", "terminal-a", ["host-a"], new Set(["unrelated"]),
|
||||
).includes(resumed));
|
||||
assert.deepEqual(getScopedHistorySessions(
|
||||
sessions, "workspace", "merged", ["host-a"], new Set(Object.values(selected)),
|
||||
members, selected,
|
||||
), [resumed, staleWorkspace]);
|
||||
// Returning to A makes the same stored conversation available again.
|
||||
assert.ok(getScopedHistorySessions(
|
||||
sessions, "terminal", "terminal-a", ["host-a"], new Set(["unrelated"]),
|
||||
).includes(resumed));
|
||||
assert.equal(resumed.scope.targetId, "closed-terminal");
|
||||
});
|
||||
|
||||
test("workspace history cache tracks member selections but ignores unrelated selections", () => {
|
||||
const sessions = [createSession("resumed", { type: "terminal", targetId: "closed" }, 1)];
|
||||
const members = new Set(["terminal-a"]);
|
||||
const history = (selected: Record<string, string | null>) => getScopedHistorySessions(
|
||||
sessions, "workspace", "merged", undefined, new Set(), members, selected,
|
||||
);
|
||||
const empty = history({});
|
||||
assert.deepEqual(empty, []);
|
||||
const inherited = history({ "terminal:terminal-a": "resumed" });
|
||||
assert.deepEqual(inherited, sessions);
|
||||
assert.equal(history({
|
||||
"terminal:terminal-a": "resumed", "terminal:outside": "other",
|
||||
}), inherited);
|
||||
assert.equal(history({ "terminal:terminal-a": null }), empty);
|
||||
assert.equal(history({ "workspace:unrelated": "resumed" }), empty);
|
||||
assert.equal(_getScopedHistoryCacheSizeForTests(sessions), 2);
|
||||
});
|
||||
95
components/ai/scopedHistorySessions.ts
Normal file
95
components/ai/scopedHistorySessions.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { getSessionScopeMatchRank } from './sessionScopeMatch';
|
||||
|
||||
type HistoryCacheKey = string;
|
||||
const MAX_HISTORY_CACHE_ENTRIES_PER_SESSION_LIST = 64;
|
||||
const historyCache = new WeakMap<AISession[], Map<HistoryCacheKey, AISession[]>>();
|
||||
|
||||
function buildHistoryCacheKey(
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId: string | undefined,
|
||||
scopeHostIds: string[] | undefined,
|
||||
activeTerminalSessionIds: Set<string>,
|
||||
workspaceMemberTerminalIds: Set<string> | undefined,
|
||||
workspaceMemberActiveSessionIds: Set<string>,
|
||||
): HistoryCacheKey {
|
||||
const hostKey = scopeHostIds ? [...scopeHostIds].sort().join(',') : '';
|
||||
const terminalKey = scopeType === 'terminal'
|
||||
? [...activeTerminalSessionIds].sort().join(',')
|
||||
: '';
|
||||
const memberKey = scopeType === 'workspace' && workspaceMemberTerminalIds
|
||||
? [...workspaceMemberTerminalIds].sort().join(',')
|
||||
: '';
|
||||
const memberActiveKey = [...workspaceMemberActiveSessionIds].sort().join(',');
|
||||
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}:${memberKey}:${memberActiveKey}`;
|
||||
}
|
||||
|
||||
export function getScopedHistorySessions(
|
||||
sessions: AISession[],
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId: string | undefined,
|
||||
scopeHostIds: string[] | undefined,
|
||||
activeTerminalSessionIds: Set<string>,
|
||||
workspaceMemberTerminalIds?: Set<string>,
|
||||
activeSessionIdMap?: Readonly<Record<string, string | null | undefined>>,
|
||||
): AISession[] {
|
||||
// A member can be continuing history created on an older terminal. Its
|
||||
// selected chat belongs in workspace history even though scope.targetId
|
||||
// still identifies that older terminal. Do not include nonmember selections.
|
||||
const workspaceMemberActiveSessionIds = new Set<string>();
|
||||
if (scopeType === 'workspace' && workspaceMemberTerminalIds && activeSessionIdMap) {
|
||||
for (const terminalId of workspaceMemberTerminalIds) {
|
||||
const sessionId = activeSessionIdMap[`terminal:${terminalId}`];
|
||||
if (sessionId) workspaceMemberActiveSessionIds.add(sessionId);
|
||||
}
|
||||
}
|
||||
let scopeCache = historyCache.get(sessions);
|
||||
if (!scopeCache) {
|
||||
scopeCache = new Map();
|
||||
historyCache.set(sessions, scopeCache);
|
||||
}
|
||||
|
||||
const cacheKey = buildHistoryCacheKey(
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
workspaceMemberActiveSessionIds,
|
||||
);
|
||||
const cached = scopeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
scopeCache.delete(cacheKey);
|
||||
scopeCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const result = sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(
|
||||
session,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
workspaceMemberTerminalIds,
|
||||
workspaceMemberActiveSessionIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session);
|
||||
|
||||
scopeCache.set(cacheKey, result);
|
||||
while (scopeCache.size > MAX_HISTORY_CACHE_ENTRIES_PER_SESSION_LIST) {
|
||||
const oldestKey = scopeCache.keys().next().value;
|
||||
if (oldestKey == null) break;
|
||||
scopeCache.delete(oldestKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function _getScopedHistoryCacheSizeForTests(sessions: AISession[]): number {
|
||||
return historyCache.get(sessions)?.size ?? 0;
|
||||
}
|
||||
15
components/ai/sessionHistoryLayout.test.ts
Normal file
15
components/ai/sessionHistoryLayout.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_HISTORY_ROW_CLASSNAMES,
|
||||
} from "./sessionHistoryLayout.ts";
|
||||
|
||||
test("session history row keeps metadata pinned to the end while title truncates", () => {
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
|
||||
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
|
||||
});
|
||||
7
components/ai/sessionHistoryLayout.ts
Normal file
7
components/ai/sessionHistoryLayout.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SESSION_HISTORY_ROW_CLASSNAMES = {
|
||||
row: 'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
title: 'text-[13px] truncate min-w-0',
|
||||
meta: 'flex items-center gap-2 justify-self-end shrink-0',
|
||||
time: 'text-[12px] text-muted-foreground/50 whitespace-nowrap',
|
||||
deleteButton: 'opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer shrink-0',
|
||||
} as const;
|
||||
148
components/ai/sessionScopeMatch.test.ts
Normal file
148
components/ai/sessionScopeMatch.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||
import { getSessionScopeMatchRank } from "./sessionScopeMatch.ts";
|
||||
|
||||
function createSession(id: string, targetId: string, hostIds: string[]): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId,
|
||||
hostIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("host-matched terminal session is excluded when another active terminal already displays it", () => {
|
||||
const session = createSession("session-1", "terminal-other", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("host-matched terminal session remains resumable when no terminal is displaying it", () => {
|
||||
const session = createSession("session-1", "terminal-closed", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-other"]),
|
||||
),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("host-mismatched terminal session is not resumable for the current terminal", () => {
|
||||
const session = createSession("session-1", "terminal-closed", ["host-b"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("ownership is tracked by session id, not scope.targetId", () => {
|
||||
// Session was created in terminal-A but a different terminal (B) is now
|
||||
// displaying it after the user resumed it from history. Opening a third
|
||||
// terminal (C) should not see this session as owned, because the new
|
||||
// ownership check is keyed on session id, not the stale targetId.
|
||||
const session = createSession("session-1", "terminal-A", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-C",
|
||||
["host-a"],
|
||||
// terminal-B is displaying session-1; pass session-1 as an
|
||||
// active-id so C sees it as in-use
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("session targeting the current scope is an exact match (rank 3)", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test("scope type mismatch returns 0 regardless of target or hosts", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace scope treats member-terminal chats as exact matches after merge", () => {
|
||||
const session = createSession("session-1", "terminal-a", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"ws-1",
|
||||
["host-a"],
|
||||
undefined,
|
||||
new Set(["terminal-a", "terminal-b"]),
|
||||
),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace scope ignores terminal chats that are not workspace members", () => {
|
||||
const session = createSession("session-1", "terminal-other", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"ws-1",
|
||||
["host-a"],
|
||||
undefined,
|
||||
new Set(["terminal-a", "terminal-b"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
50
components/ai/sessionScopeMatch.ts
Normal file
50
components/ai/sessionScopeMatch.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AISession } from "../../infrastructure/ai/types";
|
||||
|
||||
export function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: "terminal" | "workspace",
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
/**
|
||||
* Session ids currently displayed by other terminal scopes. Tracked by
|
||||
* session id rather than `scope.targetId` so that a host-matched session
|
||||
* resumed from a different terminal is still recognised as in-use and
|
||||
* not offered (or cleaned) as if it were orphaned.
|
||||
*/
|
||||
activeTerminalSessionIds?: Set<string>,
|
||||
/**
|
||||
* Terminal session ids currently living in this workspace. When terminals
|
||||
* merge, their AI chats stay `scope.type === "terminal"`; treating those
|
||||
* member chats as exact workspace matches keeps history/resume working.
|
||||
*/
|
||||
workspaceMemberTerminalIds?: Set<string>,
|
||||
/** Chats currently selected by workspace members, including resumed history. */
|
||||
workspaceMemberActiveSessionIds?: Set<string>,
|
||||
): number {
|
||||
// After a terminal merge the AI panel flips to workspace scope, but chats
|
||||
// created or resumed on member terminals remain terminal-scoped. Rank them as
|
||||
// exact matches so they stay visible and preferred over stale workspaces.
|
||||
if (
|
||||
scopeType === "workspace"
|
||||
&& session.scope.type === "terminal"
|
||||
&& (
|
||||
(session.scope.targetId && workspaceMemberTerminalIds?.has(session.scope.targetId))
|
||||
|| workspaceMemberActiveSessionIds?.has(session.id)
|
||||
)
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 3;
|
||||
|
||||
if (scopeType === "terminal" && activeTerminalSessionIds?.has(session.id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (scopeType === "terminal" && scopeHostIds?.length && session.scope.hostIds?.length) {
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 2 : 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
91
components/ai/streamdownCodeHighlighter.test.ts
Normal file
91
components/ai/streamdownCodeHighlighter.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type {
|
||||
CodeHighlighterPlugin,
|
||||
HighlightOptions,
|
||||
} from 'streamdown';
|
||||
import {
|
||||
createPlainCodeHighlightResult,
|
||||
createSafeCodeHighlighter,
|
||||
resolveSupportedCodeLanguage,
|
||||
} from '../ai-elements/streamdownCodeHighlighter';
|
||||
|
||||
type HighlightResult = NonNullable<ReturnType<CodeHighlighterPlugin['highlight']>>;
|
||||
|
||||
const createFakeHighlighter = (
|
||||
supportedLanguages: string[],
|
||||
highlightImpl?: CodeHighlighterPlugin['highlight'],
|
||||
): CodeHighlighterPlugin => ({
|
||||
name: 'shiki',
|
||||
type: 'code-highlighter',
|
||||
getSupportedLanguages: () => supportedLanguages as ReturnType<CodeHighlighterPlugin['getSupportedLanguages']>,
|
||||
getThemes: () => ['github-light', 'github-dark'],
|
||||
supportsLanguage: (language) => supportedLanguages.includes(language),
|
||||
highlight: highlightImpl ?? ((options: HighlightOptions): HighlightResult => ({
|
||||
tokens: [[{ content: options.language, offset: 0 }]],
|
||||
})),
|
||||
});
|
||||
|
||||
test('maps generic conf fences to ini for Streamdown highlighting', () => {
|
||||
const highlighter = createFakeHighlighter(['ini']);
|
||||
|
||||
assert.equal(resolveSupportedCodeLanguage(highlighter, 'conf'), 'ini');
|
||||
assert.equal(resolveSupportedCodeLanguage(highlighter, ' config '), 'ini');
|
||||
});
|
||||
|
||||
test('falls back to plain tokens for unsupported languages', () => {
|
||||
const highlighter = createSafeCodeHighlighter(
|
||||
createFakeHighlighter([], () => {
|
||||
throw new Error('delegate should not be called for unsupported languages');
|
||||
}),
|
||||
);
|
||||
|
||||
const result = highlighter.highlight({
|
||||
code: '*.* action(type="omfwd"\n Target="10.185.3.1")\n',
|
||||
language: 'conf',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result?.tokens.map((line) => line.map((token) => token.content).join('')),
|
||||
['*.* action(type="omfwd"', ' Target="10.185.3.1")'],
|
||||
);
|
||||
});
|
||||
|
||||
test('uses supported aliases when highlighting generic config blocks', () => {
|
||||
let receivedLanguage: string | null = null;
|
||||
const highlighter = createSafeCodeHighlighter(
|
||||
createFakeHighlighter(['ini'], (options: HighlightOptions): HighlightResult => {
|
||||
receivedLanguage = options.language;
|
||||
return createPlainCodeHighlightResult(options.code);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = highlighter.highlight({
|
||||
code: '*.* action(type="omfwd")',
|
||||
language: 'conf',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
});
|
||||
|
||||
assert.equal(receivedLanguage, 'ini');
|
||||
assert.equal(result?.tokens[0][0].content, '*.* action(type="omfwd")');
|
||||
});
|
||||
|
||||
test('treats text fences as plain code without calling the delegate', () => {
|
||||
const highlighter = createSafeCodeHighlighter(
|
||||
createFakeHighlighter(['ini'], () => {
|
||||
throw new Error('delegate should not be called for text fences');
|
||||
}),
|
||||
);
|
||||
|
||||
const result = highlighter.highlight({
|
||||
code: 'hello\nworld',
|
||||
language: 'text',
|
||||
themes: ['github-light', 'github-dark'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result?.tokens.map((line) => line[0].content),
|
||||
['hello', 'world'],
|
||||
);
|
||||
});
|
||||
45
components/ai/toolArtifacts/TerminalArtifactCard.test.tsx
Normal file
45
components/ai/toolArtifacts/TerminalArtifactCard.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { TerminalArtifactCard } from './TerminalArtifactCard.tsx';
|
||||
|
||||
test('TerminalArtifactCard renders terminal context summary', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TerminalArtifactCard
|
||||
artifact={{
|
||||
kind: 'terminal.context',
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
totalLines: 120,
|
||||
startLine: 100,
|
||||
endLine: 102,
|
||||
returnedLines: 3,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: false,
|
||||
source: 'live',
|
||||
preview: 'alpha\nbeta\ngamma',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /prod/);
|
||||
assert.match(html, /lines 101-103 \/ 120/);
|
||||
assert.doesNotMatch(html, /alpha/);
|
||||
});
|
||||
|
||||
test('TerminalArtifactCard renders terminal read errors', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TerminalArtifactCard
|
||||
artifact={{
|
||||
kind: 'error',
|
||||
message: 'Terminal context reader is unavailable.',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /Terminal read failed/);
|
||||
assert.match(html, /Terminal context reader is unavailable/);
|
||||
});
|
||||
73
components/ai/toolArtifacts/TerminalArtifactCard.tsx
Normal file
73
components/ai/toolArtifacts/TerminalArtifactCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AlertCircle, SquareTerminal } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { TerminalToolArtifact } from './terminalToolArtifact';
|
||||
|
||||
interface TerminalArtifactCardProps {
|
||||
artifact: TerminalToolArtifact;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatLineRange(artifact: Extract<TerminalToolArtifact, { kind: 'terminal.context' }>): string {
|
||||
if (artifact.returnedLines === 0) {
|
||||
return `0 / ${artifact.totalLines} lines`;
|
||||
}
|
||||
return `lines ${artifact.startLine + 1}-${artifact.endLine + 1} / ${artifact.totalLines}`;
|
||||
}
|
||||
|
||||
function formatSubtitle(artifact: Extract<TerminalToolArtifact, { kind: 'terminal.context' }>): string {
|
||||
const parts = [
|
||||
formatLineRange(artifact),
|
||||
artifact.source,
|
||||
artifact.hasMoreBefore || artifact.hasMoreAfter ? 'more available' : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
export const TerminalArtifactCard = React.forwardRef<HTMLDivElement, TerminalArtifactCardProps>(({
|
||||
artifact,
|
||||
className,
|
||||
}, ref) => {
|
||||
if (artifact.kind === 'error') {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-2',
|
||||
className,
|
||||
)} ref={ref}>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-destructive/10 text-destructive">
|
||||
<AlertCircle size={15} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-[12px] font-medium text-foreground/85">
|
||||
Terminal read failed
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground/60">
|
||||
{artifact.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = artifact.label || artifact.sessionId;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md border border-border/25 bg-muted/10 px-2.5 py-2',
|
||||
className,
|
||||
)} ref={ref}>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-500">
|
||||
<SquareTerminal size={15} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-[12px] font-medium text-foreground/85">
|
||||
{title}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground/60">
|
||||
{formatSubtitle(artifact)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TerminalArtifactCard.displayName = 'TerminalArtifactCard';
|
||||
38
components/ai/toolArtifacts/TerminalArtifactToolResult.tsx
Normal file
38
components/ai/toolArtifacts/TerminalArtifactToolResult.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
||||
import { formatTerminalToolTooltip } from './formatTerminalToolTooltip';
|
||||
import { TerminalArtifactCard } from './TerminalArtifactCard';
|
||||
import type { TerminalToolArtifact } from './terminalToolArtifact';
|
||||
|
||||
interface TerminalArtifactToolResultProps {
|
||||
artifact: TerminalToolArtifact;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalArtifactToolResult: React.FC<TerminalArtifactToolResultProps> = ({
|
||||
artifact,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
const tooltip = formatTerminalToolTooltip(toolName, args, result, isError);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TerminalArtifactCard artifact={artifact} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-md whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
58
components/ai/toolArtifacts/VaultArtifactCard.test.tsx
Normal file
58
components/ai/toolArtifacts/VaultArtifactCard.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider.tsx';
|
||||
import { VaultArtifactCard } from './VaultArtifactCard.tsx';
|
||||
|
||||
test('VaultArtifactCard renders note artifact title', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<VaultArtifactCard
|
||||
artifact={{
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
group: 'ops/prod',
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /Runbook/);
|
||||
assert.match(html, /ops\/prod/);
|
||||
});
|
||||
|
||||
test('VaultArtifactCard does not render a clickable note without navigation wiring', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<VaultArtifactCard
|
||||
artifact={{
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(html, /<button/);
|
||||
});
|
||||
|
||||
test('VaultArtifactCard renders host batch summary', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<VaultArtifactCard
|
||||
artifact={{
|
||||
kind: 'vault.hosts.batch',
|
||||
addedCount: 2,
|
||||
preview: [{ label: 'Web', hostname: '10.0.0.1' }],
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(html, /Added 2 hosts/);
|
||||
assert.match(html, /Web/);
|
||||
});
|
||||
211
components/ai/toolArtifacts/VaultArtifactCard.tsx
Normal file
211
components/ai/toolArtifacts/VaultArtifactCard.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { VaultToolArtifact } from './vaultToolArtifact';
|
||||
import {
|
||||
canNavigateVaultArtifact,
|
||||
navigateVaultArtifact,
|
||||
useVaultArtifactNavigation,
|
||||
} from './VaultArtifactNavigationContext';
|
||||
import { VaultArtifactIcon } from './vaultArtifactPresentation';
|
||||
|
||||
interface VaultArtifactCardProps {
|
||||
artifact: VaultToolArtifact;
|
||||
toolName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getArtifactPresentation(
|
||||
artifact: VaultToolArtifact,
|
||||
t: (key: string, values?: Record<string, unknown>) => string,
|
||||
): {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
clickable: boolean;
|
||||
} {
|
||||
switch (artifact.kind) {
|
||||
case 'vault.note':
|
||||
return {
|
||||
title: artifact.title,
|
||||
subtitle: artifact.group ?? t('ai.chat.artifact.noteFallback'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.host':
|
||||
return {
|
||||
title: artifact.label,
|
||||
subtitle: artifact.port
|
||||
? `${artifact.hostname}:${artifact.port}`
|
||||
: artifact.hostname,
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.hosts.batch': {
|
||||
const preview = artifact.preview
|
||||
.slice(0, 2)
|
||||
.map((host) => host.label || host.hostname)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return {
|
||||
title: artifact.dryRun
|
||||
? t('ai.chat.artifact.hostsPreview', { count: artifact.addedCount })
|
||||
: t('ai.chat.artifact.hostsAdded', { count: artifact.addedCount }),
|
||||
subtitle: preview || t('ai.chat.artifact.openHosts'),
|
||||
clickable: true,
|
||||
};
|
||||
}
|
||||
case 'vault.summary':
|
||||
return {
|
||||
title: artifact.section === 'notes'
|
||||
? t('ai.chat.artifact.notesSummary', { count: artifact.count })
|
||||
: artifact.section === 'hosts'
|
||||
? t('ai.chat.artifact.hostsSummary', { count: artifact.count })
|
||||
: artifact.section === 'snippets'
|
||||
? t('ai.chat.artifact.snippetsSummary', { count: artifact.count })
|
||||
: t('ai.chat.artifact.scriptsSummary', { count: artifact.count }),
|
||||
subtitle: artifact.section === 'notes'
|
||||
? t('ai.chat.artifact.openNotes')
|
||||
: artifact.section === 'hosts'
|
||||
? t('ai.chat.artifact.openHosts')
|
||||
: t('ai.chat.artifact.openSnippets'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.snippet':
|
||||
return {
|
||||
title: artifact.label,
|
||||
subtitle: artifact.package || t('ai.chat.artifact.snippetFallback'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script':
|
||||
return {
|
||||
title: artifact.label,
|
||||
subtitle: artifact.language
|
||||
? t('ai.chat.artifact.scriptLanguage', { language: artifact.language })
|
||||
: artifact.package || t('ai.chat.artifact.scriptFallback'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.snippet.deleted':
|
||||
return {
|
||||
title: t('ai.chat.artifact.snippetDeleted'),
|
||||
subtitle: artifact.snippetId,
|
||||
clickable: false,
|
||||
};
|
||||
case 'vault.script.deleted':
|
||||
return {
|
||||
title: t('ai.chat.artifact.scriptDeleted'),
|
||||
subtitle: artifact.scriptId,
|
||||
clickable: false,
|
||||
};
|
||||
case 'vault.snippet.run':
|
||||
return {
|
||||
title: t('ai.chat.artifact.snippetRan'),
|
||||
subtitle: artifact.command || artifact.snippetId,
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script.run':
|
||||
return {
|
||||
title: artifact.status
|
||||
? t('ai.chat.artifact.scriptRunStatus', { status: artifact.status })
|
||||
: t('ai.chat.artifact.scriptStarted'),
|
||||
subtitle: artifact.runId,
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script.runs':
|
||||
return {
|
||||
title: t('ai.chat.artifact.scriptRunsSummary', { count: artifact.count }),
|
||||
subtitle: t('ai.chat.artifact.openSnippets'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'vault.script.action':
|
||||
return {
|
||||
title: artifact.action === 'stop'
|
||||
? t('ai.chat.artifact.scriptRunStopped')
|
||||
: artifact.action === 'pause'
|
||||
? t('ai.chat.artifact.scriptRunPaused')
|
||||
: t('ai.chat.artifact.scriptRunResumed'),
|
||||
subtitle: artifact.runId,
|
||||
clickable: false,
|
||||
};
|
||||
case 'vault.script.reference':
|
||||
return {
|
||||
title: t('ai.chat.artifact.scriptReference'),
|
||||
subtitle: t('ai.chat.artifact.openSnippets'),
|
||||
clickable: true,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
title: t('ai.chat.artifact.failed'),
|
||||
subtitle: artifact.message,
|
||||
clickable: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: '',
|
||||
clickable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VaultArtifactCard: React.FC<VaultArtifactCardProps> = ({
|
||||
artifact,
|
||||
toolName,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const navigation = useVaultArtifactNavigation();
|
||||
const presentation = getArtifactPresentation(artifact, t);
|
||||
const canNavigate = presentation.clickable && canNavigateVaultArtifact(artifact, navigation);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!canNavigate || !navigation) return;
|
||||
navigateVaultArtifact(artifact, navigation);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<VaultArtifactIcon artifact={artifact} toolName={toolName} />
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-[12px] font-medium text-foreground/85">
|
||||
{presentation.title}
|
||||
</div>
|
||||
{presentation.subtitle && (
|
||||
<div className="truncate text-[11px] text-muted-foreground/60">
|
||||
{presentation.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canNavigate && (
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!canNavigate) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
presentation.clickable
|
||||
? 'flex w-full items-center gap-2.5 rounded-md border border-border/25 bg-muted/10 px-2.5 py-2 text-left'
|
||||
: 'flex items-center gap-2 px-1 py-0.5',
|
||||
className,
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 rounded-md border border-border/25 bg-muted/10 px-2.5 py-2',
|
||||
'text-left transition-colors hover:bg-muted/20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createVaultArtifactNavigationActions,
|
||||
navigateVaultArtifact,
|
||||
} from './VaultArtifactNavigationContext.tsx';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
test('clicking an existing note artifact opens that note', () => {
|
||||
const openedNotes: string[] = [];
|
||||
const unavailable: Array<{ title: string; message: string }> = [];
|
||||
const navigation = createVaultArtifactNavigationActions({
|
||||
notes: [{ id: 'note-1', title: 'Runbook', content: '', createdAt: 1, updatedAt: 1 }],
|
||||
hosts: [],
|
||||
snippets: [],
|
||||
t,
|
||||
onOpenVaultNote: (noteId) => openedNotes.push(noteId),
|
||||
onOpenVaultHost: () => {},
|
||||
onOpenVaultSection: () => {},
|
||||
onUnavailable: (message, title) => unavailable.push({ title, message }),
|
||||
});
|
||||
|
||||
navigateVaultArtifact({
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
}, navigation);
|
||||
|
||||
assert.deepEqual(openedNotes, ['note-1']);
|
||||
assert.deepEqual(unavailable, []);
|
||||
});
|
||||
|
||||
test('clicking an existing snippet artifact opens that snippet', () => {
|
||||
const openedSnippets: string[] = [];
|
||||
const navigation = createVaultArtifactNavigationActions({
|
||||
notes: [],
|
||||
hosts: [],
|
||||
snippets: [{ id: 'snippet-1', label: 'Restart nginx', command: 'sudo systemctl restart nginx' }],
|
||||
t,
|
||||
onOpenVaultSnippet: (snippetId) => openedSnippets.push(snippetId),
|
||||
onUnavailable: (message, title) => { void message; void title; },
|
||||
});
|
||||
|
||||
navigateVaultArtifact({
|
||||
kind: 'vault.snippet',
|
||||
snippetId: 'snippet-1',
|
||||
label: 'Restart nginx',
|
||||
}, navigation);
|
||||
|
||||
assert.deepEqual(openedSnippets, ['snippet-1']);
|
||||
});
|
||||
|
||||
test('clicking a missing note artifact shows an unavailable message instead of opening', () => {
|
||||
const openedNotes: string[] = [];
|
||||
const unavailable: Array<{ title: string; message: string }> = [];
|
||||
const navigation = createVaultArtifactNavigationActions({
|
||||
notes: [],
|
||||
hosts: [],
|
||||
snippets: [],
|
||||
t,
|
||||
onOpenVaultNote: (noteId) => openedNotes.push(noteId),
|
||||
onOpenVaultHost: () => {},
|
||||
onOpenVaultSection: () => {},
|
||||
onUnavailable: (message, title) => unavailable.push({ title, message }),
|
||||
});
|
||||
|
||||
navigateVaultArtifact({
|
||||
kind: 'vault.note',
|
||||
noteId: 'deleted-note',
|
||||
title: 'Deleted note',
|
||||
}, navigation);
|
||||
|
||||
assert.deepEqual(openedNotes, []);
|
||||
assert.deepEqual(unavailable, [{
|
||||
title: 'ai.chat.artifact.unavailableTitle',
|
||||
message: 'ai.chat.artifact.noteMissing',
|
||||
}]);
|
||||
});
|
||||
206
components/ai/toolArtifacts/VaultArtifactNavigationContext.tsx
Normal file
206
components/ai/toolArtifacts/VaultArtifactNavigationContext.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo } from 'react';
|
||||
import type { Host, Snippet, VaultNote } from '../../../types';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { toast } from '../../ui/toast';
|
||||
import type { VaultSummarySection, VaultToolArtifact } from './vaultToolArtifact';
|
||||
|
||||
export type VaultArtifactNavSection = Extract<VaultSummarySection, 'notes' | 'hosts' | 'snippets'>;
|
||||
|
||||
export interface VaultArtifactNavigationActions {
|
||||
openVaultNote?: (noteId: string) => void;
|
||||
openVaultHost?: (hostId: string) => void;
|
||||
openVaultSnippet?: (snippetId: string) => void;
|
||||
openVaultSection?: (section: VaultArtifactNavSection) => void;
|
||||
}
|
||||
|
||||
interface CreateVaultArtifactNavigationActionsOptions {
|
||||
notes: VaultNote[];
|
||||
hosts: Host[];
|
||||
snippets: Snippet[];
|
||||
t: (key: string) => string;
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSnippet?: (snippetId: string) => void;
|
||||
onOpenVaultSection?: (section: VaultArtifactNavSection) => void;
|
||||
onUnavailable: (message: string, title: string) => void;
|
||||
}
|
||||
|
||||
interface VaultArtifactNavigationProviderProps {
|
||||
notes: VaultNote[];
|
||||
hosts: Host[];
|
||||
snippets?: Snippet[];
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSnippet?: (snippetId: string) => void;
|
||||
onOpenVaultSection?: (section: VaultArtifactNavSection) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const VaultArtifactNavigationContext = createContext<VaultArtifactNavigationActions | null>(null);
|
||||
|
||||
export function createVaultArtifactNavigationActions({
|
||||
notes,
|
||||
hosts,
|
||||
snippets,
|
||||
t,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
onUnavailable,
|
||||
}: CreateVaultArtifactNavigationActionsOptions): VaultArtifactNavigationActions {
|
||||
const actions: VaultArtifactNavigationActions = {};
|
||||
|
||||
if (onOpenVaultNote) {
|
||||
actions.openVaultNote = (noteId: string) => {
|
||||
const exists = notes.some((note) => note.id === noteId);
|
||||
if (!exists) {
|
||||
onUnavailable(t('ai.chat.artifact.noteMissing'), t('ai.chat.artifact.unavailableTitle'));
|
||||
return;
|
||||
}
|
||||
onOpenVaultNote(noteId);
|
||||
};
|
||||
}
|
||||
|
||||
if (onOpenVaultHost) {
|
||||
actions.openVaultHost = (hostId: string) => {
|
||||
const exists = hosts.some((host) => host.id === hostId);
|
||||
if (!exists) {
|
||||
onUnavailable(t('ai.chat.artifact.hostMissing'), t('ai.chat.artifact.unavailableTitle'));
|
||||
return;
|
||||
}
|
||||
onOpenVaultHost(hostId);
|
||||
};
|
||||
}
|
||||
|
||||
if (onOpenVaultSnippet) {
|
||||
actions.openVaultSnippet = (snippetId: string) => {
|
||||
const exists = snippets.some((snippet) => snippet.id === snippetId);
|
||||
if (!exists) {
|
||||
onUnavailable(t('ai.chat.artifact.snippetMissing'), t('ai.chat.artifact.unavailableTitle'));
|
||||
return;
|
||||
}
|
||||
onOpenVaultSnippet(snippetId);
|
||||
};
|
||||
}
|
||||
|
||||
if (onOpenVaultSection) {
|
||||
actions.openVaultSection = onOpenVaultSection;
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function VaultArtifactNavigationProvider({
|
||||
notes,
|
||||
hosts,
|
||||
snippets = [],
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
children,
|
||||
}: VaultArtifactNavigationProviderProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const onUnavailable = useCallback((message: string, title: string) => {
|
||||
toast.warning(message, title);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<VaultArtifactNavigationActions>(() => createVaultArtifactNavigationActions({
|
||||
notes,
|
||||
hosts,
|
||||
snippets,
|
||||
t,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSnippet,
|
||||
onOpenVaultSection,
|
||||
onUnavailable,
|
||||
}), [
|
||||
hosts,
|
||||
notes,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultNote,
|
||||
onOpenVaultSection,
|
||||
onOpenVaultSnippet,
|
||||
onUnavailable,
|
||||
snippets,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VaultArtifactNavigationContext.Provider value={value}>
|
||||
{children}
|
||||
</VaultArtifactNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVaultArtifactNavigation(): VaultArtifactNavigationActions | null {
|
||||
return useContext(VaultArtifactNavigationContext);
|
||||
}
|
||||
|
||||
export function navigateVaultArtifact(
|
||||
artifact: VaultToolArtifact,
|
||||
navigation: VaultArtifactNavigationActions,
|
||||
): void {
|
||||
switch (artifact.kind) {
|
||||
case 'vault.note':
|
||||
navigation.openVaultNote?.(artifact.noteId);
|
||||
break;
|
||||
case 'vault.host':
|
||||
navigation.openVaultHost?.(artifact.hostId);
|
||||
break;
|
||||
case 'vault.hosts.batch':
|
||||
navigation.openVaultSection?.('hosts');
|
||||
break;
|
||||
case 'vault.summary':
|
||||
if (artifact.section === 'scripts') {
|
||||
navigation.openVaultSection?.('snippets');
|
||||
} else {
|
||||
navigation.openVaultSection?.(artifact.section);
|
||||
}
|
||||
break;
|
||||
case 'vault.snippet':
|
||||
case 'vault.script':
|
||||
navigation.openVaultSnippet?.(artifact.kind === 'vault.snippet' ? artifact.snippetId : artifact.scriptId);
|
||||
break;
|
||||
case 'vault.snippet.run':
|
||||
navigation.openVaultSnippet?.(artifact.snippetId);
|
||||
break;
|
||||
case 'vault.script.run':
|
||||
navigation.openVaultSnippet?.(artifact.scriptId);
|
||||
break;
|
||||
case 'vault.script.reference':
|
||||
case 'vault.script.runs':
|
||||
navigation.openVaultSection?.('snippets');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function canNavigateVaultArtifact(
|
||||
artifact: VaultToolArtifact,
|
||||
navigation: VaultArtifactNavigationActions | null,
|
||||
): boolean {
|
||||
if (!navigation) return false;
|
||||
switch (artifact.kind) {
|
||||
case 'vault.note':
|
||||
return Boolean(navigation.openVaultNote);
|
||||
case 'vault.host':
|
||||
return Boolean(navigation.openVaultHost);
|
||||
case 'vault.hosts.batch':
|
||||
case 'vault.summary':
|
||||
case 'vault.script.reference':
|
||||
case 'vault.script.runs':
|
||||
return Boolean(navigation.openVaultSection);
|
||||
case 'vault.snippet':
|
||||
case 'vault.script':
|
||||
case 'vault.snippet.run':
|
||||
case 'vault.script.run':
|
||||
return Boolean(navigation.openVaultSnippet);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
38
components/ai/toolArtifacts/VaultArtifactToolResult.tsx
Normal file
38
components/ai/toolArtifacts/VaultArtifactToolResult.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
|
||||
import { VaultArtifactCard } from './VaultArtifactCard';
|
||||
import { formatVaultToolTooltip } from './formatVaultToolTooltip';
|
||||
import type { VaultToolArtifact } from './vaultToolArtifact';
|
||||
|
||||
interface VaultArtifactToolResultProps {
|
||||
artifact: VaultToolArtifact;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const VaultArtifactToolResult: React.FC<VaultArtifactToolResultProps> = ({
|
||||
artifact,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
const tooltip = formatVaultToolTooltip(toolName, args, result, isError);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<VaultArtifactCard artifact={artifact} toolName={toolName} className="cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-md whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
25
components/ai/toolArtifacts/formatTerminalToolTooltip.ts
Normal file
25
components/ai/toolArtifacts/formatTerminalToolTooltip.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
function serialize(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTerminalToolTooltip(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
result?: unknown,
|
||||
isError?: boolean,
|
||||
): string {
|
||||
const sections = [
|
||||
`Tool: ${toolName}`,
|
||||
args ? `Args:\n${serialize(args)}` : null,
|
||||
result ? `Result:\n${serialize(result)}` : null,
|
||||
isError ? 'Status: error' : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
18
components/ai/toolArtifacts/formatVaultToolTooltip.test.ts
Normal file
18
components/ai/toolArtifacts/formatVaultToolTooltip.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatVaultToolTooltip } from './formatVaultToolTooltip.ts';
|
||||
|
||||
test('formatVaultToolTooltip joins tool name, args, and result with line breaks', () => {
|
||||
const text = formatVaultToolTooltip(
|
||||
'vault_notes_create',
|
||||
{ title: 'Runbook' },
|
||||
{ ok: true, note: { id: 'n1', title: 'Runbook' } },
|
||||
);
|
||||
|
||||
assert.match(text, /^vault_notes_create/);
|
||||
assert.match(text, /Arguments:/);
|
||||
assert.match(text, /"title": "Runbook"/);
|
||||
assert.match(text, /Result:/);
|
||||
assert.match(text, /"ok": true/);
|
||||
});
|
||||
37
components/ai/toolArtifacts/formatVaultToolTooltip.ts
Normal file
37
components/ai/toolArtifacts/formatVaultToolTooltip.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
function stringifyToolPayload(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatVaultToolTooltip(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
result?: unknown,
|
||||
isError?: boolean,
|
||||
): string {
|
||||
const lines: string[] = [toolName];
|
||||
|
||||
if (args && Object.keys(args).length > 0) {
|
||||
lines.push('', 'Arguments:', stringifyToolPayload(args));
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
lines.push('', isError ? 'Error:' : 'Result:', stringifyToolPayload(result));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
82
components/ai/toolArtifacts/terminalToolArtifact.test.ts
Normal file
82
components/ai/toolArtifacts/terminalToolArtifact.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parseTerminalToolArtifact } from './terminalToolArtifact.ts';
|
||||
|
||||
test('parseTerminalToolArtifact maps terminal context reads', () => {
|
||||
const artifact = parseTerminalToolArtifact('terminal_read_context', {
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
content: 'alpha\nbeta\ngamma',
|
||||
totalLines: 120,
|
||||
startLine: 100,
|
||||
endLine: 102,
|
||||
returnedLines: 3,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: true,
|
||||
source: 'live',
|
||||
});
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'terminal.context',
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
totalLines: 120,
|
||||
startLine: 100,
|
||||
endLine: 102,
|
||||
returnedLines: 3,
|
||||
hasMoreBefore: true,
|
||||
hasMoreAfter: true,
|
||||
source: 'live',
|
||||
preview: 'alpha\nbeta\ngamma',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseTerminalToolArtifact maps errors', () => {
|
||||
const artifact = parseTerminalToolArtifact('terminal_read_context', {
|
||||
ok: false,
|
||||
error: 'Terminal session not found.',
|
||||
});
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'error',
|
||||
message: 'Terminal session not found.',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseTerminalToolArtifact unwraps Claude MCP text result envelopes', () => {
|
||||
const artifact = parseTerminalToolArtifact('mcp__netcatty-remote-hosts__terminal_read_context', JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
content: 'alpha\nbeta',
|
||||
totalLines: 20,
|
||||
startLine: 19,
|
||||
endLine: 20,
|
||||
returnedLines: 2,
|
||||
}),
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'terminal.context',
|
||||
sessionId: 'session-1',
|
||||
label: 'prod',
|
||||
range: 'tail',
|
||||
totalLines: 20,
|
||||
startLine: 19,
|
||||
endLine: 20,
|
||||
returnedLines: 2,
|
||||
hasMoreBefore: false,
|
||||
hasMoreAfter: false,
|
||||
source: undefined,
|
||||
preview: 'alpha\nbeta',
|
||||
});
|
||||
});
|
||||
81
components/ai/toolArtifacts/terminalToolArtifact.ts
Normal file
81
components/ai/toolArtifacts/terminalToolArtifact.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { normalizeArtifactToolName } from './toolArtifactNames';
|
||||
import { parseResultPayload } from './toolArtifactResultPayload';
|
||||
|
||||
export type TerminalToolArtifact =
|
||||
| {
|
||||
kind: 'terminal.context';
|
||||
sessionId: string;
|
||||
label?: string;
|
||||
range: string;
|
||||
totalLines: number;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
returnedLines: number;
|
||||
hasMoreBefore: boolean;
|
||||
hasMoreAfter: boolean;
|
||||
source?: string;
|
||||
preview: string;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const TERMINAL_ARTIFACT_TOOL_NAMES = new Set([
|
||||
'terminal_read_context',
|
||||
]);
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
export function parseTerminalToolArtifact(
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
): TerminalToolArtifact | null {
|
||||
const normalizedToolName = normalizeArtifactToolName(toolName);
|
||||
if (!normalizedToolName || !TERMINAL_ARTIFACT_TOOL_NAMES.has(normalizedToolName)) return null;
|
||||
|
||||
const payload = parseResultPayload(result);
|
||||
if (!payload) return null;
|
||||
|
||||
if (payload.ok === false || payload.isError === true || typeof payload.error === 'string') {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: readString(payload.error) ?? 'Terminal context read failed.',
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = readString(payload.sessionId);
|
||||
const content = typeof payload.content === 'string' ? payload.content : '';
|
||||
const totalLines = readNumber(payload.totalLines);
|
||||
const startLine = readNumber(payload.startLine);
|
||||
const endLine = readNumber(payload.endLine);
|
||||
const returnedLines = readNumber(payload.returnedLines);
|
||||
if (!sessionId || totalLines == null || startLine == null || endLine == null || returnedLines == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'terminal.context',
|
||||
sessionId,
|
||||
label: readString(payload.label),
|
||||
range: readString(payload.range) ?? 'viewport',
|
||||
totalLines,
|
||||
startLine,
|
||||
endLine,
|
||||
returnedLines,
|
||||
hasMoreBefore: readBoolean(payload.hasMoreBefore),
|
||||
hasMoreAfter: readBoolean(payload.hasMoreAfter),
|
||||
source: readString(payload.source),
|
||||
preview: content.split('\n').slice(0, 6).join('\n'),
|
||||
};
|
||||
}
|
||||
53
components/ai/toolArtifacts/toolArtifactNames.test.ts
Normal file
53
components/ai/toolArtifacts/toolArtifactNames.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
inferArtifactToolNameFromCliArgs,
|
||||
normalizeArtifactToolName,
|
||||
} from './toolArtifactNames.ts';
|
||||
|
||||
test('normalizeArtifactToolName unwraps MCP server prefixes', () => {
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('mcp__netcatty__vault_notes_create'),
|
||||
'vault_notes_create',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('mcp__netcatty-remote-hosts__terminal_read_context'),
|
||||
'terminal_read_context',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeArtifactToolName unwraps OpenCode server prefixes', () => {
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts_vault_notes_get'),
|
||||
'vault_notes_get',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts_vault_hosts_list'),
|
||||
'vault_hosts_list',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts_terminal_read_context'),
|
||||
'terminal_read_context',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeArtifactToolName unwraps Copilot server prefixes', () => {
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts-vault_notes_list'),
|
||||
'vault_notes_list',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeArtifactToolName('netcatty-remote-hosts-terminal_read_context'),
|
||||
'terminal_read_context',
|
||||
);
|
||||
});
|
||||
|
||||
test('inferArtifactToolNameFromCliArgs maps Netcatty CLI artifact commands', () => {
|
||||
assert.equal(
|
||||
inferArtifactToolNameFromCliArgs({
|
||||
command: `/bin/zsh -lc '"/Applications/Netcatty.app/netcatty-tool-cli" vault host get --host-id host_1 --json'`,
|
||||
}),
|
||||
'host_get',
|
||||
);
|
||||
});
|
||||
105
components/ai/toolArtifacts/toolArtifactNames.ts
Normal file
105
components/ai/toolArtifacts/toolArtifactNames.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
const MCP_TOOL_NAME_PREFIX = 'mcp__';
|
||||
|
||||
const KNOWN_ARTIFACT_TOOL_NAMES = [
|
||||
'terminal_read_context',
|
||||
'vault_notes_create',
|
||||
'vault_notes_update',
|
||||
'vault_notes_get',
|
||||
'vault_notes_list',
|
||||
'vault_hosts_create',
|
||||
'vault_hosts_import',
|
||||
'vault_hosts_list',
|
||||
'host_get',
|
||||
'snippets_list',
|
||||
'snippets_get',
|
||||
'snippets_create',
|
||||
'snippets_update',
|
||||
'snippets_delete',
|
||||
'snippets_run',
|
||||
'scripts_list',
|
||||
'scripts_get',
|
||||
'scripts_create',
|
||||
'scripts_update',
|
||||
'scripts_delete',
|
||||
'scripts_run',
|
||||
'scripts_reference',
|
||||
'scripts_runs_list',
|
||||
'scripts_run_stop',
|
||||
'scripts_run_pause',
|
||||
'scripts_run_resume',
|
||||
'scripts_targets_set',
|
||||
] as const;
|
||||
|
||||
const CLI_ARTIFACT_TOOL_NAMES = new Map<string, string>([
|
||||
['vault host get', 'host_get'],
|
||||
]);
|
||||
|
||||
function readCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = args.command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
|
||||
const isShellWrap =
|
||||
raw.length >= 3 &&
|
||||
/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/.test(String(raw[0] ?? '')) &&
|
||||
/^-l?c$/.test(String(raw[1] ?? ''));
|
||||
|
||||
return isShellWrap
|
||||
? String(raw[raw.length - 1] ?? '') || null
|
||||
: raw.map((part) => String(part)).join(' ');
|
||||
}
|
||||
|
||||
function unwrapShellCommand(command: string): string {
|
||||
const strWrap = command.match(
|
||||
/^(?:\S*\/)?(?:sh|bash|zsh|fish|ash|dash)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/,
|
||||
);
|
||||
return strWrap ? strWrap[2] : command;
|
||||
}
|
||||
|
||||
function stripWrappingQuote(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length < 2) return trimmed;
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
return (first === last && (first === '"' || first === "'"))
|
||||
? trimmed.slice(1, -1)
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
export function normalizeArtifactToolName(toolName: string | undefined): string | undefined {
|
||||
const trimmed = toolName?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
if (trimmed.startsWith(MCP_TOOL_NAME_PREFIX)) {
|
||||
const segments = trimmed.split('__').filter(Boolean);
|
||||
return segments[segments.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
const prefixedArtifactToolName = KNOWN_ARTIFACT_TOOL_NAMES.find((candidate) => (
|
||||
trimmed.endsWith(`_${candidate}`) || trimmed.endsWith(`-${candidate}`)
|
||||
));
|
||||
if (prefixedArtifactToolName) return prefixedArtifactToolName;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function inferArtifactToolNameFromCliArgs(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const command = readCommandString(args);
|
||||
if (!command) return undefined;
|
||||
|
||||
const unwrapped = unwrapShellCommand(command);
|
||||
const cliMatch = unwrapped.match(/(?:^|\s|["'])(?:\S*\/)?netcatty-tool-cli(?:\.(?:cjs|cmd))?(?=["'\s]|$)([\s\S]*)$/);
|
||||
if (!cliMatch) return undefined;
|
||||
|
||||
const afterCli = stripWrappingQuote(cliMatch[1] ?? '').replace(/^["']?\s*/, '');
|
||||
const commandKey = afterCli
|
||||
.split(/\s+/)
|
||||
.filter((part) => part && !part.startsWith('-'))
|
||||
.slice(0, 3)
|
||||
.join(' ');
|
||||
|
||||
return CLI_ARTIFACT_TOOL_NAMES.get(commandKey);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parseResultPayload } from './toolArtifactResultPayload.ts';
|
||||
|
||||
test('parseResultPayload unwraps MCP text content arrays', () => {
|
||||
assert.deepEqual(parseResultPayload(JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, value: 42 }),
|
||||
},
|
||||
])), {
|
||||
ok: true,
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload unwraps MCP content objects', () => {
|
||||
assert.deepEqual(parseResultPayload({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, label: 'prod' }),
|
||||
},
|
||||
],
|
||||
}), {
|
||||
ok: true,
|
||||
label: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload unwraps Copilot content string objects', () => {
|
||||
assert.deepEqual(parseResultPayload({
|
||||
content: JSON.stringify({ ok: true, notes: [{ id: 'note-1' }] }),
|
||||
detailedContent: JSON.stringify({ ok: true, notes: [{ id: 'note-1' }] }),
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, notes: [{ id: 'note-1' }] }),
|
||||
},
|
||||
],
|
||||
}), {
|
||||
ok: true,
|
||||
notes: [{ id: 'note-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload unwraps Copilot contents arrays', () => {
|
||||
assert.deepEqual(parseResultPayload({
|
||||
contents: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ ok: true, hosts: [{ id: 'host-1' }] }),
|
||||
},
|
||||
],
|
||||
}), {
|
||||
ok: true,
|
||||
hosts: [{ id: 'host-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('parseResultPayload preserves plain result objects', () => {
|
||||
const payload = { ok: true, name: 'plain' };
|
||||
assert.equal(parseResultPayload(payload), payload);
|
||||
});
|
||||
60
components/ai/toolArtifacts/toolArtifactResultPayload.ts
Normal file
60
components/ai/toolArtifacts/toolArtifactResultPayload.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseJsonRecord(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return parseResultPayload(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeJson(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
}
|
||||
|
||||
function unwrapMcpTextEnvelope(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const textPart = value.find((entry) => {
|
||||
const record = asRecord(entry);
|
||||
return record?.type === 'text' && typeof record.text === 'string';
|
||||
});
|
||||
const record = asRecord(textPart);
|
||||
return typeof record?.text === 'string' ? record.text : value;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (record?.type === 'text' && typeof record.text === 'string') {
|
||||
return record.text;
|
||||
}
|
||||
if (Array.isArray(record?.content)) {
|
||||
return unwrapMcpTextEnvelope(record.content);
|
||||
}
|
||||
if (typeof record?.content === 'string' && looksLikeJson(record.content)) {
|
||||
return record.content;
|
||||
}
|
||||
if (typeof record?.detailedContent === 'string' && looksLikeJson(record.detailedContent)) {
|
||||
return record.detailedContent;
|
||||
}
|
||||
if (Array.isArray(record?.contents)) {
|
||||
return unwrapMcpTextEnvelope(record.contents);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseResultPayload(result: unknown): Record<string, unknown> | null {
|
||||
if (result == null) return null;
|
||||
const unwrapped = unwrapMcpTextEnvelope(result);
|
||||
|
||||
if (typeof unwrapped === 'string') {
|
||||
return parseJsonRecord(unwrapped);
|
||||
}
|
||||
|
||||
return asRecord(unwrapped);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveVaultArtifactVisualKind } from './vaultArtifactPresentation.tsx';
|
||||
|
||||
test('resolveVaultArtifactVisualKind distinguishes note vs host tools', () => {
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{ kind: 'vault.note', noteId: 'n1', title: 'Doc' },
|
||||
'vault_notes_create',
|
||||
),
|
||||
'noteCreate',
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{ kind: 'vault.host', hostId: 'h1', label: 'Web', hostname: '10.0.0.1' },
|
||||
'host_get',
|
||||
),
|
||||
'host',
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{
|
||||
kind: 'vault.hosts.batch',
|
||||
sourceTool: 'vault_hosts_import',
|
||||
addedCount: 2,
|
||||
preview: [],
|
||||
},
|
||||
'vault_hosts_import',
|
||||
),
|
||||
'hostImport',
|
||||
);
|
||||
assert.equal(
|
||||
resolveVaultArtifactVisualKind(
|
||||
{
|
||||
kind: 'vault.hosts.batch',
|
||||
sourceTool: 'vault_hosts_create',
|
||||
addedCount: 2,
|
||||
preview: [],
|
||||
},
|
||||
'vault_hosts_create',
|
||||
),
|
||||
'hostCreate',
|
||||
);
|
||||
});
|
||||
200
components/ai/toolArtifacts/vaultArtifactPresentation.tsx
Normal file
200
components/ai/toolArtifacts/vaultArtifactPresentation.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
FileCode,
|
||||
FilePenLine,
|
||||
FileText,
|
||||
FolderInput,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
ListChecks,
|
||||
NotebookPen,
|
||||
Pause,
|
||||
Play,
|
||||
Server,
|
||||
ServerCog,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { VaultToolArtifact } from './vaultToolArtifact';
|
||||
|
||||
export type VaultArtifactVisualKind =
|
||||
| 'noteCreate'
|
||||
| 'noteUpdate'
|
||||
| 'noteRead'
|
||||
| 'noteList'
|
||||
| 'host'
|
||||
| 'hostCreate'
|
||||
| 'hostImport'
|
||||
| 'hostList'
|
||||
| 'snippet'
|
||||
| 'snippetCreate'
|
||||
| 'snippetUpdate'
|
||||
| 'snippetList'
|
||||
| 'snippetRun'
|
||||
| 'snippetDeleted'
|
||||
| 'script'
|
||||
| 'scriptCreate'
|
||||
| 'scriptUpdate'
|
||||
| 'scriptList'
|
||||
| 'scriptRun'
|
||||
| 'scriptDeleted'
|
||||
| 'scriptRuns'
|
||||
| 'scriptAction'
|
||||
| 'scriptReference'
|
||||
| 'error';
|
||||
|
||||
const ARTIFACT_ICON_SIZE = 18;
|
||||
|
||||
const VISUAL_STYLES: Record<VaultArtifactVisualKind, { wrapper: string; icon: string }> = {
|
||||
noteCreate: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
|
||||
noteUpdate: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
noteRead: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/80' },
|
||||
noteList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
host: { wrapper: 'bg-emerald-500/12', icon: 'text-emerald-400' },
|
||||
hostCreate: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
|
||||
hostImport: { wrapper: 'bg-amber-500/12', icon: 'text-amber-400' },
|
||||
hostList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
snippet: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
|
||||
snippetCreate: { wrapper: 'bg-sky-500/12', icon: 'text-sky-400' },
|
||||
snippetUpdate: { wrapper: 'bg-sky-500/10', icon: 'text-sky-300/90' },
|
||||
snippetList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
snippetRun: { wrapper: 'bg-sky-500/10', icon: 'text-sky-300/90' },
|
||||
snippetDeleted: { wrapper: 'bg-muted/25', icon: 'text-muted-foreground/60' },
|
||||
script: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
|
||||
scriptCreate: { wrapper: 'bg-violet-500/12', icon: 'text-violet-400' },
|
||||
scriptUpdate: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
scriptList: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
scriptRun: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
scriptDeleted: { wrapper: 'bg-muted/25', icon: 'text-muted-foreground/60' },
|
||||
scriptRuns: { wrapper: 'bg-muted/30', icon: 'text-muted-foreground/70' },
|
||||
scriptAction: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
scriptReference: { wrapper: 'bg-violet-500/10', icon: 'text-violet-300/90' },
|
||||
error: { wrapper: 'bg-destructive/10', icon: 'text-destructive/80' },
|
||||
};
|
||||
|
||||
export function resolveVaultArtifactVisualKind(
|
||||
artifact: VaultToolArtifact,
|
||||
toolName?: string,
|
||||
): VaultArtifactVisualKind {
|
||||
if (artifact.kind === 'error') return 'error';
|
||||
|
||||
if (artifact.kind === 'vault.note') {
|
||||
if (toolName === 'vault_notes_create') return 'noteCreate';
|
||||
if (toolName === 'vault_notes_update') return 'noteUpdate';
|
||||
return 'noteRead';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.host') return 'host';
|
||||
|
||||
if (artifact.kind === 'vault.hosts.batch') {
|
||||
if (artifact.sourceTool === 'vault_hosts_import' || toolName === 'vault_hosts_import') {
|
||||
return 'hostImport';
|
||||
}
|
||||
return 'hostCreate';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.summary') {
|
||||
if (artifact.section === 'notes') return 'noteList';
|
||||
if (artifact.section === 'hosts') return 'hostList';
|
||||
if (artifact.section === 'snippets') return 'snippetList';
|
||||
return 'scriptList';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.snippet') {
|
||||
if (toolName === 'snippets_create') return 'snippetCreate';
|
||||
if (toolName === 'snippets_update') return 'snippetUpdate';
|
||||
return 'snippet';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.snippet.deleted') return 'snippetDeleted';
|
||||
if (artifact.kind === 'vault.snippet.run') return 'snippetRun';
|
||||
|
||||
if (artifact.kind === 'vault.script') {
|
||||
if (toolName === 'scripts_create') return 'scriptCreate';
|
||||
if (toolName === 'scripts_update' || toolName === 'scripts_targets_set') return 'scriptUpdate';
|
||||
return 'script';
|
||||
}
|
||||
|
||||
if (artifact.kind === 'vault.script.deleted') return 'scriptDeleted';
|
||||
if (artifact.kind === 'vault.script.run') return 'scriptRun';
|
||||
if (artifact.kind === 'vault.script.runs') return 'scriptRuns';
|
||||
if (artifact.kind === 'vault.script.action') return 'scriptAction';
|
||||
if (artifact.kind === 'vault.script.reference') return 'scriptReference';
|
||||
|
||||
return 'host';
|
||||
}
|
||||
|
||||
function renderVisualIcon(kind: VaultArtifactVisualKind): React.ReactNode {
|
||||
const className = VISUAL_STYLES[kind].icon;
|
||||
switch (kind) {
|
||||
case 'noteCreate':
|
||||
return <NotebookPen size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'noteUpdate':
|
||||
return <FilePenLine size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'noteRead':
|
||||
return <FileText size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'noteList':
|
||||
return <Library size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'host':
|
||||
return <Server size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'hostCreate':
|
||||
return <ServerCog size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'hostImport':
|
||||
return <FolderInput size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'hostList':
|
||||
return <LayoutGrid size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'snippet':
|
||||
case 'snippetCreate':
|
||||
case 'snippetUpdate':
|
||||
case 'snippetRun':
|
||||
return <Zap size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'snippetList':
|
||||
return <SquareTerminal size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'snippetDeleted':
|
||||
return <Trash2 size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'script':
|
||||
case 'scriptCreate':
|
||||
case 'scriptUpdate':
|
||||
case 'scriptReference':
|
||||
return <FileCode size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptList':
|
||||
case 'scriptRuns':
|
||||
return <ListChecks size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptRun':
|
||||
return <Play size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptDeleted':
|
||||
return <Trash2 size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'scriptAction':
|
||||
return <Pause size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
case 'error':
|
||||
return <AlertCircle size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
default:
|
||||
return <BookOpen size={ARTIFACT_ICON_SIZE} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function VaultArtifactIcon({
|
||||
artifact,
|
||||
toolName,
|
||||
}: {
|
||||
artifact: VaultToolArtifact;
|
||||
toolName?: string;
|
||||
}) {
|
||||
const kind = resolveVaultArtifactVisualKind(artifact, toolName);
|
||||
const styles = VISUAL_STYLES[kind];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
|
||||
styles.wrapper,
|
||||
)}
|
||||
>
|
||||
{renderVisualIcon(kind)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
141
components/ai/toolArtifacts/vaultToolArtifact.test.ts
Normal file
141
components/ai/toolArtifacts/vaultToolArtifact.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parseVaultToolArtifact } from './vaultToolArtifact.ts';
|
||||
|
||||
test('parseVaultToolArtifact maps note create results', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_notes_create', {
|
||||
ok: true,
|
||||
note: { id: 'note-1', title: 'Runbook', group: 'ops/prod' },
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.note',
|
||||
noteId: 'note-1',
|
||||
title: 'Runbook',
|
||||
group: 'ops/prod',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps single host create to host artifact', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_hosts_create', {
|
||||
ok: true,
|
||||
addedCount: 1,
|
||||
previewHosts: [
|
||||
{ id: 'host-1', label: 'Dokploy', hostname: '10.2.0.209' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.host',
|
||||
hostId: 'host-1',
|
||||
label: 'Dokploy',
|
||||
hostname: '10.2.0.209',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps host batch import results', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_hosts_create', {
|
||||
ok: true,
|
||||
addedCount: 2,
|
||||
previewHosts: [
|
||||
{ id: 'host-1', label: 'Web', hostname: '10.0.0.1' },
|
||||
{ id: 'host-2', hostname: 'db.internal' },
|
||||
],
|
||||
});
|
||||
assert.equal(artifact?.kind, 'vault.hosts.batch');
|
||||
if (artifact?.kind !== 'vault.hosts.batch') return;
|
||||
assert.equal(artifact.addedCount, 2);
|
||||
assert.equal(artifact.preview.length, 2);
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps host get results', () => {
|
||||
const artifact = parseVaultToolArtifact('host_get', {
|
||||
ok: true,
|
||||
host: { id: 'host-9', label: 'Prod', hostname: 'prod.example.com', port: 22 },
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.host',
|
||||
hostId: 'host-9',
|
||||
label: 'Prod',
|
||||
hostname: 'prod.example.com',
|
||||
port: 22,
|
||||
group: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps errors', () => {
|
||||
const artifact = parseVaultToolArtifact('vault_notes_get', {
|
||||
ok: false,
|
||||
error: 'Vault note "missing" was not found.',
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'error',
|
||||
message: 'Vault note "missing" was not found.',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps script create results', () => {
|
||||
const artifact = parseVaultToolArtifact('scripts_create', {
|
||||
ok: true,
|
||||
script: {
|
||||
id: 'script-1',
|
||||
label: 'Disk cleanup',
|
||||
language: 'javascript',
|
||||
package: 'maintenance',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.script',
|
||||
scriptId: 'script-1',
|
||||
label: 'Disk cleanup',
|
||||
package: 'maintenance',
|
||||
language: 'javascript',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps snippet list results', () => {
|
||||
const artifact = parseVaultToolArtifact('snippets_list', {
|
||||
ok: true,
|
||||
snippets: [{ id: 's1', label: 'Restart nginx' }],
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.summary',
|
||||
section: 'snippets',
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact maps script run results', () => {
|
||||
const artifact = parseVaultToolArtifact('scripts_run', {
|
||||
ok: true,
|
||||
snippetId: 'script-1',
|
||||
runId: 'run-9',
|
||||
kind: 'script',
|
||||
});
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.script.run',
|
||||
scriptId: 'script-1',
|
||||
runId: 'run-9',
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseVaultToolArtifact unwraps Claude MCP text result envelopes', () => {
|
||||
const artifact = parseVaultToolArtifact('mcp__netcatty-remote-hosts__vault_notes_list', JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
notes: [
|
||||
{ id: 'note-1', title: 'Docker Compose' },
|
||||
{ id: 'note-2', title: 'Dokploy' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
]));
|
||||
|
||||
assert.deepEqual(artifact, {
|
||||
kind: 'vault.summary',
|
||||
section: 'notes',
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
330
components/ai/toolArtifacts/vaultToolArtifact.ts
Normal file
330
components/ai/toolArtifacts/vaultToolArtifact.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { normalizeArtifactToolName } from './toolArtifactNames';
|
||||
import { parseResultPayload } from './toolArtifactResultPayload';
|
||||
|
||||
export type VaultSummarySection = 'notes' | 'hosts' | 'snippets' | 'scripts';
|
||||
|
||||
export type VaultToolArtifact =
|
||||
| {
|
||||
kind: 'vault.note';
|
||||
noteId: string;
|
||||
title: string;
|
||||
group?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.host';
|
||||
hostId: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
group?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.hosts.batch';
|
||||
sourceTool?: 'vault_hosts_create' | 'vault_hosts_import';
|
||||
addedCount: number;
|
||||
dryRun?: boolean;
|
||||
preview: Array<{ hostId?: string; label?: string; hostname?: string }>;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.summary';
|
||||
section: VaultSummarySection;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.snippet';
|
||||
snippetId: string;
|
||||
label: string;
|
||||
package?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script';
|
||||
scriptId: string;
|
||||
label: string;
|
||||
package?: string;
|
||||
language?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.snippet.deleted';
|
||||
snippetId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.deleted';
|
||||
scriptId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.snippet.run';
|
||||
snippetId: string;
|
||||
command?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.run';
|
||||
scriptId: string;
|
||||
runId: string;
|
||||
status?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.runs';
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.action';
|
||||
action: 'stop' | 'pause' | 'resume';
|
||||
runId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'vault.script.reference';
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const VAULT_ARTIFACT_TOOL_NAMES = new Set([
|
||||
'vault_notes_create',
|
||||
'vault_notes_update',
|
||||
'vault_notes_get',
|
||||
'vault_notes_list',
|
||||
'vault_hosts_create',
|
||||
'vault_hosts_import',
|
||||
'vault_hosts_list',
|
||||
'host_get',
|
||||
'snippets_list',
|
||||
'snippets_get',
|
||||
'snippets_create',
|
||||
'snippets_update',
|
||||
'snippets_delete',
|
||||
'snippets_run',
|
||||
'scripts_list',
|
||||
'scripts_get',
|
||||
'scripts_create',
|
||||
'scripts_update',
|
||||
'scripts_delete',
|
||||
'scripts_run',
|
||||
'scripts_reference',
|
||||
'scripts_runs_list',
|
||||
'scripts_run_stop',
|
||||
'scripts_run_pause',
|
||||
'scripts_run_resume',
|
||||
'scripts_targets_set',
|
||||
]);
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseNoteArtifact(note: unknown): VaultToolArtifact | null {
|
||||
if (!note || typeof note !== 'object') return null;
|
||||
const record = note as Record<string, unknown>;
|
||||
const noteId = readString(record.id);
|
||||
const title = readString(record.title);
|
||||
if (!noteId || !title) return null;
|
||||
return {
|
||||
kind: 'vault.note',
|
||||
noteId,
|
||||
title,
|
||||
group: readString(record.group),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHostArtifact(host: unknown): VaultToolArtifact | null {
|
||||
if (!host || typeof host !== 'object') return null;
|
||||
const record = host as Record<string, unknown>;
|
||||
const hostId = readString(record.id);
|
||||
const hostname = readString(record.hostname);
|
||||
if (!hostId || !hostname) return null;
|
||||
return {
|
||||
kind: 'vault.host',
|
||||
hostId,
|
||||
label: readString(record.label) ?? hostname,
|
||||
hostname,
|
||||
port: readNumber(record.port),
|
||||
group: readString(record.group),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnippetArtifact(snippet: unknown): VaultToolArtifact | null {
|
||||
if (!snippet || typeof snippet !== 'object') return null;
|
||||
const record = snippet as Record<string, unknown>;
|
||||
const snippetId = readString(record.id);
|
||||
const label = readString(record.label);
|
||||
if (!snippetId || !label) return null;
|
||||
return {
|
||||
kind: 'vault.snippet',
|
||||
snippetId,
|
||||
label,
|
||||
package: readString(record.package),
|
||||
};
|
||||
}
|
||||
|
||||
function parseScriptArtifact(script: unknown): VaultToolArtifact | null {
|
||||
if (!script || typeof script !== 'object') return null;
|
||||
const record = script as Record<string, unknown>;
|
||||
const scriptId = readString(record.id);
|
||||
const label = readString(record.label);
|
||||
if (!scriptId || !label) return null;
|
||||
return {
|
||||
kind: 'vault.script',
|
||||
scriptId,
|
||||
label,
|
||||
package: readString(record.package),
|
||||
language: readString(record.language),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePreviewHosts(value: unknown): Array<{ hostId?: string; label?: string; hostname?: string }> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const hostname = readString(record.hostname);
|
||||
if (!hostname) return null;
|
||||
return {
|
||||
hostId: readString(record.id),
|
||||
label: readString(record.label),
|
||||
hostname,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { hostId?: string; label?: string; hostname: string } => entry !== null);
|
||||
}
|
||||
|
||||
export function isVaultArtifactToolName(toolName: string): boolean {
|
||||
const normalized = normalizeArtifactToolName(toolName);
|
||||
return normalized ? VAULT_ARTIFACT_TOOL_NAMES.has(normalized) : false;
|
||||
}
|
||||
|
||||
export function parseVaultToolArtifact(
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
): VaultToolArtifact | null {
|
||||
const normalizedToolName = normalizeArtifactToolName(toolName);
|
||||
if (!normalizedToolName || !VAULT_ARTIFACT_TOOL_NAMES.has(normalizedToolName)) return null;
|
||||
|
||||
const payload = parseResultPayload(result);
|
||||
if (!payload) return null;
|
||||
|
||||
if (payload.ok === false || payload.isError === true) {
|
||||
const message = readString(payload.error) ?? 'Operation failed.';
|
||||
return { kind: 'error', message };
|
||||
}
|
||||
|
||||
switch (normalizedToolName) {
|
||||
case 'vault_notes_create':
|
||||
case 'vault_notes_update':
|
||||
case 'vault_notes_get':
|
||||
return parseNoteArtifact(payload.note);
|
||||
case 'vault_notes_list': {
|
||||
const notes = Array.isArray(payload.notes) ? payload.notes : [];
|
||||
return { kind: 'vault.summary', section: 'notes', count: notes.length };
|
||||
}
|
||||
case 'vault_hosts_create':
|
||||
case 'vault_hosts_import': {
|
||||
const preview = parsePreviewHosts(payload.previewHosts);
|
||||
const addedCount = readNumber(payload.addedCount)
|
||||
?? (payload.dryRun === true ? readNumber(payload.validCount) : undefined)
|
||||
?? preview.length;
|
||||
if (addedCount <= 0 && preview.length === 0) return null;
|
||||
|
||||
const dryRun = payload.dryRun === true;
|
||||
if (!dryRun && addedCount === 1 && preview.length === 1 && preview[0].hostname) {
|
||||
const single = preview[0];
|
||||
if (single.hostId) {
|
||||
return {
|
||||
kind: 'vault.host',
|
||||
hostId: single.hostId,
|
||||
label: single.label ?? single.hostname,
|
||||
hostname: single.hostname,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'vault.hosts.batch',
|
||||
sourceTool: normalizedToolName === 'vault_hosts_import' ? 'vault_hosts_import' : 'vault_hosts_create',
|
||||
addedCount,
|
||||
dryRun,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
case 'vault_hosts_list': {
|
||||
const hosts = Array.isArray(payload.hosts) ? payload.hosts : [];
|
||||
return { kind: 'vault.summary', section: 'hosts', count: hosts.length };
|
||||
}
|
||||
case 'host_get':
|
||||
return parseHostArtifact(payload.host);
|
||||
case 'snippets_list': {
|
||||
const snippets = Array.isArray(payload.snippets) ? payload.snippets : [];
|
||||
return { kind: 'vault.summary', section: 'snippets', count: snippets.length };
|
||||
}
|
||||
case 'snippets_get':
|
||||
case 'snippets_create':
|
||||
case 'snippets_update':
|
||||
return parseSnippetArtifact(payload.snippet);
|
||||
case 'snippets_delete': {
|
||||
const snippetId = readString(payload.snippetId);
|
||||
if (!snippetId) return null;
|
||||
return { kind: 'vault.snippet.deleted', snippetId };
|
||||
}
|
||||
case 'snippets_run': {
|
||||
const snippetId = readString(payload.snippetId);
|
||||
if (!snippetId) return null;
|
||||
return {
|
||||
kind: 'vault.snippet.run',
|
||||
snippetId,
|
||||
command: readString(payload.command),
|
||||
};
|
||||
}
|
||||
case 'scripts_list': {
|
||||
const scripts = Array.isArray(payload.scripts) ? payload.scripts : [];
|
||||
return { kind: 'vault.summary', section: 'scripts', count: scripts.length };
|
||||
}
|
||||
case 'scripts_get':
|
||||
case 'scripts_create':
|
||||
case 'scripts_update':
|
||||
case 'scripts_targets_set':
|
||||
return parseScriptArtifact(payload.script);
|
||||
case 'scripts_delete': {
|
||||
const scriptId = readString(payload.scriptId);
|
||||
if (!scriptId) return null;
|
||||
return { kind: 'vault.script.deleted', scriptId };
|
||||
}
|
||||
case 'scripts_run': {
|
||||
const scriptId = readString(payload.snippetId) ?? readString(payload.scriptId);
|
||||
const runId = readString(payload.runId);
|
||||
if (!scriptId || !runId) return null;
|
||||
return {
|
||||
kind: 'vault.script.run',
|
||||
scriptId,
|
||||
runId,
|
||||
status: readString(payload.status),
|
||||
};
|
||||
}
|
||||
case 'scripts_reference':
|
||||
return { kind: 'vault.script.reference' };
|
||||
case 'scripts_runs_list': {
|
||||
const runs = Array.isArray(payload.runs) ? payload.runs : [];
|
||||
return { kind: 'vault.script.runs', count: runs.length };
|
||||
}
|
||||
case 'scripts_run_stop':
|
||||
return parseScriptRunAction(payload, 'stop');
|
||||
case 'scripts_run_pause':
|
||||
return parseScriptRunAction(payload, 'pause');
|
||||
case 'scripts_run_resume':
|
||||
return parseScriptRunAction(payload, 'resume');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseScriptRunAction(
|
||||
payload: Record<string, unknown>,
|
||||
action: 'stop' | 'pause' | 'resume',
|
||||
): VaultToolArtifact | null {
|
||||
const runId = readString(payload.runId);
|
||||
if (!runId) return null;
|
||||
return { kind: 'vault.script.action', action, runId };
|
||||
}
|
||||
18
components/ai/useExternalMcpGrantPersister.ts
Normal file
18
components/ai/useExternalMcpGrantPersister.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAIPermissionGrantsState } from '../../application/state/useAIPermissionGrantsState';
|
||||
import { registerGrantPersister } from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
/**
|
||||
* Keep Always Allow grants writable even when Catty AI panel is not mounted
|
||||
* (External MCP approvals in main/settings windows).
|
||||
*/
|
||||
export function useExternalMcpGrantPersister(): void {
|
||||
const { addGrant } = useAIPermissionGrantsState();
|
||||
|
||||
useEffect(() => {
|
||||
return registerGrantPersister((rule) => {
|
||||
addGrant(rule);
|
||||
});
|
||||
}, [addGrant]);
|
||||
}
|
||||
81
components/ai/useProviderModelCatalog.ts
Normal file
81
components/ai/useProviderModelCatalog.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
fetchProviderModelCatalog,
|
||||
providerModelCacheKey,
|
||||
readCachedProviderModelCatalog,
|
||||
seedProviderModelCatalog,
|
||||
} from '../../infrastructure/ai/cattyProviderModels';
|
||||
import type { ComposerPickerModel } from '../../infrastructure/ai/composerPicker';
|
||||
import type { ProviderConfig } from '../../infrastructure/ai/types';
|
||||
import { getFetchBridge } from '../settings/tabs/ai/types';
|
||||
|
||||
export interface ProviderModelCatalog {
|
||||
models: ComposerPickerModel[];
|
||||
fetched: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useProviderModelCatalog(
|
||||
provider: ProviderConfig | undefined,
|
||||
enabled: boolean,
|
||||
): ProviderModelCatalog {
|
||||
const cacheKey = provider && enabled ? providerModelCacheKey(provider) : '';
|
||||
const providerRef = useRef(provider);
|
||||
providerRef.current = provider;
|
||||
const seed = useMemo(
|
||||
() => {
|
||||
if (!cacheKey) return { models: [], fetched: false };
|
||||
const current = providerRef.current;
|
||||
return current ? seedProviderModelCatalog(current) : { models: [], fetched: false };
|
||||
},
|
||||
[cacheKey],
|
||||
);
|
||||
const [catalog, setCatalog] = useState<Omit<ProviderModelCatalog, 'loading'>>(() => {
|
||||
const current = providerRef.current;
|
||||
const hit = current && enabled ? readCachedProviderModelCatalog(current) : null;
|
||||
return hit ? { models: hit, fetched: true } : seed;
|
||||
});
|
||||
const [loading, setLoading] = useState(() => {
|
||||
const current = providerRef.current;
|
||||
return Boolean(enabled && current && !readCachedProviderModelCatalog(current));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const current = providerRef.current;
|
||||
if (!enabled || !current) {
|
||||
setCatalog({ models: [], fetched: false });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hit = readCachedProviderModelCatalog(current);
|
||||
if (hit) {
|
||||
setCatalog((prev) => (
|
||||
prev.fetched && prev.models === hit ? prev : { models: hit, fetched: true }
|
||||
));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setCatalog(seedProviderModelCatalog(current));
|
||||
setLoading(true);
|
||||
void fetchProviderModelCatalog(current, getFetchBridge()).then((next) => {
|
||||
if (cancelled) return;
|
||||
setCatalog(next);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cacheKey, enabled]);
|
||||
|
||||
return {
|
||||
models: catalog.models.length > 0 ? catalog.models : seed.models,
|
||||
fetched: catalog.fetched,
|
||||
loading,
|
||||
error: catalog.error,
|
||||
};
|
||||
}
|
||||
80
components/ai/userSkillsState.test.ts
Normal file
80
components/ai/userSkillsState.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getNextSelectedUserSkillSlugsMap,
|
||||
getReadyUserSkillOptions,
|
||||
pruneSelectedUserSkillSlugsMap,
|
||||
} from "./userSkillsState.ts";
|
||||
|
||||
test("getReadyUserSkillOptions returns only ready skills and clears invalid payloads", () => {
|
||||
assert.deepEqual(getReadyUserSkillOptions(null), []);
|
||||
assert.deepEqual(getReadyUserSkillOptions({ ok: false }), []);
|
||||
assert.deepEqual(
|
||||
getReadyUserSkillOptions({
|
||||
ok: true,
|
||||
skills: [
|
||||
{
|
||||
id: "alpha",
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
description: "Alpha helper",
|
||||
status: "ready",
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
slug: "beta",
|
||||
name: "Beta",
|
||||
description: "Beta helper",
|
||||
status: "warning",
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
{
|
||||
id: "alpha",
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
description: "Alpha helper",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("pruneSelectedUserSkillSlugsMap removes stale slugs and empty scopes", () => {
|
||||
assert.deepEqual(
|
||||
pruneSelectedUserSkillSlugsMap(
|
||||
{
|
||||
"terminal:1": ["alpha", "missing"],
|
||||
"workspace:1": ["missing"],
|
||||
},
|
||||
[
|
||||
{
|
||||
id: "alpha",
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
description: "Alpha helper",
|
||||
},
|
||||
],
|
||||
),
|
||||
{
|
||||
"terminal:1": ["alpha"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("getNextSelectedUserSkillSlugsMap preserves selections when refresh fails", () => {
|
||||
const selected = {
|
||||
"terminal:1": ["alpha", "missing"],
|
||||
"workspace:1": ["beta"],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
getNextSelectedUserSkillSlugsMap(selected, null),
|
||||
selected,
|
||||
);
|
||||
assert.equal(
|
||||
getNextSelectedUserSkillSlugsMap(selected, { ok: false }),
|
||||
selected,
|
||||
);
|
||||
});
|
||||
73
components/ai/userSkillsState.ts
Normal file
73
components/ai/userSkillsState.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface UserSkillStatusItemLike {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "ready" | "warning";
|
||||
}
|
||||
|
||||
export interface UserSkillsStatusLike {
|
||||
ok: boolean;
|
||||
skills?: UserSkillStatusItemLike[];
|
||||
}
|
||||
|
||||
export interface UserSkillOption {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function getReadyUserSkillOptions(
|
||||
status: UserSkillsStatusLike | null | undefined,
|
||||
): UserSkillOption[] {
|
||||
if (!status?.ok || !Array.isArray(status.skills)) return [];
|
||||
|
||||
return status.skills
|
||||
.filter((skill) => skill.status === "ready" && typeof skill.slug === "string" && skill.slug.length > 0)
|
||||
.map((skill) => ({
|
||||
id: skill.id,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
}));
|
||||
}
|
||||
|
||||
export function pruneSelectedUserSkillSlugsMap(
|
||||
selectedByScope: Record<string, string[]>,
|
||||
options: UserSkillOption[],
|
||||
): Record<string, string[]> {
|
||||
const validSlugs = new Set(options.map((option) => option.slug));
|
||||
let changed = false;
|
||||
const nextEntries: Array<[string, string[]]> = [];
|
||||
|
||||
for (const [scopeKey, slugs] of Object.entries(selectedByScope)) {
|
||||
const filteredSlugs = slugs.filter((slug) => validSlugs.has(slug));
|
||||
if (filteredSlugs.length !== slugs.length) changed = true;
|
||||
if (filteredSlugs.length > 0) {
|
||||
nextEntries.push([scopeKey, filteredSlugs]);
|
||||
} else if (slugs.length > 0) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return selectedByScope;
|
||||
}
|
||||
|
||||
return Object.fromEntries(nextEntries);
|
||||
}
|
||||
|
||||
export function getNextSelectedUserSkillSlugsMap(
|
||||
selectedByScope: Record<string, string[]>,
|
||||
status: UserSkillsStatusLike | null | undefined,
|
||||
): Record<string, string[]> {
|
||||
if (!status?.ok || !Array.isArray(status.skills)) {
|
||||
return selectedByScope;
|
||||
}
|
||||
|
||||
return pruneSelectedUserSkillSlugsMap(
|
||||
selectedByScope,
|
||||
getReadyUserSkillOptions(status),
|
||||
);
|
||||
}
|
||||
38
components/ai/userSkillsStatusEvents.ts
Normal file
38
components/ai/userSkillsStatusEvents.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const USER_SKILLS_STATUS_CHANGED_EVENT = 'netcatty:user-skills-status-changed';
|
||||
const USER_SKILLS_STATUS_CHANGED_KEY = 'ai:user-skills-status-changed';
|
||||
|
||||
type SettingsBridge = {
|
||||
notifySettingsChanged?: (payload: { key: string; value: unknown }) => void;
|
||||
onSettingsChanged?: (callback: (payload: { key: string; value: unknown }) => void) => () => void;
|
||||
};
|
||||
|
||||
function getSettingsBridge(): SettingsBridge | undefined {
|
||||
return (window as unknown as { netcatty?: SettingsBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function notifyUserSkillsStatusChanged() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new Event(USER_SKILLS_STATUS_CHANGED_EVENT));
|
||||
getSettingsBridge()?.notifySettingsChanged?.({
|
||||
key: USER_SKILLS_STATUS_CHANGED_KEY,
|
||||
value: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeUserSkillsStatusChanged(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
const handleLocalEvent = () => callback();
|
||||
window.addEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
|
||||
|
||||
const unsubscribeSettings = getSettingsBridge()?.onSettingsChanged?.((payload) => {
|
||||
if (payload.key === USER_SKILLS_STATUS_CHANGED_KEY) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(USER_SKILLS_STATUS_CHANGED_EVENT, handleLocalEvent);
|
||||
unsubscribeSettings?.();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user