| 1 | import { useState } from "react"; |
| 2 | import { Check, ChevronDown, ChevronUp, CornerDownRight, Pencil, Trash2, X } from "lucide-react"; |
| 3 | import { guidanceHasKnownPendingState, guidanceIsEditable, guidanceIsInFlight, guidanceNeedsRetry } from "../lib/composerGuidance"; |
| 4 | import { useI18n } from "../lib/i18n"; |
| 5 | import type { StructuredInvocationSubmit } from "../lib/invocationDisplay"; |
| 6 | import { InboxRecoveryBanner } from "./InboxRecoveryBanner"; |
| 7 | import { Tooltip } from "./Tooltip"; |
| 8 | |
| 9 | export type PendingGuidance = { |
| 10 | id: string; |
| 11 | text: string; |
| 12 | submitText: string; |
| 13 | state?: string; |
| 14 | intent?: string; |
| 15 | source?: string; |
| 16 | paused?: boolean; |
| 17 | recoveredCount?: number; |
| 18 | structured?: StructuredInvocationSubmit; |
| 19 | }; |
| 20 | |
| 21 | export type InboxRecoveryNotice = { |
| 22 | draftKey: string; |
| 23 | tabId: string; |
| 24 | count: number; |
| 25 | recovered: boolean; |
| 26 | }; |
| 27 | |
| 28 | export function ComposerGuidanceShelf({ |
| 29 | recovery, |
| 30 | recoveryDisabled, |
| 31 | items, |
| 32 | expanded, |
| 33 | running, |
| 34 | disabled, |
| 35 | readOnly, |
| 36 | sendingId, |
| 37 | onReview, |
| 38 | onRecoveryResumed, |
| 39 | onRecoveryError, |
| 40 | onToggleExpanded, |
| 41 | onSend, |
| 42 | onDismiss, |
| 43 | onEdit, |
| 44 | }: { |
| 45 | recovery: InboxRecoveryNotice | null; |
| 46 | recoveryDisabled: boolean; |
| 47 | items: PendingGuidance[]; |
| 48 | expanded: boolean; |
| 49 | running: boolean; |
| 50 | disabled: boolean; |
| 51 | readOnly: boolean; |
| 52 | sendingId: string | null; |
| 53 | onReview: () => void; |
| 54 | onRecoveryResumed: () => void; |
| 55 | onRecoveryError: (error: unknown) => void; |
| 56 | onToggleExpanded: () => void; |
| 57 | onSend: (item: PendingGuidance) => void; |
| 58 | onDismiss: (item: PendingGuidance) => void; |
| 59 | onEdit?: (item: PendingGuidance, text: string) => void | Promise<void>; |
| 60 | }) { |
| 61 | const { t } = useI18n(); |
| 62 | const visible = expanded ? items : items.slice(0, 2); |
| 63 | const hiddenCount = Math.max(0, items.length - 2); |
| 64 | const [editingId, setEditingId] = useState<string | null>(null); |
| 65 | const [editDraft, setEditDraft] = useState(""); |
| 66 | const [editBusy, setEditBusy] = useState(false); |
| 67 | |
| 68 | const startEdit = (item: PendingGuidance) => { |
| 69 | setEditingId(item.id); |
| 70 | setEditDraft(item.submitText.trim() || item.text); |
| 71 | }; |
| 72 | |
| 73 | const cancelEdit = () => { |
| 74 | setEditingId(null); |
| 75 | setEditDraft(""); |
| 76 | setEditBusy(false); |
| 77 | }; |
| 78 | |
| 79 | const saveEdit = async (item: PendingGuidance) => { |
| 80 | if (!onEdit || editBusy) return; |
| 81 | const next = editDraft.trim(); |
| 82 | if (!next) return; |
| 83 | setEditBusy(true); |
| 84 | try { |
| 85 | await onEdit(item, next); |
| 86 | cancelEdit(); |
| 87 | } catch { |
| 88 | setEditBusy(false); |
| 89 | } |
| 90 | }; |
| 91 | |
| 92 | return ( |
| 93 | <> |
| 94 | {recovery && ( |
| 95 | <InboxRecoveryBanner |
| 96 | key={`${recovery.draftKey}:${recovery.tabId}`} |
| 97 | count={recovery.count} |
| 98 | recovered={recovery.recovered} |
| 99 | disabled={recoveryDisabled} |
| 100 | tabId={recovery.tabId} |
| 101 | onReview={onReview} |
| 102 | onResumed={onRecoveryResumed} |
| 103 | onError={onRecoveryError} |
| 104 | /> |
| 105 | )} |
| 106 | {items.length > 0 && ( |
| 107 | <div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}> |
| 108 | <div className="composer-guidance-head"> |
| 109 | <span className="composer-guidance-head__label"> |
| 110 | <CornerDownRight size={14} /> |
| 111 | <span>{t("composer.guidanceCount", { n: items.length })}</span> |
| 112 | </span> |
| 113 | </div> |
| 114 | <div className="composer-guidance-list"> |
| 115 | {visible.map((item, index) => { |
| 116 | const inFlight = guidanceIsInFlight(item.state); |
| 117 | const unknownState = !guidanceHasKnownPendingState(item.state); |
| 118 | const needsRetry = guidanceNeedsRetry(item.state); |
| 119 | const waitingForEarlier = !running && !inFlight && index > 0; |
| 120 | const canEdit = Boolean(onEdit) && !readOnly && !disabled && guidanceIsEditable(item) && !waitingForEarlier && sendingId === null; |
| 121 | const editing = editingId === item.id; |
| 122 | const actionLabel = inFlight |
| 123 | ? t("composer.guidanceInFlight") |
| 124 | : waitingForEarlier |
| 125 | ? t("composer.guidanceWaiting") |
| 126 | : needsRetry |
| 127 | ? t("composer.guidanceRetry") |
| 128 | : t("composer.guidanceSend"); |
| 129 | return ( |
| 130 | <div className={`composer-guidance-item${editing ? " composer-guidance-item--editing" : ""}`} key={item.id}> |
| 131 | <CornerDownRight size={14} className="composer-guidance-item__icon" /> |
| 132 | {editing ? ( |
| 133 | <input |
| 134 | className="composer-guidance-item__editor" |
| 135 | value={editDraft} |
| 136 | onChange={(event) => setEditDraft(event.target.value)} |
| 137 | aria-label={t("composer.guidanceEdit")} |
| 138 | disabled={editBusy} |
| 139 | onKeyDown={(event) => { |
| 140 | if (event.key === "Enter") { |
| 141 | event.preventDefault(); |
| 142 | void saveEdit(item); |
| 143 | } |
| 144 | if (event.key === "Escape") { |
| 145 | event.preventDefault(); |
| 146 | cancelEdit(); |
| 147 | } |
| 148 | }} |
| 149 | /> |
| 150 | ) : ( |
| 151 | <span className="composer-guidance-item__text">{item.text.trim() || t("composer.guidanceEmptyPreview")}</span> |
| 152 | )} |
| 153 | {editing ? ( |
| 154 | <> |
| 155 | <Tooltip label={t("composer.guidanceSaveEdit")}> |
| 156 | <button |
| 157 | className="composer-guidance-item__action" |
| 158 | type="button" |
| 159 | aria-label={t("composer.guidanceSaveEdit")} |
| 160 | disabled={editBusy || !editDraft.trim()} |
| 161 | onClick={() => void saveEdit(item)} |
| 162 | > |
| 163 | <Check size={14} /> |
| 164 | </button> |
| 165 | </Tooltip> |
| 166 | <Tooltip label={t("composer.guidanceCancelEdit")}> |
| 167 | <button |
| 168 | className="composer-guidance-item__action" |
| 169 | type="button" |
| 170 | aria-label={t("composer.guidanceCancelEdit")} |
| 171 | disabled={editBusy} |
| 172 | onClick={cancelEdit} |
| 173 | > |
| 174 | <X size={14} /> |
| 175 | </button> |
| 176 | </Tooltip> |
| 177 | </> |
| 178 | ) : ( |
| 179 | <> |
| 180 | {canEdit && ( |
| 181 | <Tooltip label={t("composer.guidanceEdit")}> |
| 182 | <button |
| 183 | className="composer-guidance-item__action" |
| 184 | type="button" |
| 185 | aria-label={t("composer.guidanceEdit")} |
| 186 | onClick={() => startEdit(item)} |
| 187 | > |
| 188 | <Pencil size={14} /> |
| 189 | </button> |
| 190 | </Tooltip> |
| 191 | )} |
| 192 | <Tooltip label={actionLabel}> |
| 193 | <button |
| 194 | className="composer-guidance-item__guide" |
| 195 | type="button" |
| 196 | aria-label={actionLabel} |
| 197 | disabled={inFlight || unknownState || waitingForEarlier || disabled || readOnly || sendingId !== null || (running && !needsRetry && Boolean(item.structured)) || Boolean(item.paused)} |
| 198 | onClick={() => onSend(item)} |
| 199 | > |
| 200 | <CornerDownRight size={13} /> |
| 201 | <span>{t(needsRetry ? "composer.guidanceRetryMode" : running ? "composer.guidanceMode" : "composer.guidanceSendMode")}</span> |
| 202 | </button> |
| 203 | </Tooltip> |
| 204 | <Tooltip label={inFlight ? actionLabel : t("composer.guidanceDismiss")}> |
| 205 | <button |
| 206 | className="composer-guidance-item__action" |
| 207 | type="button" |
| 208 | aria-label={inFlight ? actionLabel : t("composer.guidanceDismiss")} |
| 209 | disabled={disabled || readOnly || inFlight || unknownState || sendingId === item.id} |
| 210 | onClick={() => onDismiss(item)} |
| 211 | > |
| 212 | <Trash2 size={14} /> |
| 213 | </button> |
| 214 | </Tooltip> |
| 215 | </> |
| 216 | )} |
| 217 | </div> |
| 218 | ); |
| 219 | })} |
| 220 | {items.length > 2 && ( |
| 221 | <button |
| 222 | className="composer-guidance-more" |
| 223 | type="button" |
| 224 | aria-expanded={expanded} |
| 225 | onClick={onToggleExpanded} |
| 226 | > |
| 227 | {expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />} |
| 228 | <span>{expanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenCount })}</span> |
| 229 | </button> |
| 230 | )} |
| 231 | </div> |
| 232 | </div> |
| 233 | )} |
| 234 | </> |
| 235 | ); |
| 236 | } |
| 237 |