'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { apiFetch } from '../lib/api' /** Agent side backing (Standard Agent Specification 0.2.0). */ export type SideBacking = 'human' | 'prompt' | 'handler' /** One row of `GET /api/threads`. */ export interface Thread { id: string agent_id: string user_id: string | null tags: string[] parent?: string | null terminated?: number | null created_at: number last_activity_at?: number | null executing?: boolean last_message_preview?: string | null last_message_at?: number | null agent: { name?: string title?: string description?: string | null icon?: string | null side_a_backing?: SideBacking side_b_backing?: SideBacking } } /** Shape returned by `POST /api/threads`. */ export interface CreatedThread { threadId: string agent_id: string } // The builder UI titles threads from a `name:` tag; `title:` is the tag older // versions of this chat wrote, still read so existing threads keep their names. const NAME_TAG = 'name' const LEGACY_TITLE_TAG = 'title' export function getTagValue(tags: string[], key: string): string | undefined { const tag = tags.find(t => t.startsWith(`${key}:`)) return tag ? tag.slice(key.length + 1) : undefined } export function getThreadName(thread: Thread): string | undefined { return getTagValue(thread.tags, NAME_TAG) || getTagValue(thread.tags, LEGACY_TITLE_TAG) } export function getThreadTitle(thread: Thread): string { return getThreadName(thread) || thread.agent?.title || thread.agent?.name || 'Untitled' } export function withThreadName(tags: string[], name: string): string[] { const rest = tags.filter(tag => !tag.startsWith(`${NAME_TAG}:`) && !tag.startsWith(`${LEGACY_TITLE_TAG}:`)) return [...rest, `${NAME_TAG}:${name}`] } export function useThreads() { const [threads, setThreads] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const threadsRef = useRef(threads) threadsRef.current = threads // Read on mount and after local mutations or run-state changes; never polled. const fetchThreads = useCallback(async () => { try { const response = await apiFetch('/api/threads?limit=50&sort=activity') if (!response.ok) throw new Error('Failed to fetch threads') const data = await response.json() as { threads?: Thread[] } // Subagent child threads are shown inside their parent conversation. setThreads((data.threads || []).filter(t => !t.parent)) setError(null) } catch (err) { console.error('Failed to load threads:', err) setError(err instanceof Error ? err.message : 'Failed to load threads') } finally { setLoading(false) } }, []) useEffect(() => { fetchThreads() }, [fetchThreads]) const deleteThread = useCallback(async (threadId: string) => { const response = await apiFetch(`/api/threads/${threadId}`, { method: 'DELETE' }) if (!response.ok) throw new Error('Failed to delete thread') setThreads((prev) => prev.filter((t) => t.id !== threadId)) }, []) const updateThreadTitle = useCallback(async (threadId: string, title: string) => { const thread = threadsRef.current.find(t => t.id === threadId) if (!thread) return const tags = withThreadName(thread.tags, title) setThreads((prev) => prev.map((t) => t.id === threadId ? { ...t, tags } : t)) try { const response = await apiFetch(`/api/threads/${threadId}`, { method: 'PATCH', body: JSON.stringify({ tags }), }) if (!response.ok) { console.error('Failed to update thread title:', response.status) fetchThreads() } } catch (err) { console.error('Failed to update thread:', err) fetchThreads() } }, [fetchThreads]) return { threads, loading, error, refreshThreads: fetchThreads, deleteThread, updateThreadTitle, } }