(props: SessionChatProps)
| 300 | } |
| 301 | |
| 302 | function SessionChatInner(props: SessionChatProps) { |
| 303 | const { haptic } = usePlatform() |
| 304 | const { t } = useTranslation() |
| 305 | const navigate = useNavigate() |
| 306 | const sessionInactive = !props.session.active |
| 307 | const inactiveCanResume = inactiveSessionCanResume(props.session, props.messages.length) |
| 308 | const terminalSupported = isRemoteTerminalSupported(props.session.metadata) |
| 309 | const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map()) |
| 310 | const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map()) |
| 311 | const visibleGroupsRef = useRef<ToolGroupBlock[]>([]) |
| 312 | const [forceScrollToken, setForceScrollToken] = useState(0) |
| 313 | const [outlineOpen, setOutlineOpen] = useState(false) |
| 314 | const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') |
| 315 | const lastSyncedCursorModelRef = useRef<string | null | undefined>(undefined) |
| 316 | const scratchlist = useScratchlist(props.session.id) |
| 317 | const [scratchlistMode, setScratchlistMode] = useState(false) |
| 318 | // Mode resets across sessions implicitly: SessionChat is keyed by |
| 319 | // session.id at the public-export boundary, so a session switch |
| 320 | // remounts SessionChatInner from scratch and `scratchlistMode` |
| 321 | // initializes to false again. (Previous effect-based reset was |
| 322 | // racy on first paint - see public-export comment for context.) |
| 323 | const handleScratchlistToggle = useCallback(() => { |
| 324 | setScratchlistMode((m) => !m) |
| 325 | }, []) |
| 326 | /** |
| 327 | * Global keyboard shortcut: Ctrl/Cmd + Shift + S toggles scratchlist |
| 328 | * mode (open/close drawer + flip composer routing). |
| 329 | * |
| 330 | * Convention matches the v1 always-visible panel's shortcut so muscle |
| 331 | * memory carries over. Other composer-adjacent globals in the app use |
| 332 | * the same modifier shape: Ctrl/Cmd-m cycles agent model in |
| 333 | * HappyComposer. Ctrl/Cmd-Shift-S is unreserved by Chrome / Firefox / |
| 334 | * Safari at the app level (browser Save As is Ctrl-S / Cmd-S, no |
| 335 | * Shift), so requiring Shift keeps the user's save-page muscle memory |
| 336 | * working. Bound at SessionChat scope (not the drawer) because the |
| 337 | * drawer is unmounted while mode is off — a drawer-scoped listener |
| 338 | * couldn't reopen it. |
| 339 | * |
| 340 | * Skipped when focus is inside an open dialog or single-line input |
| 341 | * (see isScratchlistHotkeyBlockedTarget). Otherwise fires for any |
| 342 | * focus target - composer textarea is the expected case so it's |
| 343 | * deliberately allowed. Window-level shortcut without target |
| 344 | * filtering would silently toggle mode "behind" modal dialogs |
| 345 | * (rename, schedule picker, FUE callout); the bot caught this on |
| 346 | * PR #798. |
| 347 | */ |
| 348 | useEffect(() => { |
| 349 | const onKeyDown = (e: globalThis.KeyboardEvent) => { |
| 350 | if (!isScratchlistToggleHotkey(e)) return |
| 351 | if (isScratchlistHotkeyBlockedTarget(e.target)) return |
| 352 | e.preventDefault() |
| 353 | setScratchlistMode((m) => !m) |
| 354 | } |
| 355 | window.addEventListener('keydown', onKeyDown) |
| 356 | return () => window.removeEventListener('keydown', onKeyDown) |
| 357 | }, []) |
| 358 | /** |
| 359 | * onSend wrapper: when scratchlist mode is on AND the submission is |
nothing calls this directly
no test coverage detected