mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence
Backend (sidecar): - Extended ProtocolModels with DTOs for custom agents, hooks, skills, infinite sessions, session lifecycle, and 9 new event types - Added CopilotManagedSessionIds for stable SDK session ID mapping - Added CopilotSessionManager/ICopilotSessionManager for session lifecycle - Added CopilotSessionHooks for hook registration - Added CopilotMessageOptionsMetadata for mid-turn steering - Extended CopilotAgentBundle to wire custom agents, hooks, skills, infinite sessions, and stable session IDs - Extended CopilotTurnExecutionState to project 13 new SDK event types - Widened ITurnWorkflowRunner callback to accept SidecarEventDto - Added list/delete/disconnect session commands to SidecarProtocolHost - Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total) Frontend (renderer + main + shared): - Added ChatMessageAttachment type and helpers (attachment.ts) - Extended sidecar contracts with MessageMode, 3 new command types, 9 new event types, and agent/session config DTOs - Extended SessionEventRecord with 6 new event kinds and ~20 fields - Added PatternAgentCopilotConfig to pattern domain - Added attachments support to ChatMessageRecord - Updated sidecar client with session lifecycle methods and turn-scoped event routing via onTurnScopedEvent callback - Updated main process: handleTurnScopedEvent(), deleteSession(), steering bypass for mid-turn messages, attachment passthrough - Added deleteSession IPC handler and preload binding - Added TurnEventLog state tracker with format/apply/prune helpers - ChatPane: always-enabled composer, steering indicator, attachment picker with preview, image thumbnails in message history, context-usage bar, amber steer mode for send button - ActivityPanel: turn events section with sub-agent, hook, skill, and compaction event rendering - Sidebar: delete session action in context menu - App.tsx: wired sessionUsage, turnEventLogs, and deleteSession Documentation: - AGENTS.md: added glob safety rule for node_modules - README.md: added steering, image input, and richer observability - ARCHITECTURE.md: added turn-scoped events, steering, and attachments - Website: added steering and image input feature cards, updated live visibility and session cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, Clock, ShieldAlert, Sparkles, Users } from 'lucide-react';
|
||||
import { Activity, ArrowRight, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isAgentActivityCompleted,
|
||||
type AgentActivityRow,
|
||||
type SessionActivityState,
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
@@ -145,6 +146,35 @@ function AgentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Turn event helpers ─────────────────────────────────────── */
|
||||
|
||||
import type { SessionEventKind } from '@shared/domain/event';
|
||||
|
||||
function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) {
|
||||
const base = 'size-3';
|
||||
switch (kind) {
|
||||
case 'subagent':
|
||||
return <ArrowRight className={`${base} ${success === false ? 'text-red-400' : 'text-sky-400'}`} />;
|
||||
case 'hook-lifecycle':
|
||||
return <Cog className={`${base} ${phase === 'start' ? 'animate-spin text-amber-400' : success === false ? 'text-red-400' : 'text-emerald-400'}`} />;
|
||||
case 'skill-invoked':
|
||||
return <Sparkles className={`${base} text-violet-400`} />;
|
||||
case 'session-compaction':
|
||||
return <CheckCircle2 className={`${base} ${phase === 'start' ? 'animate-pulse text-amber-400' : 'text-emerald-400'}`} />;
|
||||
default:
|
||||
return <Zap className={`${base} text-zinc-500`} />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTurnEventTimestamp(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ActivityPanel ─────────────────────────────────────────── */
|
||||
|
||||
interface ActivityPanelProps {
|
||||
@@ -152,6 +182,7 @@ interface ActivityPanelProps {
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
pattern: PatternDefinition;
|
||||
session: SessionRecord;
|
||||
turnEvents?: TurnEventLog;
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
@@ -159,6 +190,7 @@ export function ActivityPanel({
|
||||
onJumpToMessage,
|
||||
pattern,
|
||||
session,
|
||||
turnEvents,
|
||||
}: ActivityPanelProps) {
|
||||
const activityRows = useMemo(
|
||||
() => buildAgentActivityRows(activity, pattern.agents),
|
||||
@@ -239,6 +271,39 @@ export function ActivityPanel({
|
||||
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
|
||||
</div>
|
||||
|
||||
{/* ── Turn events section ─────────────────────────── */}
|
||||
{turnEvents && turnEvents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<Zap className="size-3" />
|
||||
<span>Events</span>
|
||||
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[9px] tabular-nums text-zinc-500">
|
||||
{turnEvents.length}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-0.5 rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2">
|
||||
{turnEvents.slice().reverse().map((entry, index) => (
|
||||
<div key={index} className="flex items-start gap-2 py-1">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<TurnEventIcon kind={entry.kind} phase={entry.phase} success={entry.success} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-zinc-300">{entry.label}</span>
|
||||
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">
|
||||
{formatTurnEventTimestamp(entry.occurredAt)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.detail && (
|
||||
<p className="text-[10px] leading-snug text-zinc-600">{entry.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, ShieldAlert, Square, User } from 'lucide-react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, ShieldAlert, Square, User, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||
@@ -11,7 +11,10 @@ import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPil
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { InteractionMode } from '@shared/contracts/sidecar';
|
||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
@@ -37,7 +40,8 @@ interface ChatPaneProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
sessionUsage?: SessionUsageState;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
|
||||
@@ -60,6 +64,7 @@ export function ChatPane({
|
||||
availableModels,
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
@@ -99,8 +104,9 @@ export function ChatPane({
|
||||
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
|
||||
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
|
||||
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
|
||||
const isComposerDisabled = isSessionBusy || isUpdatingSessionModelConfig;
|
||||
const isComposerDisabled = isUpdatingSessionModelConfig;
|
||||
const canSubmitInput = hasComposerContent && !isComposerDisabled;
|
||||
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
|
||||
|
||||
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||
const mcpServers = toolingSettings.mcpServers;
|
||||
@@ -140,7 +146,10 @@ export function ChatPane({
|
||||
}, [session.id]);
|
||||
|
||||
function handleComposerSubmit(content: string) {
|
||||
void onSend(content);
|
||||
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
|
||||
const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined;
|
||||
setPendingAttachments([]);
|
||||
void onSend(content, attachments, messageMode);
|
||||
}
|
||||
|
||||
function handleDismissPlan() {
|
||||
@@ -336,6 +345,29 @@ export function ChatPane({
|
||||
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}`
|
||||
}
|
||||
>
|
||||
{/* Attachment thumbnails */}
|
||||
{isUser && message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, attIdx) =>
|
||||
isImageAttachment(att) ? (
|
||||
<img
|
||||
key={attIdx}
|
||||
alt={getAttachmentDisplayName(att)}
|
||||
className="max-h-48 max-w-xs rounded-lg border border-zinc-700 object-cover"
|
||||
src={`data:${att.mimeType};base64,${att.data}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={attIdx}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] text-zinc-400"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
{getAttachmentDisplayName(att)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && message.pending ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-zinc-200">
|
||||
{message.content}
|
||||
@@ -510,6 +542,29 @@ export function ChatPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment preview */}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-1 pb-2">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-1.5 text-[11px] text-zinc-300"
|
||||
>
|
||||
<Paperclip className="size-3 text-zinc-500" />
|
||||
<span className="max-w-[160px] truncate">{getAttachmentDisplayName(attachment)}</span>
|
||||
<button
|
||||
aria-label="Remove attachment"
|
||||
className="ml-1 rounded p-0.5 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300"
|
||||
onClick={() => setPendingAttachments((prev) => prev.filter((_, i) => i !== index))}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-zinc-700 bg-zinc-900 transition-colors focus-within:border-indigo-500/50">
|
||||
<MarkdownComposer
|
||||
ref={composerRef}
|
||||
@@ -526,7 +581,7 @@ export function ChatPane({
|
||||
: pendingMcpAuth
|
||||
? 'MCP server requires authentication...'
|
||||
: isSessionBusy
|
||||
? 'Waiting for response...'
|
||||
? 'Steer the agent (sends immediately)...'
|
||||
: isUpdatingSessionModelConfig
|
||||
? 'Saving model settings...'
|
||||
: isPlanMode
|
||||
@@ -535,6 +590,38 @@ export function ChatPane({
|
||||
}
|
||||
>
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1">
|
||||
{/* Attachment picker */}
|
||||
<button
|
||||
aria-label="Attach image"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
||||
disabled={isComposerDisabled}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.multiple = true;
|
||||
input.onchange = () => {
|
||||
if (!input.files) return;
|
||||
const newAttachments: ChatMessageAttachment[] = [];
|
||||
for (const file of input.files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
setPendingAttachments((prev) => [
|
||||
...prev,
|
||||
{ type: 'blob', data: base64, mimeType: file.type, displayName: file.name },
|
||||
]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Paperclip className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Plan mode toggle */}
|
||||
{onSetInteractionMode && !isSessionBusy && (
|
||||
<button
|
||||
@@ -553,29 +640,39 @@ export function ChatPane({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Send / Stop button */}
|
||||
{/* Send / Stop / Steer button */}
|
||||
<button
|
||||
className={`flex size-8 items-center justify-center rounded-lg transition ${
|
||||
isSessionBusy
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||
: canSubmitInput
|
||||
? isPlanMode
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-500'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: canSubmitInput || pendingAttachments.length > 0
|
||||
? isSessionBusy
|
||||
? 'bg-amber-600 text-white hover:bg-amber-500'
|
||||
: isPlanMode
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-500'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: 'bg-zinc-800 text-zinc-600'
|
||||
}`}
|
||||
disabled={!canSubmitInput && !isSessionBusy}
|
||||
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
|
||||
onClick={() => {
|
||||
if (isSessionBusy) {
|
||||
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
|
||||
onCancelTurn?.();
|
||||
} else {
|
||||
composerRef.current?.submit();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={isSessionBusy ? 'Stop generating' : isPlanMode ? 'Send as plan request' : 'Send message'}
|
||||
aria-label={
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
? 'Stop generating'
|
||||
: isSessionBusy
|
||||
? 'Steer agent'
|
||||
: isPlanMode
|
||||
? 'Send as plan request'
|
||||
: 'Send message'
|
||||
}
|
||||
>
|
||||
{isSessionBusy ? (
|
||||
{isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 ? (
|
||||
<Square className="size-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
@@ -591,7 +688,38 @@ export function ChatPane({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isSessionBusy && (hasComposerContent || pendingAttachments.length > 0) && (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1.5 pt-0.5">
|
||||
<div className="size-1.5 rounded-full bg-amber-500" />
|
||||
<span className="text-[10px] font-medium text-amber-400/80">
|
||||
Steering — your message will be injected into the current turn
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session usage bar */}
|
||||
{sessionUsage && sessionUsage.tokenLimit > 0 && (
|
||||
<div className="px-1 pt-1.5">
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
sessionUsage.currentTokens / sessionUsage.tokenLimit > 0.9
|
||||
? 'bg-red-500'
|
||||
: sessionUsage.currentTokens / sessionUsage.tokenLimit > 0.7
|
||||
? 'bg-amber-500'
|
||||
: 'bg-indigo-500/60'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums">
|
||||
{Math.round((sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}% context
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -45,6 +46,7 @@ interface SidebarProps {
|
||||
onDuplicateSession: (sessionId: string) => void;
|
||||
onSetSessionPinned: (sessionId: string, isPinned: boolean) => void;
|
||||
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
onRefreshGitContext: (projectId: string) => void;
|
||||
}
|
||||
|
||||
@@ -128,14 +130,16 @@ function ActionMenuItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-[12px] text-zinc-300 transition hover:bg-zinc-800"
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-[12px] transition hover:bg-zinc-800 ${className ?? 'text-zinc-300'}`}
|
||||
onClick={onClick}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
@@ -475,6 +479,7 @@ export function Sidebar({
|
||||
onDuplicateSession,
|
||||
onSetSessionPinned,
|
||||
onSetSessionArchived,
|
||||
onDeleteSession,
|
||||
onRefreshGitContext,
|
||||
}: SidebarProps) {
|
||||
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
|
||||
@@ -742,6 +747,15 @@ export function Sidebar({
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
<ActionMenuItem
|
||||
className="text-red-400 hover:bg-red-500/10"
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
onClick={() => {
|
||||
onDeleteSession(menuState.sessionId);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user