165 lines
6.5 KiB
TypeScript
165 lines
6.5 KiB
TypeScript
import { useState, type ReactElement } from 'react';
|
|
import type { PendingApproval } from './use-chat-connection';
|
|
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
|
import { asNonEmptyString, asString } from './runtime-guards';
|
|
import type {
|
|
CommandManifest,
|
|
SlashCommandApprovalResultPayload,
|
|
SlashCommandResultPayload,
|
|
} from '@/lib/chat-contract';
|
|
|
|
/** Stable fallback copy shown for a failed command only when the server's
|
|
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
|
* malformed — the structured contract reason itself is otherwise shown
|
|
* directly, never a raw thrown exception, stack trace, or object value. */
|
|
const COMMAND_FAILURE_COPY = 'Command failed.';
|
|
|
|
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
|
* stored command:result message at ingestion, but this component must never
|
|
* assume every caller went through that path — bounding again here means a
|
|
* hostile/oversized message can never force an unbounded render. */
|
|
function boundMessage(value: string): string {
|
|
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
|
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
|
: value;
|
|
}
|
|
|
|
interface CommandsPanelProps {
|
|
manifest: CommandManifest | null;
|
|
results: SlashCommandResultPayload[];
|
|
approval: SlashCommandApprovalResultPayload | null;
|
|
pendingApproval: PendingApproval | null;
|
|
hasConversation: boolean;
|
|
onExecute: (input: { command: string; args?: string }) => void;
|
|
onApprove: (input: { command: string; args?: string }) => void;
|
|
onRunApproved: () => void;
|
|
}
|
|
|
|
export function CommandsPanel({
|
|
manifest,
|
|
results,
|
|
approval,
|
|
pendingApproval,
|
|
hasConversation,
|
|
onExecute,
|
|
onApprove,
|
|
onRunApproved,
|
|
}: CommandsPanelProps): ReactElement {
|
|
const [command, setCommand] = useState('');
|
|
const [args, setArgs] = useState('');
|
|
|
|
// Defense-in-depth: the reducer already normalizes success/approvalId
|
|
// before storing `approval`, but a matching command string alone must
|
|
// never be trusted here either — require the literal boolean `true` and a
|
|
// non-empty string approvalId, not merely truthy values.
|
|
const canRunApproved =
|
|
approval?.success === true &&
|
|
typeof approval.approvalId === 'string' &&
|
|
approval.approvalId.length > 0 &&
|
|
!!pendingApproval &&
|
|
pendingApproval.command === approval.command;
|
|
|
|
// A manifest arrives from the server as untyped JSON at runtime — guard
|
|
// both collections before mapping so a malformed manifest cannot throw.
|
|
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
|
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
|
|
|
return (
|
|
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
|
{commands.length > 0 ? (
|
|
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
|
{commands.map((cmd, index) => (
|
|
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
|
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
{skills.length > 0 ? (
|
|
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
|
{skills.map((skill, index) => (
|
|
<li key={asString(skill?.name) || `skill-${index}`}>
|
|
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<input
|
|
aria-label="Command name"
|
|
value={command}
|
|
onChange={(event) => setCommand(event.target.value)}
|
|
placeholder="command"
|
|
/>
|
|
<input
|
|
aria-label="Command arguments"
|
|
value={args}
|
|
onChange={(event) => setArgs(event.target.value)}
|
|
placeholder="args (optional)"
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={!hasConversation || !command.trim()}
|
|
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
|
>
|
|
Run command
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!hasConversation || !command.trim()}
|
|
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
|
>
|
|
Request approval
|
|
</button>
|
|
</div>
|
|
|
|
{approval ? (
|
|
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
|
{/* A successful approval shows stable client copy only — never
|
|
the server-controlled approval.message or echoed
|
|
approval.command as the primary confirmation. The frozen local
|
|
pendingApproval below (not this line) is the sole authoritative
|
|
statement of what will run. A denial, by contrast, is not an
|
|
execution authority and safely surfaces the guarded structured
|
|
reason the server gave (e.g. "Not authorized"), falling back to
|
|
a stable copy only when absent/malformed. */}
|
|
<span>
|
|
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
|
</span>
|
|
{canRunApproved && pendingApproval ? (
|
|
<>
|
|
{/* Authoritative frozen local command+args — what the click below
|
|
will actually emit. The server's `approval` above is display-only
|
|
and must never be trusted to represent the executed payload. */}
|
|
<span>
|
|
Will run: /{pendingApproval.command}{' '}
|
|
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
|
</span>
|
|
<button type="button" onClick={onRunApproved}>
|
|
Run approved command
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{results.length > 0 ? (
|
|
<ul aria-label="Command results" className="flex flex-col gap-1">
|
|
{results.map((result, index) => (
|
|
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
|
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
|
{result.success
|
|
? typeof result.message === 'string' && result.message
|
|
? ` — ${boundMessage(result.message)}`
|
|
: ''
|
|
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|