TypeScript · 4199 bytes Raw Blame History
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 }