Repository Public
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’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