● Zion Boggan
repos/Darwin/face/src/components/surface/AskBox.tsx
zionboggan.com ↗
294 lines · tsx
History for this file →
1
"use client";
2
 
3
import { useState, useRef, useEffect, useCallback } from "react";
4
import type { AskResponse, Receipt } from "@/lib/types";
5
import { renderMarkdownLite } from "@/lib/markdown-lite";
6
 
7
function stripReceiptMd(s: string): string {
8
  return s
9
    .replace(/```[\s\S]*?```/g, " ")
10
    .replace(/`([^`]*)`/g, "$1")
11
    .replace(/^#{1,6}\s*/gm, "")
12
    .replace(/^\s*[-*+]\s+/gm, "· ")
13
    .replace(/\*\*(.*?)\*\*/g, "$1")
14
    .replace(/\*(.*?)\*/g, "$1")
15
    .replace(/\s+/g, " ")
16
    .trim();
17
}
18
 
19
interface AskBoxProps {
20
  data: AskResponse | null;
21
  loading: boolean;
22
  error: string | null;
23
  onSubmit: (query: string) => Promise<void>;
24
  onFocusChange?: (focused: boolean) => void;
25
}
26
 
27
function ReceiptChip({ receipt, isExpanded, onToggle }: {
28
  receipt: Receipt;
29
  isExpanded: boolean;
30
  onToggle: () => void;
31
}) {
32
  const confidencePct = Math.round(receipt.confidence * 100);
33
 
34
  return (
35
    <button
36
      onClick={onToggle}
37
      data-cursor-expand
38
      className="group text-left w-full"
39
      aria-expanded={isExpanded}
40
    >
41
      <div className="border-l-2 border-teal-bio pl-3 py-1.5 bg-surface-1 hover:bg-surface-2 transition-colors duration-200 rounded-r-sm">
42
        <div className="flex items-center gap-2 flex-wrap">
43
          <span className="font-mono text-label-sm text-teal-bio uppercase tracking-widest">
44
            [{receipt.source}]
45
          </span>
46
          <span className="font-mono text-label-sm text-text-muted truncate max-w-[200px]">
47
            {receipt.path}
48
          </span>
49
          <span className="font-mono text-label-sm text-text-secondary ml-auto flex-shrink-0">
50
            {confidencePct}%
51
          </span>
52
        </div>
53
 
54
        {isExpanded && (
55
          <div className="mt-2 pt-2 border-t border-surface-3">
56
            <p className="font-ui text-xs text-text-secondary leading-relaxed">
57
              {stripReceiptMd(receipt.snippet)}
58
            </p>
59
            <p className="font-mono text-label-sm text-text-muted mt-1">
60
              {new Date(receipt.last_seen).toLocaleString()}
61
            </p>
62
          </div>
63
        )}
64
      </div>
65
    </button>
66
  );
67
}
68
 
69
export default function AskBox({ data, loading, error, onSubmit, onFocusChange }: AskBoxProps) {
70
  const [query, setQuery] = useState("");
71
  const [expandedReceipts, setExpandedReceipts] = useState<Set<string>>(new Set());
72
  const inputRef = useRef<HTMLInputElement>(null);
73
  const answerRef = useRef<HTMLDivElement>(null);
74
 
75
  const handleSubmit = useCallback(
76
    async (e: React.FormEvent) => {
77
      e.preventDefault();
78
      if (!query.trim() || loading) return;
79
      await onSubmit(query);
80
    },
81
    [query, loading, onSubmit]
82
  );
83
 
84
  const toggleReceipt = useCallback((id: string) => {
85
    setExpandedReceipts((prev) => {
86
      const next = new Set(prev);
87
      if (next.has(id)) next.delete(id);
88
      else next.add(id);
89
      return next;
90
    });
91
  }, []);
92
 
93
  useEffect(() => {
94
    if (!data || !answerRef.current) return;
95
    const el = answerRef.current;
96
    el.style.opacity = "0";
97
    el.style.transform = "translateY(20px)";
98
    requestAnimationFrame(() => {
99
      requestAnimationFrame(() => {
100
        el.style.transition = "opacity 0.7s cubic-bezier(0.16,1,0.3,1), transform 0.7s cubic-bezier(0.16,1,0.3,1)";
101
        el.style.opacity = "1";
102
        el.style.transform = "translateY(0)";
103
      });
104
    });
105
  }, [data]);
106
 
107
  return (
108
    <div className="flex flex-col gap-7 h-full">
109
      {}
110
      <div>
111
        <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3">
112
          Ask Darwin
113
        </p>
114
        <h2 className="font-display text-display-md font-bold text-text-primary leading-tight">
115
          Query the organism
116
        </h2>
117
      </div>
118
 
119
      {}
120
      <form onSubmit={handleSubmit} className="relative">
121
        <label htmlFor="darwin-ask" className="sr-only">
122
          Ask Darwin a question about your homelab
123
        </label>
124
        <input
125
          ref={inputRef}
126
          id="darwin-ask"
127
          type="text"
128
          value={query}
129
          onChange={(e) => setQuery(e.target.value)}
130
          placeholder="What is Hermes doing right now?"
131
          autoComplete="off"
132
          autoCorrect="off"
133
          autoCapitalize="off"
134
          spellCheck="false"
135
          disabled={loading}
136
          onFocus={() => onFocusChange?.(true)}
137
          onBlur={() => onFocusChange?.(false)}
138
          style={{ fontSize: "16px", minHeight: "44px" }}
139
          className={[
140
            "w-full bg-transparent border-b-2 border-text-muted",
141
            "focus:border-teal-bio outline-none",
142
            "font-ui text-text-primary placeholder:text-text-muted",
143
            "py-3 pr-16 transition-colors duration-300",
144
            "disabled:opacity-50",
145
          ].join(" ")}
146
        />
147
        <button
148
          type="submit"
149
          disabled={loading || !query.trim()}
150
          aria-label="Submit query"
151
          style={{ minWidth: "44px", minHeight: "44px", touchAction: "manipulation" }}
152
          className={[
153
            "absolute right-0 top-1/2 -translate-y-1/2",
154
            "w-11 h-11 flex items-center justify-center",
155
            "border border-text-muted hover:border-teal-bio",
156
            "text-text-muted hover:text-teal-bio",
157
            "transition-all duration-200 disabled:opacity-30",
158
          ].join(" ")}
159
          data-cursor-expand
160
        >
161
          {loading ? (
162
            <svg className="animate-spin" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
163
              <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" strokeDasharray="20 17" />
164
            </svg>
165
          ) : (
166
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
167
              <path d="M2 8h12M8 2l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" />
168
            </svg>
169
          )}
170
        </button>
171
      </form>
172
 
173
      {}
174
      {loading && (
175
        <div className="flex items-center gap-3">
176
          <div className="flex gap-1" aria-hidden="true">
177
            {[0, 1, 2].map((i) => (
178
              <div
179
                key={i}
180
                className="w-1.5 h-1.5 rounded-full bg-teal-bio"
181
                style={{
182
                  animation: `pulse 1.2s ease-in-out ${i * 0.2}s infinite`,
183
                }}
184
              />
185
            ))}
186
          </div>
187
          <span className="font-mono text-label-sm text-text-muted" aria-live="polite">
188
            Querying all engines...
189
          </span>
190
        </div>
191
      )}
192
 
193
      {}
194
      {error && (
195
        <div className="border-l-2 border-red-500 pl-3 py-2" role="alert">
196
          <p className="font-mono text-label-sm text-red-400">{error}</p>
197
        </div>
198
      )}
199
 
200
      {}
201
      {data && (
202
        <div ref={answerRef} className="flex flex-col gap-6 overflow-y-auto w-full max-w-full">
203
          {}
204
          {data.answer && (
205
            <div className="w-full max-w-full">
206
              <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-4">
207
                Answer
208
              </p>
209
              <div
210
                className="font-ui text-base text-text-primary answer-prose"
211
                style={{ lineHeight: "1.75", letterSpacing: "0.005em" }}
212
                dangerouslySetInnerHTML={{ __html: renderMarkdownLite(data.answer) }}
213
              />
214
            </div>
215
          )}
216
 
217
          {}
218
          <div className="flex items-center gap-2 flex-wrap pt-2 border-t border-surface-2">
219
            {data.engines.map((eng) => (
220
              <span
221
                key={eng.engine}
222
                className={[
223
                  "font-mono text-label-sm px-3 py-1 border rounded-sm",
224
                  eng.status === "up"
225
                    ? "border-teal-muted text-teal-bio"
226
                    : "border-surface-3 text-text-muted",
227
                ].join(" ")}
228
              >
229
                {eng.engine}
230
                {eng.ms != null && <span className="opacity-50 ml-1.5">{eng.ms}ms</span>}
231
              </span>
232
            ))}
233
            <span className="font-mono text-label-sm text-text-muted ml-auto opacity-60">
234
              {data.took_ms}ms total
235
            </span>
236
          </div>
237
 
238
          {}
239
          {data.receipts.length > 0 && (
240
            <div>
241
              <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3">
242
                Sources - {data.receipts.length}
243
              </p>
244
              <div className="flex flex-col gap-2.5">
245
                {data.receipts.map((receipt) => (
246
                  <ReceiptChip
247
                    key={receipt.id}
248
                    receipt={receipt}
249
                    isExpanded={expandedReceipts.has(receipt.id)}
250
                    onToggle={() => toggleReceipt(receipt.id)}
251
                  />
252
                ))}
253
              </div>
254
            </div>
255
          )}
256
 
257
          {}
258
          {data.conflicts.length > 0 && (
259
            <div>
260
              <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
261
                Conflicts
262
              </p>
263
              {data.conflicts.map((c, i) => (
264
                <div key={i} className="border-l-2 border-yellow-600 pl-3 py-1.5 mb-2">
265
                  <p className="font-mono text-label-sm text-yellow-500">{c.claim}</p>
266
                  <p className="font-mono text-label-sm text-text-muted">
267
                    Winner: {c.winner} - {c.reason}
268
                  </p>
269
                </div>
270
              ))}
271
            </div>
272
          )}
273
        </div>
274
      )}
275
 
276
      {}
277
      {!data && !loading && !error && (
278
        <div className="flex-1 flex items-center justify-center">
279
          <div className="text-center">
280
            <div className="w-12 h-12 mx-auto mb-4 border border-surface-3 flex items-center justify-center" aria-hidden="true">
281
              <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
282
                <circle cx="10" cy="10" r="8" stroke="#16e0c8" strokeWidth="1" opacity="0.4" />
283
                <circle cx="10" cy="10" r="3" fill="#16e0c8" opacity="0.6" />
284
              </svg>
285
            </div>
286
            <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
287
              Organism ready
288
            </p>
289
          </div>
290
        </div>
291
      )}
292
    </div>
293
  );
294
}