({
projectSlug,
agentSlug,
agentDisplayName,
threadId,
sessionKey,
initialEvents,
initialByteOffset,
composerDisabled = false,
blockedReason,
onPolled,
autoKickoff = false,
kickoffMessage,
taskId,
mcpCatalog,
}: Props)
| 146 | const KICKOFF_FIRED = new Set<string>(); |
| 147 | |
| 148 | export function LiveTranscript({ |
| 149 | projectSlug, |
| 150 | agentSlug, |
| 151 | agentDisplayName, |
| 152 | threadId, |
| 153 | sessionKey, |
| 154 | initialEvents, |
| 155 | initialByteOffset, |
| 156 | composerDisabled = false, |
| 157 | blockedReason, |
| 158 | onPolled, |
| 159 | autoKickoff = false, |
| 160 | kickoffMessage, |
| 161 | taskId, |
| 162 | mcpCatalog, |
| 163 | }: Props) { |
| 164 | const router = useRouter(); |
| 165 | const [events, setEvents] = useState<TranscriptEvent[]>(initialEvents); |
| 166 | const [byteOffset, setByteOffset] = useState(initialByteOffset); |
| 167 | const [input, setInput] = useState(""); |
| 168 | const [sendingChat, setSendingChat] = useState(false); |
| 169 | /** |
| 170 | * Wallclock when the current turn started, in ms. Drives the "elapsed" |
| 171 | * counter in WorkingStatus during the gap between hitting send and the |
| 172 | * agent's first transcript event landing — without this, elapsed reflects |
| 173 | * the *previous* turn's last event timestamp (often minutes/hours stale). |
| 174 | * Cleared once the new user_message lands and rendering catches up. |
| 175 | */ |
| 176 | const [turnStartedAt, setTurnStartedAt] = useState<number | null>(null); |
| 177 | const [stopPolling, setStopPolling] = useState(false); |
| 178 | const [slashIndex, setSlashIndex] = useState(0); |
| 179 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 180 | |
| 181 | // Slash command autocomplete: open while input starts with "/" and the |
| 182 | // user is still composing the command name (no space yet). After they |
| 183 | // pick or type a space, the popover closes. |
| 184 | const slashQuery = input.startsWith("/") && !input.includes(" ") ? input : null; |
| 185 | const slashOpen = slashQuery !== null && !sendingChat && !composerDisabled; |
| 186 | const slashMatches = useMemo<SlashCommand[]>( |
| 187 | () => (slashOpen ? filterSlashCommands(slashQuery!) : []), |
| 188 | [slashOpen, slashQuery], |
| 189 | ); |
| 190 | const safeSlashIndex = |
| 191 | slashMatches.length === 0 |
| 192 | ? 0 |
| 193 | : Math.min(slashIndex, slashMatches.length - 1); |
| 194 | |
| 195 | function insertSlashCommand(cmd: SlashCommand) { |
| 196 | // Catalog `name` is the command without the leading slash ("new", "clear"). |
| 197 | // Insert ends with a trailing space so the popover closes — a second |
| 198 | // Enter then submits. |
| 199 | const insert = cmd.insert ?? `/${cmd.name} `; |
| 200 | setInput(insert); |
| 201 | setSlashIndex(0); |
| 202 | requestAnimationFrame(() => { |
| 203 | const ta = textareaRef.current; |
| 204 | if (ta) { |
| 205 | ta.focus(); |
nothing calls this directly
no test coverage detected