fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
b2e005f2b4
commit
caebf9ef70
@@ -1,11 +1,16 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import type { PendingApproval } from './use-chat-connection';
|
||||
import { asString } from './runtime-guards';
|
||||
import type {
|
||||
CommandManifest,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandResultPayload,
|
||||
} from '@/lib/chat-contract';
|
||||
|
||||
/** Stable client copy shown for a failed command — never the raw server
|
||||
* detail, which could leak internal error text to the user. */
|
||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||
|
||||
interface CommandsPanelProps {
|
||||
manifest: CommandManifest | null;
|
||||
results: SlashCommandResultPayload[];
|
||||
@@ -30,19 +35,39 @@ export function CommandsPanel({
|
||||
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 &&
|
||||
!!approval.approvalId &&
|
||||
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">
|
||||
{manifest && manifest.commands.length > 0 ? (
|
||||
{commands.length > 0 ? (
|
||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||
{manifest.commands.map((cmd) => (
|
||||
<li key={cmd.name}>
|
||||
<strong>/{cmd.name}</strong> — {cmd.description}
|
||||
{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>
|
||||
@@ -79,16 +104,24 @@ export function CommandsPanel({
|
||||
|
||||
{approval ? (
|
||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||
<span>
|
||||
{approval.success
|
||||
? `Approved: /${approval.command}`
|
||||
: `Approval denied: /${approval.command}`}
|
||||
{approval.message ? ` — ${approval.message}` : ''}
|
||||
</span>
|
||||
{canRunApproved ? (
|
||||
<button type="button" onClick={onRunApproved}>
|
||||
Run approved command
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<span>{approval.success ? 'Approved.' : '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}
|
||||
@@ -97,8 +130,12 @@ export function CommandsPanel({
|
||||
<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'}>
|
||||
/{result.command}: {result.success ? 'success' : 'failed'}
|
||||
{result.message ? ` — ${result.message}` : ''}
|
||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||
{result.success
|
||||
? typeof result.message === 'string' && result.message
|
||||
? ` — ${result.message}`
|
||||
: ''
|
||||
: ` — ${COMMAND_FAILURE_COPY}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user