feat: phase 3 frontend — model badges, argument-hint composer, ancestor path display

Surface prompt model and argumentHint metadata across the UI:

- InlinePromptPill: send model in promptInvocation, show model badge
  in dropdown, show argumentHint preview, support armed-prompt state
  for prompts with argumentHint (prompt stays pinned, hint becomes
  composer placeholder, user types additional context before sending)
- ChatPane: armed-prompt state management, argumentHint-driven
  placeholder, send button enabled when prompt is armed, clear armed
  state on session change
- PromptInvocationChrome: model badge in transcript, parent-repo badge
  and shortened source path display for ancestor-relative paths
- ProjectSettingsPanel: model and argumentHint on prompt cards,
  parent-repo badge on instructions and prompts with ancestor paths,
  .claude/rules mentioned in instructions description and empty state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 12:53:38 +02:00
co-authored by Copilot
parent 6eadb36c10
commit a18803758b
3 changed files with 190 additions and 49 deletions
+65 -23
View File
@@ -10,7 +10,7 @@ import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { InlinePromptPill, type ArmedPrompt } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
@@ -193,6 +193,7 @@ export function ChatPane({
const canSubmitInput = hasComposerContent && !isComposerDisabled;
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
const promptFiles = useMemo(() => project.customization?.promptFiles ?? [], [project.customization?.promptFiles]);
const [armedPrompt, setArmedPrompt] = useState<ArmedPrompt | null>(null);
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
const mcpServers = toolingSettings.mcpServers;
@@ -232,13 +233,24 @@ export function ChatPane({
setIsResolvingApproval(false);
setIsUpdatingSessionModelConfig(false);
setEditingMessageId(undefined);
setArmedPrompt(null);
}, [session.id]);
function handleComposerSubmit(content: string) {
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined;
setPendingAttachments([]);
void onSend(content, attachments, messageMode);
if (armedPrompt) {
const invocation = { ...armedPrompt.invocation };
if (content.trim()) {
invocation.resolvedPrompt = `${invocation.resolvedPrompt}\n\n${content.trim()}`;
}
setArmedPrompt(null);
void onSend('', attachments, messageMode, invocation);
} else {
void onSend(content, attachments, messageMode);
}
}
const handleCopyMessage = useCallback((content: string) => {
@@ -747,21 +759,23 @@ export function ChatPane({
onContentChange={setHasComposerContent}
onSubmit={handleComposerSubmit}
placeholder={
pendingApproval
? 'Awaiting approval...'
: pendingUserInput
? 'Awaiting your input above...'
: pendingPlanReview
? 'Review the plan above...'
: pendingMcpAuth
? 'MCP server requires authentication...'
: isSessionBusy
? 'Steer the agent (sends immediately)...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
? 'Describe what to plan...'
: 'Message...'
armedPrompt?.prompt.argumentHint
? armedPrompt.prompt.argumentHint
: pendingApproval
? 'Awaiting approval...'
: pendingUserInput
? 'Awaiting your input above...'
: pendingPlanReview
? 'Review the plan above...'
: pendingMcpAuth
? 'MCP server requires authentication...'
: isSessionBusy
? 'Steer the agent (sends immediately)...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
? 'Describe what to plan...'
: 'Message...'
}
>
{/* Bottom action bar: left = shortcuts, right = buttons */}
@@ -785,7 +799,10 @@ export function ChatPane({
)}
{!isScratchpad && promptFiles.length > 0 && (
<InlinePromptPill
armedPrompt={armedPrompt}
disabled={isComposerDisabled}
onArm={setArmedPrompt}
onDisarm={() => setArmedPrompt(null)}
onSubmit={(promptInvocation) => void onSend('', undefined, undefined, promptInvocation)}
promptFiles={promptFiles}
/>
@@ -847,9 +864,9 @@ export function ChatPane({
{/* Send / Stop / Steer button */}
<button
className={`flex size-8 items-center justify-center rounded-lg transition-all duration-150 ${
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt
? 'bg-[var(--color-status-error)]/80 text-white hover:bg-[var(--color-status-error)]'
: canSubmitInput || pendingAttachments.length > 0
: canSubmitInput || pendingAttachments.length > 0 || armedPrompt
? isSessionBusy
? 'bg-[var(--color-status-warning)] text-white hover:brightness-110'
: isPlanMode
@@ -857,9 +874,9 @@ export function ChatPane({
: 'brand-gradient-bg text-white shadow-[0_2px_12px_rgba(36,92,249,0.25)] hover:shadow-[0_4px_20px_rgba(36,92,249,0.35)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)]'
}`}
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0 && !armedPrompt}
onClick={() => {
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt) {
onCancelTurn?.();
} else {
composerRef.current?.submit();
@@ -867,7 +884,7 @@ export function ChatPane({
}}
type="button"
aria-label={
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt
? 'Stop generating'
: isSessionBusy
? 'Steer agent'
@@ -933,8 +950,19 @@ export function ChatPane({
/* ── Prompt invocation chrome ───────────────────────────────── */
function formatInvocationSourcePath(sourcePath: string): string {
const normalized = sourcePath.replace(/\\/g, '/');
const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//;
if (ancestorPrefix.test(normalized)) {
const segments = normalized.split('/').filter((s) => s !== '..');
return `${segments.join('/')}`;
}
return normalized;
}
function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvocation }) {
const [expanded, setExpanded] = useState(false);
const isAncestor = invocation.sourcePath.startsWith('..');
return (
<div className="rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3">
@@ -949,6 +977,11 @@ function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvoc
{invocation.name}
</span>
<div className="flex items-center gap-1.5">
{invocation.model && (
<span className="rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{invocation.model}
</span>
)}
{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}
@@ -967,7 +1000,16 @@ function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvoc
{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>
<div className="mt-0.5 flex items-center gap-1.5 pl-6">
{isAncestor && (
<span className="rounded bg-[var(--color-surface-3)] px-1 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
<span className="text-[10px] text-[var(--color-text-muted)]" title={invocation.sourcePath}>
{formatInvocationSourcePath(invocation.sourcePath)}
</span>
</div>
{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)]">
@@ -9,6 +9,13 @@ import type { ProjectAgentProfile, ProjectInstructionApplicationMode, ProjectIns
/* ── Types ────────────────────────────────────────────────── */
/** Shortens ancestor-relative source paths like `..\..\..\.github\prompts\x.md` to `↑ .github/prompts/x.md` */
function formatSettingsSourcePath(sourcePath: string): string {
const normalized = sourcePath.replace(/\\/g, '/');
const segments = normalized.split('/').filter((s) => s !== '..');
return `${segments.join('/')}`;
}
type ProjectSettingsSection = 'overview' | 'instructions' | 'agents' | 'prompts' | 'mcp-servers' | 'danger-zone';
interface NavItem {
@@ -335,7 +342,7 @@ function InstructionsContent({
return (
<div>
<SectionHeader
description="Repository instructions discovered from copilot-instructions.md, AGENTS.md, CLAUDE.md, and .github/instructions/**/*.instructions.md."
description="Repository instructions discovered from copilot-instructions.md, AGENTS.md, CLAUDE.md, .github/instructions/**/*.instructions.md, and .claude/rules/**/*.md."
title="Instructions"
>
<RescanButton onClick={onRescan} />
@@ -343,7 +350,7 @@ function InstructionsContent({
{instructions.length === 0 ? (
<EmptyState>
No instruction files found. Add <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code>, <code className="text-[var(--color-text-secondary)]">AGENTS.md</code>, <code className="text-[var(--color-text-secondary)]">CLAUDE.md</code>, or <code className="text-[var(--color-text-secondary)]">*.instructions.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/instructions/</code>.
No instruction files found. Add <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code>, <code className="text-[var(--color-text-secondary)]">AGENTS.md</code>, <code className="text-[var(--color-text-secondary)]">CLAUDE.md</code>, <code className="text-[var(--color-text-secondary)]">*.instructions.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/instructions/</code>, or <code className="text-[var(--color-text-secondary)]">*.md</code> files under <code className="text-[var(--color-text-secondary)]">.claude/rules/</code>.
</EmptyState>
) : (
<div className="space-y-3">
@@ -359,7 +366,8 @@ function InstructionsContent({
function InstructionCard({ instruction }: { instruction: ProjectInstructionFile }) {
const [expanded, setExpanded] = useState(false);
const isLong = instruction.content.length > 300;
const displayName = instruction.name ?? instruction.sourcePath;
const isAncestor = instruction.sourcePath.startsWith('..');
const displayName = instruction.name ?? (isAncestor ? formatSettingsSourcePath(instruction.sourcePath) : instruction.sourcePath);
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)]">
@@ -370,8 +378,13 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
>
<FileCode2 className="size-4 shrink-0 text-[var(--color-text-accent)]" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{displayName}</span>
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]" title={instruction.sourcePath}>{displayName}</span>
<InstructionModeBadge mode={instruction.applicationMode} />
{isAncestor && (
<span className="shrink-0 rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
{instruction.applyTo && (
<span
className="truncate rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
@@ -571,6 +584,8 @@ function PromptsContent({
}
function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
const isAncestor = prompt.sourcePath.startsWith('..');
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] px-5 py-4">
<div className="flex items-start gap-3">
@@ -578,6 +593,11 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{prompt.name}</span>
{prompt.model && (
<span className="rounded-full bg-[var(--color-accent-purple)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</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}
@@ -588,10 +608,20 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
</span>
)}
{isAncestor && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
</div>
{prompt.description && (
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{prompt.description}</p>
)}
{prompt.argumentHint && (
<p className="mt-1 text-[11px] italic text-[var(--color-text-muted)]">
Hint: {prompt.argumentHint}
</p>
)}
{prompt.tools && prompt.tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{prompt.tools.map((tool) => (
@@ -617,7 +647,9 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
))}
</div>
)}
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">{prompt.sourcePath}</p>
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]" title={prompt.sourcePath}>
{isAncestor ? formatSettingsSourcePath(prompt.sourcePath) : prompt.sourcePath}
</p>
</div>
</div>
</div>
@@ -22,18 +22,41 @@ function buildPromptInvocation(prompt: ProjectPromptFile, resolvedTemplate: stri
if (prompt.description) invocation.description = prompt.description;
if (prompt.agent) invocation.agent = prompt.agent;
if (prompt.model) invocation.model = prompt.model;
if (prompt.tools?.length) invocation.tools = prompt.tools;
return invocation;
}
/** Returns a human-friendly display label for source paths, shortening ancestor-relative segments. */
function formatPromptSourcePath(sourcePath: string): string {
const normalized = sourcePath.replace(/\\/g, '/');
const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//;
if (ancestorPrefix.test(normalized)) {
const segments = normalized.split('/').filter((s) => s !== '..');
return `${segments.join('/')}`;
}
return normalized;
}
export interface ArmedPrompt {
prompt: ProjectPromptFile;
invocation: ProjectPromptInvocation;
}
export function InlinePromptPill({
promptFiles,
disabled,
armedPrompt,
onArm,
onDisarm,
onSubmit,
}: {
promptFiles: ReadonlyArray<ProjectPromptFile>;
disabled: boolean;
armedPrompt?: ArmedPrompt | null;
onArm?: (armed: ArmedPrompt) => void;
onDisarm?: () => void;
onSubmit: (invocation: ProjectPromptInvocation) => void;
}) {
const [open, setOpen] = useState(false);
@@ -47,23 +70,32 @@ export function InlinePromptPill({
setVariableValues({});
}, []);
const armOrSubmit = useCallback((prompt: ProjectPromptFile, resolvedTemplate: string) => {
const invocation = buildPromptInvocation(prompt, resolvedTemplate);
if (prompt.argumentHint && onArm) {
onArm({ prompt, invocation });
handleClose();
} else {
onSubmit(invocation);
handleClose();
}
}, [onArm, onSubmit, handleClose]);
const handleSelectPrompt = useCallback((prompt: ProjectPromptFile) => {
if (prompt.variables.length === 0) {
onSubmit(buildPromptInvocation(prompt, prompt.template.trim()));
handleClose();
armOrSubmit(prompt, prompt.template.trim());
} else {
setSelectedPrompt(prompt);
setVariableValues({});
}
}, [onSubmit, handleClose]);
}, [armOrSubmit]);
const handleSubmitWithVariables = useCallback(() => {
if (!selectedPrompt) return;
const resolved = resolvePromptTemplate(selectedPrompt.template, variableValues).trim();
if (!resolved) return;
onSubmit(buildPromptInvocation(selectedPrompt, resolved));
handleClose();
}, [selectedPrompt, variableValues, onSubmit, handleClose]);
armOrSubmit(selectedPrompt, resolved);
}, [selectedPrompt, variableValues, armOrSubmit]);
const handleVariableChange = useCallback((name: string, value: string) => {
setVariableValues((prev) => ({ ...prev, [name]: value }));
@@ -78,18 +110,31 @@ export function InlinePromptPill({
return (
<div className="relative" ref={ref}>
<button
aria-expanded={open}
aria-haspopup="listbox"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<FileText className="size-3" />
Prompts
<span className="text-[var(--color-text-muted)]">({promptFiles.length})</span>
</button>
{armedPrompt ? (
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-status-success)]/10 px-2.5 py-1 text-[11px] font-medium text-[var(--color-status-success)] transition-all duration-200 hover:bg-[var(--color-status-success)]/20"
onClick={() => onDisarm?.()}
type="button"
aria-label={`Disarm prompt: ${armedPrompt.prompt.name}`}
>
<FileText className="size-3" />
<span className="max-w-[120px] truncate">{armedPrompt.prompt.name}</span>
<X className="size-3 opacity-60" />
</button>
) : (
<button
aria-expanded={open}
aria-haspopup="listbox"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<FileText className="size-3" />
Prompts
<span className="text-[var(--color-text-muted)]">({promptFiles.length})</span>
</button>
)}
{open && !disabled && (
<div
@@ -151,6 +196,11 @@ function PromptList({
{prompt.agent}
</span>
)}
{prompt.model && (
<span className="shrink-0 rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</span>
)}
</div>
{prompt.description && (
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">
@@ -163,6 +213,11 @@ function PromptList({
{prompt.tools.length} tool{prompt.tools.length === 1 ? '' : 's'}
</span>
)}
{prompt.argumentHint && (
<span className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] italic text-[var(--color-text-muted)]">
hint: {prompt.argumentHint}
</span>
)}
{prompt.variables.map((v) => (
<span
key={v.name}
@@ -172,6 +227,11 @@ function PromptList({
</span>
))}
</div>
{prompt.sourcePath.startsWith('..') && (
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{formatPromptSourcePath(prompt.sourcePath)}
</div>
)}
</div>
</button>
))}
@@ -206,8 +266,15 @@ function PromptVariableForm({
<X className="size-3" />
</button>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
</span>
{prompt.model && (
<span className="shrink-0 rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</span>
)}
</div>
{prompt.description && (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{prompt.description}</div>
@@ -238,7 +305,7 @@ function PromptVariableForm({
type="button"
>
<ArrowUp className="size-3.5" />
Send prompt
{prompt.argumentHint ? 'Arm prompt' : 'Send prompt'}
</button>
</div>
);