SPB Git

Repository Public

wait-card.tsx 15.4 KB 274 lines tsx

1'use client';2import { useState } from 'react';3import Link from 'next/link';4import { useQueryClient } from '@tanstack/react-query';5import { Check, ChevronDown, CircleHelp, Clock, ShieldAlert, ShieldCheck, X } from 'lucide-react';6import type { MissionDto, ResolveWait, WaitDto } from '@aos/shared';7import { api, errorMessage } from '@/lib/api';8import { qk } from '@/lib/queries';9import { riskOf } from '@/lib/status';10import { looksLikeUnifiedDiff, parseUnifiedDiff } from '@/lib/diff';11import { keyArgOf, obj } from '@/lib/timeline';12import { fmtDateTime, fmtDuration, fmtRelative } from '@/lib/format';13import { useBreakpoint, useNow } from '@/hooks/use-viewport';14import { timeline } from '@/stores/timeline';15import { toast } from '@/stores/toasts';16import { haptic } from '@/lib/haptic';17import { Button } from '@/components/ui/button';18import { Dialog } from '@/components/ui/dialog';19import { Textarea } from '@/components/ui/input';20import { Badge } from '@/components/ui/badge';21import { RiskBadge } from '@/components/missions/bits';22import { DiffView } from '@/components/timeline/diff-view';23import { cn } from '@/lib/cn';2425const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v : null);2627/** What an approval/question payload says, read defensively (payload is an open record). */28export function waitView(w: WaitDto) {29  const p = w.payload;30  const tool = str(p.tool);31  const input = p.input;32  const diffText = str(p.diff);33  return {34    tool,35    input,36    risk: riskOf(p.risk),37    title: str(p.title) ?? (w.kind === 'question' ? 'Question from the agent' : tool ? `Run ${tool}` : 'Approval requested'),38    detail: str(p.detail) ?? str(p.reason),39    keyArg: keyArgOf(input),40    diff: diffText && looksLikeUnifiedDiff(diffText) ? parseUnifiedDiff(diffText) : null,41    question: str(p.text) ?? str(p.question) ?? str(p.title),42    options: Array.isArray(p.options) ? p.options.filter((o): o is string => typeof o === 'string') : [],43  };44}4546export function useResolveWait(): (w: WaitDto, body: ResolveWait) => Promise<void> {47  const qc = useQueryClient();48  return async (w, body) => {49    const res = await api.post<unknown>(`/waits/${w.id}/resolve`, body);50    const r = obj(res);51    const resolution = body.kind === 'approval' ? { decision: body.decision, remember: body.remember, note: body.note ?? null } : { answer: body.answer };52    timeline.rememberResolved(typeof r.id === 'string' && typeof r.status === 'string' ? (res as WaitDto) : { ...w, status: 'resolved', resolution, resolvedAt: new Date().toISOString() });53    qc.setQueryData<WaitDto[]>(qk.waits, (l) => l?.filter((x) => x.id !== w.id));54    qc.setQueryData<MissionDto[]>(qk.missions, (l) => l?.map((m) => (m.id === w.missionId ? { ...m, pendingWaits: Math.max(0, m.pendingWaits - 1) } : m)));55    void qc.invalidateQueries({ queryKey: qk.waits });56    void qc.invalidateQueries({ queryKey: qk.missions });57    void qc.invalidateQueries({ queryKey: qk.mission(w.missionId) });58    if (body.kind === 'approval' && body.remember) void qc.invalidateQueries({ queryKey: qk.policy });59    timeline.sync(w.runId);60  };61}6263/** Time left before the wait expires (expiry = deny). */64function Expiry({ resolveBy }: { resolveBy: string | null }) {65  const now = useNow(resolveBy ? 1000 : 60_000);66  if (!resolveBy) return null;67  const left = (new Date(resolveBy).getTime() - now) / 1000;68  if (!Number.isFinite(left)) return null;69  const soon = left < 3600;70  return (71    <span className={cn('tnum inline-flex items-center gap-1', soon ? 'font-medium text-warning' : 'text-muted')} title={`Expires ${fmtDateTime(resolveBy)}; an expired request counts as a denial`} data-testid="wait-expiry">72      <Clock className="size-3" aria-hidden /> {left <= 0 ? 'expiring' : `expires in ${fmtDuration(left)}`}73    </span>74  );75}7677function ArgsBlock({ input }: { input: unknown }) {78  const [open, setOpen] = useState(false);79  const text = typeof input === 'string' ? input : JSON.stringify(input ?? {}, null, 2);80  const long = text.split('\n').length > 8 || text.length > 600;81  return (82    <div className="relative">83      <pre data-testid="wait-args" className={cn('overflow-x-auto whitespace-pre-wrap break-words rounded-sm bg-surface-2 px-3 py-2 font-mono text-[12px] leading-relaxed text-text', long && !open && 'max-h-40 overflow-y-hidden')}>{text}</pre>84      {long && (85        <button onClick={() => setOpen(!open)} className="mt-1 inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs font-medium text-accent hover:bg-hover pointer-coarse:min-h-9">86          <ChevronDown className={cn('size-3 transition-transform', open && 'rotate-180')} aria-hidden /> {open ? 'Show less' : 'Show all arguments'}87        </button>88      )}89    </div>90  );91}9293/**94 * One pending approval or question, with its actions. Used on Home ("Needs you"), in the95 * Approvals inbox and inline in the mission timeline.96 *   layout="card"   full card (Home, inbox)97 *   layout="inline" no outer chrome (timeline row provides it)98 *   compact         the diff starts folded (Home)99 */100export function WaitCard({ wait, layout = 'card', showMission = true, compact = false }: { wait: WaitDto; layout?: 'card' | 'inline'; showMission?: boolean; compact?: boolean }) {101  const v = waitView(wait);102  const resolve = useResolveWait();103  const now = useNow(30_000);104  const [busy, setBusy] = useState<string | null>(null);105  const [denying, setDenying] = useState(false);106  const [note, setNote] = useState('');107  const [remember, setRemember] = useState(false);108  const [answer, setAnswer] = useState('');109  const [showDiff, setShowDiff] = useState<boolean | null>(null);110  const diffAdded = v.diff?.reduce((n, f) => n + f.added, 0) ?? 0;111  const diffRemoved = v.diff?.reduce((n, f) => n + f.removed, 0) ?? 0;112  const bp = useBreakpoint();113  // Folded where space is scarce (Home cards, phones) so that the actions stay in reach; one tap unfolds.114  const diffSmall = !compact && diffAdded + diffRemoved <= (bp === 'mobile' ? 6 : 40);115  const act = async (key: string, body: ResolveWait, done: string) => {116    setBusy(key);117    haptic(10);118    try {119      await resolve(wait, body);120      toast.success(done, wait.missionTitle);121      setRemember(false);122    } catch (e) {123      toast.error('Could not send your decision', errorMessage(e));124    } finally {125      setBusy(null);126    }127  };128  const question = wait.kind === 'question';129  const Icon = question ? CircleHelp : v.risk === 'external' || v.risk === 'network' ? ShieldAlert : ShieldCheck;130131  return (132    <article133      data-testid="wait-card"134      data-wait-id={wait.id}135      data-kind={wait.kind}136      aria-label={`${question ? 'Question' : 'Approval'}: ${v.title}`}137      className={cn('flex min-w-0 flex-col gap-3', layout === 'card' && 'card border-info/30 p-4 md:p-5')}138    >139      <header className="flex items-start gap-3">140        <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-info/12 text-info-ink">141          <Icon className="size-[18px]" aria-hidden />142        </span>143        <div className="min-w-0 flex-1">144          <div className="flex flex-wrap items-center gap-x-2 gap-y-1">145            <h3 className="min-w-0 text-[15px] font-semibold leading-snug tracking-tight text-text">{question ? (v.question ?? v.title) : v.title}</h3>146            {!question && <RiskBadge risk={v.risk} />}147          </div>148          <p className="mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-xs text-muted">149            {showMission && (150              <Link href={`/mission/?id=${wait.missionId}`} className="max-w-full truncate font-medium text-text-2 underline-offset-2 hover:text-text hover:underline">151                {wait.missionTitle}152              </Link>153            )}154            <span className="tnum" title={fmtDateTime(wait.createdAt)}>waiting since {fmtRelative(wait.createdAt, now)}</span>155            <Expiry resolveBy={wait.resolveBy} />156          </p>157        </div>158      </header>159160      {!question && v.detail && <p className="whitespace-pre-line text-[13px] leading-relaxed text-text-2 md:text-[13.5px]">{v.detail}</p>}161162      {!question && (163        <div className="flex flex-col gap-2">164          <div className="flex items-center gap-2 text-xs text-muted">165            <span className="font-semibold uppercase tracking-wide">Exact call</span>166            {v.tool && <Badge tone="neutral" className="font-mono">{v.tool}</Badge>}167          </div>168          <ArgsBlock input={v.input} />169          {v.diff && v.diff.length > 0 && (170            <>171              <button type="button" onClick={() => setShowDiff(!(showDiff ?? diffSmall))} aria-expanded={showDiff ?? diffSmall} data-testid="wait-diff-toggle" className="flex min-h-9 items-center gap-2 self-start rounded-sm px-2 text-xs font-medium text-accent hover:bg-hover pointer-coarse:min-h-11">172                <ChevronDown className={cn('size-3.5 transition-transform', (showDiff ?? diffSmall) && 'rotate-180')} aria-hidden />173                {(showDiff ?? diffSmall) ? 'Hide changes' : 'Show changes'}174                <span className="tnum font-mono font-normal text-muted">{v.diff.length} file{v.diff.length === 1 ? '' : 's'} <span className="text-success">+{diffAdded}</span> <span className="text-error">−{diffRemoved}</span></span>175              </button>176              {(showDiff ?? diffSmall) && <DiffView files={v.diff} />}177            </>178          )}179        </div>180      )}181182      {question ? (183        <div className="flex flex-col gap-2.5">184          {v.detail && v.detail !== v.question && <p className="whitespace-pre-line text-[13px] leading-relaxed text-text-2">{v.detail}</p>}185          {v.options.length > 0 && (186            <div className="flex flex-col gap-2" role="group" aria-label="Suggested answers">187              {v.options.map((o) => (188                <Button key={o} variant="secondary" className="h-auto min-h-9 justify-start whitespace-normal px-3.5 py-2 text-left pointer-coarse:min-h-12 pointer-coarse:rounded-[14px]" loading={busy === `o:${o}`} disabled={busy !== null} onClick={() => act(`o:${o}`, { kind: 'question', answer: o }, 'Answer sent')} data-testid="wait-option">189                  {o}190                </Button>191              ))}192            </div>193          )}194          <form195            className="flex flex-col gap-2 sm:flex-row sm:items-end"196            onSubmit={(e) => {197              e.preventDefault();198              if (answer.trim()) void act('answer', { kind: 'question', answer: answer.trim() }, 'Answer sent');199            }}200          >201            <Textarea value={answer} onChange={(e) => setAnswer(e.target.value)} rows={2} maxLength={8000} placeholder={v.options.length ? 'Or write your own answer…' : 'Write your answer…'} aria-label="Your answer" className="min-h-[52px] flex-1 pointer-coarse:rounded-[14px]" data-testid="wait-answer" />202            <Button type="submit" variant="brand" className="h-9 px-4 font-semibold pointer-coarse:h-12" loading={busy === 'answer'} disabled={!answer.trim() || busy !== null} data-testid="wait-answer-send">203              Send answer204            </Button>205          </form>206        </div>207      ) : denying ? (208        <div className="flex flex-col gap-2">209          <Textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={2000} autoFocus placeholder="Optional note for the agent — why, or what to do instead" aria-label="Note for the agent" className="min-h-[52px] pointer-coarse:rounded-[14px]" data-testid="wait-deny-note" />210          <div className="flex gap-2">211            <Button variant="ghost" className="flex-1 pointer-coarse:h-12 sm:flex-none" onClick={() => setDenying(false)} disabled={busy !== null}>Back</Button>212            <Button variant="danger" className="flex-1 font-semibold pointer-coarse:h-12 sm:flex-none" loading={busy === 'deny'} disabled={busy !== null} onClick={() => act('deny', { kind: 'approval', decision: 'deny', remember: false, note: note.trim() || undefined }, 'Denied')} data-testid="wait-deny-confirm">213              <X className="size-4" aria-hidden /> Deny214            </Button>215          </div>216        </div>217      ) : (218        <div className="flex flex-col gap-2">219          <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">220            <Button variant="brand" className="h-9 px-5 font-semibold pointer-coarse:h-12 sm:order-1" loading={busy === 'approve'} disabled={busy !== null} onClick={() => act('approve', { kind: 'approval', decision: 'approve', remember: false }, 'Approved')} data-testid="wait-approve">221              <Check className="size-4" strokeWidth={2.6} aria-hidden /> Approve222            </Button>223            <Button variant="secondary" className="h-9 px-5 pointer-coarse:h-12 sm:order-3" disabled={busy !== null} onClick={() => setDenying(true)} data-testid="wait-deny">224              <X className="size-4" aria-hidden /> Deny225            </Button>226            <Button variant="ghost" className="col-span-2 h-9 pointer-coarse:h-11 sm:order-2" disabled={busy !== null} onClick={() => setRemember(true)} data-testid="wait-remember">227              Approve and remember…228            </Button>229          </div>230        </div>231      )}232233      <Dialog234        open={remember}235        onOpenChange={setRemember}236        title="Approve and always allow?"237        size="sm"238        description="This approves the call and creates a standing rule."239        footer={240          <>241            <Button variant="ghost" onClick={() => setRemember(false)}>Cancel</Button>242            <Button variant="primary" loading={busy === 'remember'} onClick={() => act('remember', { kind: 'approval', decision: 'approve', remember: true }, 'Approved — rule created')} data-testid="wait-remember-confirm">243              Approve and create rule244            </Button>245          </>246        }247      >248        <div className="flex flex-col gap-3 text-[13px] leading-relaxed text-text-2" data-testid="remember-explain">249          <p>250            A new <span className="font-semibold text-success">allow</span> rule will match the tool <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[12px] text-text">{v.tool ?? 'this tool'}</code>251            {v.tool === 'shell' && v.keyArg ? <> for commands that start like <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[12px] text-text">{v.keyArg.split(/\s+/).slice(0, 2).join(' ')}</code></> : null} in this mission&rsquo;s project.252          </p>253          <ul className="list-disc space-y-1 pl-5">254            <li>Matching calls will run <span className="font-medium text-text">without asking you again</span>, in this and future missions of the project.</li>255            <li>Hard limits still apply: protected paths and the sandbox cannot be overridden by a rule.</li>256            <li>You can review or delete the rule at any time in <span className="font-medium text-text">Policy</span>.</li>257          </ul>258        </div>259      </Dialog>260    </article>261  );262}263264/** Outcome of a wait that is no longer pending (timeline). */265export function WaitOutcome({ wait }: { wait: Pick<WaitDto, 'kind' | 'status' | 'resolution'> }) {266  const r = obj(wait.resolution);267  if (wait.status === 'expired') return <Badge tone="warning">Expired — treated as denied</Badge>;268  if (wait.status === 'cancelled') return <Badge tone="muted">Cancelled</Badge>;269  if (wait.kind === 'question') return <Badge tone="success">Answered</Badge>;270  if (r.decision === 'approve') return <Badge tone="success"><Check className="size-3" aria-hidden /> Approved{r.remember === true ? ' · rule created' : ''}</Badge>;271  if (r.decision === 'deny') return <Badge tone="error"><X className="size-3" aria-hidden /> Denied</Badge>;272  return <Badge tone="neutral">Resolved</Badge>;273}274
Files main
Menu

Theme