feat(phase3): threads backend + thread panel UI

This commit is contained in:
2026-06-30 10:36:18 -04:00
parent bd260183ef
commit f3f03df710
9 changed files with 634 additions and 122 deletions
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import { useThreadStore } from '../stores/thread.ts';
import { useMessageStore } from '../stores/message.ts';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ThreadPanelProps {
threadId: string;
threadName: string;
onClose: () => void;
}
function formatTime(iso: string): string {
const date = new Date(iso);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
export function ThreadPanel({ threadId, threadName, onClose }: ThreadPanelProps) {
const messages = useThreadStore((s) => s.messagesByThread[threadId] || []);
const fetchMessages = useThreadStore((s) => s.fetchThreadMessages);
const sendMessage = useThreadStore((s) => s.sendThreadMessage);
const addMessage = useThreadStore((s) => s.addThreadMessage);
const removeMessage = useMessageStore((s) => s.removeMessage);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchMessages(threadId);
}, [threadId, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
}, [messages]);
// Listen for gateway messages scoped to this thread channel.
useEffect(() => {
const handler = (e: MessageEvent) => {
try {
const event = JSON.parse(e.data);
if (event.type === 'MESSAGE_CREATE' && event.data?.channel_id === threadId) {
addMessage(event.data);
}
if (event.type === 'MESSAGE_DELETE' && event.data?.channel_id === threadId) {
removeMessage(threadId, event.data.id);
}
} catch {
// ignore
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [threadId, addMessage, removeMessage]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const value = inputRef.current?.value.trim();
if (!value) return;
await sendMessage(threadId, value);
if (inputRef.current) inputRef.current.value = '';
};
return (
<div className="w-80 bg-gb-bg-s border-l border-gb-bg-t flex flex-col h-full">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
<span> {threadName}</span>
<button onClick={onClose} className="text-gb-fg-f hover:text-gb-orange">[x]</button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{messages.map((m) => (
<div key={m.id} className="break-words">
<span className="text-gb-fg-f">[{formatTime(m.created_at)}]</span>{' '}
<span className="text-gb-aqua">&lt;{m.author_username}&gt;</span>{' '}
<span className="text-gb-fg">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ p: ({ ...props }) => <span {...props} className="inline" /> }}>
{m.content}
</ReactMarkdown>
</span>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none shrink-0">{'>'}</span>
<input ref={inputRef} type="text" placeholder="reply..." className="terminal-input w-full" />
</form>
</div>
);
}