| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | |
| 3 | import { useSessionStore } from "@/lib/store/sessions"; |
| 4 | import type { SessionDetail } from "@/lib/ipc/types"; |
| 5 | |
| 6 | interface ChatInputProps { |
| 7 | detail: SessionDetail; |
| 8 | } |
| 9 | |
| 10 | export function ChatInput({ detail }: ChatInputProps) { |
| 11 | const [text, setText] = useState(""); |
| 12 | const taRef = useRef<HTMLTextAreaElement>(null); |
| 13 | |
| 14 | const startTurn = useSessionStore((s) => s.startTurn); |
| 15 | const cancelTurn = useSessionStore((s) => s.cancelTurn); |
| 16 | const inFlightTurns = useSessionStore((s) => s.inFlightTurns); |
| 17 | |
| 18 | // Which turn (if any) is currently writing into THIS session? |
| 19 | const activeTurn = useMemo(() => { |
| 20 | const sid = detail.summary.id; |
| 21 | for (const t of inFlightTurns.values()) { |
| 22 | if (t.sessionId === sid && (t.status === "spawning" || t.status === "streaming")) { |
| 23 | return t; |
| 24 | } |
| 25 | } |
| 26 | return null; |
| 27 | }, [inFlightTurns, detail.summary.id]); |
| 28 | |
| 29 | const disabled = activeTurn !== null; |
| 30 | |
| 31 | // Auto-grow the textarea — bounded by max-h via CSS. |
| 32 | useEffect(() => { |
| 33 | const ta = taRef.current; |
| 34 | if (!ta) return; |
| 35 | ta.style.height = "0px"; |
| 36 | ta.style.height = `${Math.min(ta.scrollHeight, 240)}px`; |
| 37 | }, [text]); |
| 38 | |
| 39 | const onSend = useCallback(async () => { |
| 40 | const prompt = text.trim(); |
| 41 | if (!prompt || disabled) return; |
| 42 | setText(""); |
| 43 | try { |
| 44 | await startTurn(prompt); |
| 45 | } catch (err) { |
| 46 | // Restore the text so the user can retry without re-typing. |
| 47 | setText(prompt); |
| 48 | // eslint-disable-next-line no-console |
| 49 | console.error("startTurn failed", err); |
| 50 | } |
| 51 | }, [text, disabled, startTurn]); |
| 52 | |
| 53 | const onCancel = useCallback(() => { |
| 54 | if (activeTurn) void cancelTurn(activeTurn.turnId); |
| 55 | }, [activeTurn, cancelTurn]); |
| 56 | |
| 57 | const onKeyDown = useCallback( |
| 58 | (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| 59 | if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { |
| 60 | e.preventDefault(); |
| 61 | void onSend(); |
| 62 | } else if (e.key === "Escape") { |
| 63 | e.preventDefault(); |
| 64 | if (activeTurn) onCancel(); |
| 65 | } |
| 66 | }, |
| 67 | [onSend, onCancel, activeTurn], |
| 68 | ); |
| 69 | |
| 70 | return ( |
| 71 | <div className="shrink-0 border-t border-border bg-bg-1"> |
| 72 | <div className="mx-auto flex max-w-4xl items-end gap-2 px-4 py-3"> |
| 73 | <textarea |
| 74 | ref={taRef} |
| 75 | value={text} |
| 76 | onChange={(e) => setText(e.target.value)} |
| 77 | onKeyDown={onKeyDown} |
| 78 | placeholder={ |
| 79 | disabled |
| 80 | ? "streaming… press Esc to cancel" |
| 81 | : "message (⌘↵ to send)" |
| 82 | } |
| 83 | rows={1} |
| 84 | disabled={disabled} |
| 85 | className="min-h-[40px] max-h-60 flex-1 resize-none rounded border border-border bg-bg-2 px-3 py-2 font-mono text-[13px] text-fg-0 outline-none transition placeholder:text-fg-3 focus:border-accent/60 disabled:opacity-50" |
| 86 | /> |
| 87 | <div className="flex shrink-0 flex-col gap-1"> |
| 88 | {disabled ? ( |
| 89 | <button |
| 90 | type="button" |
| 91 | onClick={onCancel} |
| 92 | className="rounded border border-red-900/60 bg-red-950/40 px-3 py-2 text-[12px] font-medium text-red-200 transition hover:bg-red-950/60" |
| 93 | > |
| 94 | cancel |
| 95 | </button> |
| 96 | ) : ( |
| 97 | <button |
| 98 | type="button" |
| 99 | onClick={() => void onSend()} |
| 100 | disabled={!text.trim()} |
| 101 | className="rounded border border-accent/60 bg-accent/20 px-3 py-2 text-[12px] font-medium text-accent transition hover:bg-accent/30 disabled:cursor-not-allowed disabled:opacity-40" |
| 102 | > |
| 103 | send |
| 104 | </button> |
| 105 | )} |
| 106 | </div> |
| 107 | </div> |
| 108 | <div className="mx-auto flex max-w-4xl items-center gap-2 px-4 pb-2 text-[10px] text-fg-3"> |
| 109 | <span |
| 110 | className="rounded bg-bg-3 px-1.5 py-0.5 font-mono" |
| 111 | title="non-dangerous tools auto-run; Bash/Edit/Write still block. Per-session override coming in v1.1." |
| 112 | > |
| 113 | auto |
| 114 | </span> |
| 115 | <span>permission mode</span> |
| 116 | {activeTurn && ( |
| 117 | <> |
| 118 | <span>•</span> |
| 119 | <span className="text-accent">streaming</span> |
| 120 | </> |
| 121 | )} |
| 122 | </div> |
| 123 | </div> |
| 124 | ); |
| 125 | } |