feat: wire structured prompt invocation through renderer

- Update InlinePromptPill to build ProjectPromptInvocation from
  selected prompt file and send it via onSubmit instead of pasting
  raw resolved text as chat content
- Thread promptInvocation through ChatPane.onSend and App.tsx to
  the sendSessionMessage IPC call
- Add PromptInvocationChrome component for user messages that were
  triggered by a prompt file, showing prompt name, agent badge,
  tool count, source path, and expandable resolved body
- Surface prompt agent and tools metadata in InlinePromptPill
  dropdown list items and ProjectSettingsPanel PromptCard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 11:49:45 +02:00
co-authored by Copilot
parent 5d69d9d855
commit bcbdd2ef29
4 changed files with 116 additions and 22 deletions
+2 -1
View File
@@ -588,11 +588,12 @@ export default function App() {
} else if (selectedSession && patternForSession && projectForSession) { } else if (selectedSession && patternForSession && projectForSession) {
content = ( content = (
<ChatPane <ChatPane
onSend={(c, attachments, messageMode) => api.sendSessionMessage({ onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
sessionId: selectedSession.id, sessionId: selectedSession.id,
content: c, content: c,
attachments: attachments?.length ? attachments : undefined, attachments: attachments?.length ? attachments : undefined,
messageMode, messageMode,
promptInvocation,
})} })}
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }} onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
onResolveApproval={(approvalId, decision, alwaysApprove) => onResolveApproval={(approvalId, decision, alwaysApprove) =>
+54 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bookmark, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react'; import { AlertCircle, ArrowUp, Bookmark, Bot, ChevronDown, ChevronRight, Circle, ClipboardList, FileText, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
@@ -30,6 +30,7 @@ import {
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern'; import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session'; import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import { import {
countApprovedToolsInGroups, countApprovedToolsInGroups,
groupApprovalToolsByProvider, groupApprovalToolsByProvider,
@@ -59,7 +60,7 @@ interface ChatPaneProps {
terminalRunning?: boolean; terminalRunning?: boolean;
gitPanelOpen?: boolean; gitPanelOpen?: boolean;
gitDirty?: boolean; gitDirty?: boolean;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>; onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode, promptInvocation?: ProjectPromptInvocation) => Promise<void>;
onCancelTurn?: () => void; onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>; onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
@@ -504,6 +505,8 @@ export function ChatPane({
onSave={(content) => handleEditSave(message.id, content)} onSave={(content) => handleEditSave(message.id, content)}
onCancel={() => setEditingMessageId(undefined)} onCancel={() => setEditingMessageId(undefined)}
/> />
) : isUser && message.promptInvocation ? (
<PromptInvocationChrome invocation={message.promptInvocation} />
) : ( ) : (
<div <div
className={ className={
@@ -783,7 +786,7 @@ export function ChatPane({
{!isScratchpad && promptFiles.length > 0 && ( {!isScratchpad && promptFiles.length > 0 && (
<InlinePromptPill <InlinePromptPill
disabled={isComposerDisabled} disabled={isComposerDisabled}
onSubmit={(content) => void onSend(content)} onSubmit={(promptInvocation) => void onSend('', undefined, undefined, promptInvocation)}
promptFiles={promptFiles} promptFiles={promptFiles}
/> />
)} )}
@@ -928,6 +931,54 @@ export function ChatPane({
); );
} }
/* ── Prompt invocation chrome ───────────────────────────────── */
function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvocation }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3">
<button
className="flex w-full items-center gap-2.5 text-left"
onClick={() => setExpanded(!expanded)}
type="button"
aria-expanded={expanded}
>
<FileText className="size-3.5 shrink-0 text-[var(--color-status-success)]" />
<span className="flex-1 text-[13px] font-medium text-[var(--color-text-primary)]">
{invocation.name}
</span>
<div className="flex items-center gap-1.5">
{invocation.agent && (
<span className="rounded bg-[var(--color-accent-sky)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{invocation.agent}
</span>
)}
{invocation.tools && invocation.tools.length > 0 && (
<span className="rounded bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
{invocation.tools.length} tool{invocation.tools.length === 1 ? '' : 's'}
</span>
)}
{expanded
? <ChevronDown className="size-3.5 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3.5 text-[var(--color-text-muted)]" />}
</div>
</button>
{invocation.description && (
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">{invocation.description}</p>
)}
<p className="mt-0.5 pl-6 text-[10px] text-[var(--color-text-muted)]">{invocation.sourcePath}</p>
{expanded && (
<div className="mt-2.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
<div className="max-h-48 overflow-y-auto text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
<MarkdownContent content={invocation.resolvedPrompt} />
</div>
</div>
)}
</div>
);
}
/* ── Branch origin banner ───────────────────────────────────── */ /* ── Branch origin banner ───────────────────────────────────── */
function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) { function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) {
@@ -578,6 +578,11 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{prompt.name}</span> <span className="text-[13px] font-medium text-[var(--color-text-primary)]">{prompt.name}</span>
{prompt.agent && (
<span className="rounded-full bg-[var(--color-accent-sky)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{prompt.agent}
</span>
)}
{prompt.variables.length > 0 && ( {prompt.variables.length > 0 && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"> <span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'} {prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
@@ -587,6 +592,18 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
{prompt.description && ( {prompt.description && (
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{prompt.description}</p> <p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{prompt.description}</p>
)} )}
{prompt.tools && prompt.tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{prompt.tools.map((tool) => (
<span
key={tool}
className="rounded-md bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]"
>
{tool}
</span>
))}
</div>
)}
{prompt.variables.length > 0 && ( {prompt.variables.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{prompt.variables.map((v) => ( {prompt.variables.map((v) => (
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import { ArrowUp, FileText, X } from 'lucide-react'; import { ArrowUp, FileText, X } from 'lucide-react';
import { useClickOutside } from '@renderer/hooks/useClickOutside'; import { useClickOutside } from '@renderer/hooks/useClickOutside';
import type { ProjectPromptFile, ProjectPromptVariable } from '@shared/domain/projectCustomization'; import type { ProjectPromptFile, ProjectPromptInvocation, ProjectPromptVariable } from '@shared/domain/projectCustomization';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):[^}]+\}/g; const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):[^}]+\}/g;
@@ -12,6 +12,21 @@ function resolvePromptTemplate(template: string, values: Record<string, string>)
}); });
} }
function buildPromptInvocation(prompt: ProjectPromptFile, resolvedTemplate: string): ProjectPromptInvocation {
const invocation: ProjectPromptInvocation = {
id: prompt.id,
name: prompt.name,
sourcePath: prompt.sourcePath,
resolvedPrompt: resolvedTemplate,
};
if (prompt.description) invocation.description = prompt.description;
if (prompt.agent) invocation.agent = prompt.agent;
if (prompt.tools?.length) invocation.tools = prompt.tools;
return invocation;
}
export function InlinePromptPill({ export function InlinePromptPill({
promptFiles, promptFiles,
disabled, disabled,
@@ -19,7 +34,7 @@ export function InlinePromptPill({
}: { }: {
promptFiles: ReadonlyArray<ProjectPromptFile>; promptFiles: ReadonlyArray<ProjectPromptFile>;
disabled: boolean; disabled: boolean;
onSubmit: (resolvedContent: string) => void; onSubmit: (invocation: ProjectPromptInvocation) => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = useState<ProjectPromptFile | null>(null); const [selectedPrompt, setSelectedPrompt] = useState<ProjectPromptFile | null>(null);
@@ -34,7 +49,7 @@ export function InlinePromptPill({
const handleSelectPrompt = useCallback((prompt: ProjectPromptFile) => { const handleSelectPrompt = useCallback((prompt: ProjectPromptFile) => {
if (prompt.variables.length === 0) { if (prompt.variables.length === 0) {
onSubmit(prompt.template.trim()); onSubmit(buildPromptInvocation(prompt, prompt.template.trim()));
handleClose(); handleClose();
} else { } else {
setSelectedPrompt(prompt); setSelectedPrompt(prompt);
@@ -46,7 +61,7 @@ export function InlinePromptPill({
if (!selectedPrompt) return; if (!selectedPrompt) return;
const resolved = resolvePromptTemplate(selectedPrompt.template, variableValues).trim(); const resolved = resolvePromptTemplate(selectedPrompt.template, variableValues).trim();
if (!resolved) return; if (!resolved) return;
onSubmit(resolved); onSubmit(buildPromptInvocation(selectedPrompt, resolved));
handleClose(); handleClose();
}, [selectedPrompt, variableValues, onSubmit, handleClose]); }, [selectedPrompt, variableValues, onSubmit, handleClose]);
@@ -127,26 +142,36 @@ function PromptList({
> >
<FileText className="mt-0.5 size-3.5 shrink-0 text-[var(--color-text-muted)]" /> <FileText className="mt-0.5 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--color-text-primary)]"> <div className="flex items-center gap-1.5">
{prompt.name} <span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
</span>
{prompt.agent && (
<span className="shrink-0 rounded bg-[var(--color-accent-sky)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{prompt.agent}
</span>
)}
</div> </div>
{prompt.description && ( {prompt.description && (
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]"> <div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">
{prompt.description} {prompt.description}
</div> </div>
)} )}
{prompt.variables.length > 0 && ( <div className="mt-1 flex flex-wrap gap-1">
<div className="mt-1 flex flex-wrap gap-1"> {prompt.tools && prompt.tools.length > 0 && (
{prompt.variables.map((v) => ( <span className="rounded bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
<span {prompt.tools.length} tool{prompt.tools.length === 1 ? '' : 's'}
key={v.name} </span>
className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]" )}
> {prompt.variables.map((v) => (
{v.name} <span
</span> key={v.name}
))} className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
</div> >
)} {v.name}
</span>
))}
</div>
</div> </div>
</button> </button>
))} ))}