@standardagents/cli 0.10.1-next.bbd142a → 0.11.0-next.ab7e1ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { type Thread, getThreadTitle, getTagValue } from '../hooks/useThreads'
5
+ import { LogoMark } from './Logo'
6
+ import { useTheme } from '../hooks/useTheme'
7
+ import { useAuth } from '../hooks/useAuth'
8
+
9
+ interface SidebarProps {
10
+ threads: Thread[]
11
+ currentThreadId: string | null
12
+ onSelectThread: (threadId: string) => void
13
+ onNewThread: () => void
14
+ onDeleteThread: (threadId: string) => void
15
+ isOpen: boolean
16
+ onToggle: () => void
17
+ loading?: boolean
18
+ }
19
+
20
+ export function Sidebar({
21
+ threads,
22
+ currentThreadId,
23
+ onSelectThread,
24
+ onNewThread,
25
+ onDeleteThread,
26
+ isOpen,
27
+ onToggle,
28
+ loading,
29
+ }: SidebarProps) {
30
+ const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null)
31
+ const { theme, toggleTheme } = useTheme()
32
+ const { logout } = useAuth()
33
+
34
+ const formatRelativeTime = (timestamp: number) => {
35
+ // Handle different timestamp formats based on magnitude
36
+ let ms: number
37
+ if (timestamp > 1e15) {
38
+ ms = timestamp / 1000
39
+ } else if (timestamp > 1e12) {
40
+ ms = timestamp
41
+ } else {
42
+ ms = timestamp * 1000
43
+ }
44
+
45
+ const date = new Date(ms)
46
+ const now = new Date()
47
+ const diffMs = now.getTime() - date.getTime()
48
+ const diffSeconds = Math.floor(diffMs / 1000)
49
+ const diffMinutes = Math.floor(diffSeconds / 60)
50
+ const diffHours = Math.floor(diffMinutes / 60)
51
+ const diffDays = Math.floor(diffHours / 24)
52
+ const diffWeeks = Math.floor(diffDays / 7)
53
+ const diffMonths = Math.floor(diffDays / 30)
54
+ const diffYears = Math.floor(diffDays / 365)
55
+
56
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
57
+
58
+ if (diffSeconds < 60) {
59
+ return rtf.format(-diffSeconds, 'second')
60
+ } else if (diffMinutes < 60) {
61
+ return rtf.format(-diffMinutes, 'minute')
62
+ } else if (diffHours < 24) {
63
+ return rtf.format(-diffHours, 'hour')
64
+ } else if (diffDays < 7) {
65
+ return rtf.format(-diffDays, 'day')
66
+ } else if (diffWeeks < 4) {
67
+ return rtf.format(-diffWeeks, 'week')
68
+ } else if (diffMonths < 12) {
69
+ return rtf.format(-diffMonths, 'month')
70
+ } else {
71
+ return rtf.format(-diffYears, 'year')
72
+ }
73
+ }
74
+
75
+ const handleDeleteClick = (e: React.MouseEvent, threadId: string) => {
76
+ e.stopPropagation()
77
+ setConfirmingDelete(threadId)
78
+ }
79
+
80
+ const handleConfirmDelete = (e: React.MouseEvent, threadId: string) => {
81
+ e.stopPropagation()
82
+ onDeleteThread(threadId)
83
+ setConfirmingDelete(null)
84
+ }
85
+
86
+ const handleCancelDelete = (e: React.MouseEvent) => {
87
+ e.stopPropagation()
88
+ setConfirmingDelete(null)
89
+ }
90
+
91
+ return (
92
+ <aside
93
+ className={`
94
+ fixed top-0 left-0 h-full w-72 bg-[var(--bg-secondary)] flex flex-col border-r border-[var(--border-primary)] z-40
95
+ transform transition-transform duration-300 ease-out
96
+ ${isOpen ? 'translate-x-0' : '-translate-x-full'}
97
+ `}
98
+ >
99
+ {/* Header */}
100
+ <div className="h-[47px] flex items-center justify-between px-3 border-b border-[var(--border-primary)]">
101
+ <div className="flex items-center gap-2">
102
+ <LogoMark className="w-[18px] h-[18px] text-[var(--text-secondary)]" />
103
+ </div>
104
+ <div className="flex items-center gap-1">
105
+ <button
106
+ onClick={onNewThread}
107
+ className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
108
+ title="New chat"
109
+ >
110
+ <svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
111
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
112
+ </svg>
113
+ </button>
114
+ <button
115
+ onClick={onToggle}
116
+ className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
117
+ title="Collapse sidebar"
118
+ >
119
+ {/* ChatGPT drawer icon */}
120
+ <svg className="w-5 h-5 text-[var(--text-secondary)]" viewBox="0 0 20 20" fill="currentColor">
121
+ <path d="M6.835 4c-.451.004-.82.012-1.137.038-.386.032-.659.085-.876.162l-.2.086c-.44.224-.807.564-1.063.982l-.103.184c-.126.247-.206.562-.248 1.076-.043.523-.043 1.19-.043 2.135v2.664c0 .944 0 1.612.043 2.135.042.515.122.829.248 1.076l.103.184c.256.418.624.758 1.063.982l.2.086c.217.077.49.13.876.162.316.026.685.034 1.136.038zm11.33 7.327c0 .922 0 1.654-.048 2.243-.043.522-.125.977-.305 1.395l-.082.177a4 4 0 0 1-1.473 1.593l-.276.155c-.465.237-.974.338-1.57.387-.59.048-1.322.048-2.244.048H7.833c-.922 0-1.654 0-2.243-.048-.522-.042-.977-.126-1.395-.305l-.176-.082a4 4 0 0 1-1.594-1.473l-.154-.275c-.238-.466-.34-.975-.388-1.572-.048-.589-.048-1.32-.048-2.243V8.663c0-.922 0-1.654.048-2.243.049-.597.15-1.106.388-1.571l.154-.276a4 4 0 0 1 1.594-1.472l.176-.083c.418-.18.873-.263 1.395-.305.589-.048 1.32-.048 2.243-.048h4.334c.922 0 1.654 0 2.243.048.597.049 1.106.15 1.571.388l.276.154a4 4 0 0 1 1.473 1.594l.082.176c.18.418.262.873.305 1.395.048.589.048 1.32.048 2.243zm-10 4.668h4.002c.944 0 1.612 0 2.135-.043.514-.042.829-.122 1.076-.248l.184-.103c.418-.256.758-.624.982-1.063l.086-.2c.077-.217.13-.49.162-.876.043-.523.043-1.19.043-2.135V8.663c0-.944 0-1.612-.043-2.135-.032-.386-.085-.659-.162-.876l-.086-.2a2.67 2.67 0 0 0-.982-1.063l-.184-.103c-.247-.126-.562-.206-1.076-.248-.523-.043-1.19-.043-2.135-.043H8.164L8.165 4z" />
122
+ </svg>
123
+ </button>
124
+ </div>
125
+ </div>
126
+
127
+ {/* Thread list */}
128
+ <div className="flex-1 overflow-y-auto py-2">
129
+ {loading ? (
130
+ <div className="px-4 py-8 text-center">
131
+ <div className="inline-block w-5 h-5 border-2 border-[var(--text-muted)] border-t-[var(--text-secondary)] rounded-full animate-spin" />
132
+ </div>
133
+ ) : threads.length === 0 ? (
134
+ <div className="px-4 py-8 text-center">
135
+ <p className="text-sm text-[var(--text-tertiary)]">No conversations yet</p>
136
+ <p className="text-xs text-[var(--text-muted)] mt-1">Start a new chat to begin</p>
137
+ </div>
138
+ ) : (
139
+ <div className="space-y-0.5 px-2">
140
+ {threads.map((thread) => {
141
+ const isConfirming = confirmingDelete === thread.id
142
+
143
+ return (
144
+ <div
145
+ key={thread.id}
146
+ onClick={() => !isConfirming && onSelectThread(thread.id)}
147
+ className={`group rounded-xl transition-all duration-150 ${
148
+ isConfirming
149
+ ? 'bg-[var(--bg-active)]'
150
+ : thread.id === currentThreadId
151
+ ? 'bg-[var(--bg-active)] cursor-pointer'
152
+ : 'hover:bg-[var(--bg-hover)] cursor-pointer'
153
+ }`}
154
+ >
155
+ {isConfirming ? (
156
+ // Delete confirmation UI
157
+ <div className="px-3 py-3 space-y-2">
158
+ <p className="text-sm text-[var(--text-primary)]">Delete this thread?</p>
159
+ <div className="flex gap-2">
160
+ <button
161
+ onClick={handleCancelDelete}
162
+ className="flex-1 px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] rounded-lg transition-colors"
163
+ >
164
+ Cancel
165
+ </button>
166
+ <button
167
+ onClick={(e) => handleConfirmDelete(e, thread.id)}
168
+ className="flex-1 px-3 py-1.5 text-xs font-medium text-white bg-red-600/80 hover:bg-red-600 rounded-lg transition-colors"
169
+ >
170
+ Delete
171
+ </button>
172
+ </div>
173
+ </div>
174
+ ) : (
175
+ // Normal thread row
176
+ <div className="flex items-center gap-3 px-3 py-2.5">
177
+ <div className="flex-1 min-w-0">
178
+ {(() => {
179
+ const title = getTagValue(thread.tags, 'title')
180
+ const agentName = thread.agent?.title || thread.agent?.name || 'Agent'
181
+ return (
182
+ <>
183
+ <p className={`text-sm truncate ${
184
+ thread.id === currentThreadId ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'
185
+ }`}>
186
+ {title || agentName}
187
+ </p>
188
+ <div className="flex items-center gap-2 mt-0.5">
189
+ <span className="text-xs text-[var(--text-muted)]">
190
+ {formatRelativeTime(thread.created_at)}
191
+ </span>
192
+ {title && (
193
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-hover)] text-[var(--text-muted)]">
194
+ {agentName}
195
+ </span>
196
+ )}
197
+ </div>
198
+ </>
199
+ )
200
+ })()}
201
+ </div>
202
+ <button
203
+ onClick={(e) => handleDeleteClick(e, thread.id)}
204
+ className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-[var(--bg-active)] rounded-lg transition-all shrink-0"
205
+ title="Delete thread"
206
+ >
207
+ <svg className="w-3.5 h-3.5 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
208
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
209
+ </svg>
210
+ </button>
211
+ </div>
212
+ )}
213
+ </div>
214
+ )
215
+ })}
216
+ </div>
217
+ )}
218
+ </div>
219
+
220
+ {/* Bottom toolbar */}
221
+ <div className="px-3 py-2 border-t border-[var(--border-primary)] flex items-center justify-between">
222
+ <button
223
+ onClick={toggleTheme}
224
+ className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
225
+ title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
226
+ >
227
+ {theme === 'dark' ? (
228
+ <svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
229
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
230
+ </svg>
231
+ ) : (
232
+ <svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
233
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
234
+ </svg>
235
+ )}
236
+ <span className="sr-only">
237
+ {theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
238
+ </span>
239
+ </button>
240
+ <button
241
+ onClick={logout}
242
+ className="p-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"
243
+ title="Log out"
244
+ >
245
+ <svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
246
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
247
+ </svg>
248
+ <span className="sr-only">Log out</span>
249
+ </button>
250
+ </div>
251
+ </aside>
252
+ )
253
+ }
@@ -0,0 +1,118 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
4
+
5
+ interface User {
6
+ id: string
7
+ username: string
8
+ role: string
9
+ }
10
+
11
+ interface AuthContextValue {
12
+ isAuthenticated: boolean
13
+ isLoading: boolean
14
+ user: User | null
15
+ login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>
16
+ logout: () => void
17
+ }
18
+
19
+ const AuthContext = createContext<AuthContextValue | null>(null)
20
+
21
+ const TOKEN_KEY = 'agentbuilder_auth_token'
22
+
23
+ function getBaseUrl() {
24
+ const isVite = typeof import.meta !== 'undefined' && import.meta.env
25
+ if (isVite) {
26
+ return import.meta.env.VITE_AGENTBUILDER_URL || ''
27
+ }
28
+ return process.env.NEXT_PUBLIC_AGENTBUILDER_URL || ''
29
+ }
30
+
31
+ export function AuthProvider({ children }: { children: ReactNode }) {
32
+ const [user, setUser] = useState<User | null>(null)
33
+ const [isLoading, setIsLoading] = useState(true)
34
+
35
+ const baseUrl = getBaseUrl()
36
+
37
+ // Check for existing session on mount
38
+ useEffect(() => {
39
+ const token = localStorage.getItem(TOKEN_KEY)
40
+ if (!token) {
41
+ setIsLoading(false)
42
+ return
43
+ }
44
+
45
+ // Validate token by calling /api/auth/me
46
+ fetch(`${baseUrl}/api/auth/me`, {
47
+ headers: { 'Authorization': `Bearer ${token}` }
48
+ })
49
+ .then(res => {
50
+ if (res.ok) return res.json()
51
+ throw new Error('Invalid token')
52
+ })
53
+ .then(data => {
54
+ setUser(data.user)
55
+ })
56
+ .catch(() => {
57
+ localStorage.removeItem(TOKEN_KEY)
58
+ })
59
+ .finally(() => {
60
+ setIsLoading(false)
61
+ })
62
+ }, [baseUrl])
63
+
64
+ const login = useCallback(async (username: string, password: string) => {
65
+ try {
66
+ const res = await fetch(`${baseUrl}/api/auth/login`, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ username, password })
70
+ })
71
+
72
+ if (!res.ok) {
73
+ const data = await res.json().catch(() => ({}))
74
+ return { success: false, error: data.error || 'Invalid credentials' }
75
+ }
76
+
77
+ const data = await res.json()
78
+ localStorage.setItem(TOKEN_KEY, data.token)
79
+ setUser(data.user)
80
+ return { success: true }
81
+ } catch (err) {
82
+ return { success: false, error: 'Network error' }
83
+ }
84
+ }, [baseUrl])
85
+
86
+ const logout = useCallback(() => {
87
+ const token = localStorage.getItem(TOKEN_KEY)
88
+ if (token) {
89
+ // Fire and forget logout request
90
+ fetch(`${baseUrl}/api/auth/logout`, {
91
+ method: 'POST',
92
+ headers: { 'Authorization': `Bearer ${token}` }
93
+ }).catch(() => {})
94
+ }
95
+ localStorage.removeItem(TOKEN_KEY)
96
+ setUser(null)
97
+ }, [baseUrl])
98
+
99
+ return (
100
+ <AuthContext.Provider value={{
101
+ isAuthenticated: !!user,
102
+ isLoading,
103
+ user,
104
+ login,
105
+ logout
106
+ }}>
107
+ {children}
108
+ </AuthContext.Provider>
109
+ )
110
+ }
111
+
112
+ export function useAuth() {
113
+ const context = useContext(AuthContext)
114
+ if (!context) {
115
+ throw new Error('useAuth must be used within an AuthProvider')
116
+ }
117
+ return context
118
+ }
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
4
+
5
+ type Theme = 'light' | 'dark'
6
+
7
+ interface ThemeContextValue {
8
+ theme: Theme
9
+ toggleTheme: () => void
10
+ setTheme: (theme: Theme) => void
11
+ }
12
+
13
+ const ThemeContext = createContext<ThemeContextValue | null>(null)
14
+
15
+ export function ThemeProvider({ children }: { children: ReactNode }) {
16
+ const [theme, setThemeState] = useState<Theme>(() => {
17
+ // Check localStorage first
18
+ const stored = localStorage.getItem('theme') as Theme | null
19
+ if (stored === 'light' || stored === 'dark') return stored
20
+ // Fall back to system preference
21
+ if (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: light)').matches) {
22
+ return 'light'
23
+ }
24
+ return 'dark'
25
+ })
26
+
27
+ useEffect(() => {
28
+ // Update document class and localStorage when theme changes
29
+ document.documentElement.classList.remove('light', 'dark')
30
+ document.documentElement.classList.add(theme)
31
+ localStorage.setItem('theme', theme)
32
+ }, [theme])
33
+
34
+ const toggleTheme = () => {
35
+ setThemeState(prev => prev === 'dark' ? 'light' : 'dark')
36
+ }
37
+
38
+ const setTheme = (newTheme: Theme) => {
39
+ setThemeState(newTheme)
40
+ }
41
+
42
+ return (
43
+ <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
44
+ {children}
45
+ </ThemeContext.Provider>
46
+ )
47
+ }
48
+
49
+ export function useTheme() {
50
+ const context = useContext(ThemeContext)
51
+ if (!context) {
52
+ throw new Error('useTheme must be used within a ThemeProvider')
53
+ }
54
+ return context
55
+ }
@@ -0,0 +1,131 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, useMemo } from 'react'
4
+
5
+ export interface Thread {
6
+ id: string
7
+ agent_id: string
8
+ user_id: string | null
9
+ tags: string[]
10
+ created_at: number
11
+ agent: {
12
+ name?: string
13
+ title?: string
14
+ type: string
15
+ }
16
+ }
17
+
18
+ // Helper to get a tag value by key (format: "key:value")
19
+ export function getTagValue(tags: string[], key: string): string | undefined {
20
+ const tag = tags.find(t => t.startsWith(`${key}:`))
21
+ return tag ? tag.slice(key.length + 1) : undefined
22
+ }
23
+
24
+ // Helper to get thread title from tags
25
+ export function getThreadTitle(thread: Thread): string {
26
+ return getTagValue(thread.tags, 'title') || 'Untitled'
27
+ }
28
+
29
+ // Helper to get API config
30
+ function getApiConfig() {
31
+ const isVite = typeof import.meta !== 'undefined' && import.meta.env
32
+ const baseUrl = isVite
33
+ ? (import.meta.env.VITE_AGENTBUILDER_URL || '')
34
+ : (process.env.NEXT_PUBLIC_AGENTBUILDER_URL || '')
35
+
36
+ // Token is stored in localStorage by auth flow
37
+ const token = typeof localStorage !== 'undefined'
38
+ ? localStorage.getItem('agentbuilder_auth_token') || ''
39
+ : ''
40
+
41
+ return { baseUrl, token }
42
+ }
43
+
44
+ export function useThreads() {
45
+ const [threads, setThreads] = useState<Thread[]>([])
46
+ const [loading, setLoading] = useState(true)
47
+ const [error, setError] = useState<string | null>(null)
48
+
49
+ const { baseUrl, token } = useMemo(() => getApiConfig(), [])
50
+
51
+ const headers = useMemo(() => {
52
+ const h: Record<string, string> = { 'Content-Type': 'application/json' }
53
+ if (token) h['Authorization'] = `Bearer ${token}`
54
+ return h
55
+ }, [token])
56
+
57
+ const fetchThreads = useCallback(async () => {
58
+ try {
59
+ setLoading(true)
60
+ const response = await fetch(`${baseUrl}/api/threads?limit=50`, { headers })
61
+ if (!response.ok) throw new Error('Failed to fetch threads')
62
+ const data = await response.json()
63
+ setThreads(data.threads || [])
64
+ setError(null)
65
+ } catch (err) {
66
+ console.error('Failed to load threads:', err)
67
+ setError(err instanceof Error ? err.message : 'Failed to load threads')
68
+ } finally {
69
+ setLoading(false)
70
+ }
71
+ }, [baseUrl, headers])
72
+
73
+ useEffect(() => {
74
+ fetchThreads()
75
+ }, [fetchThreads])
76
+
77
+ const deleteThread = useCallback(async (threadId: string) => {
78
+ try {
79
+ const response = await fetch(`${baseUrl}/api/threads/${threadId}`, {
80
+ method: 'DELETE',
81
+ headers,
82
+ })
83
+ if (!response.ok) throw new Error('Failed to delete thread')
84
+ setThreads((prev) => prev.filter((t) => t.id !== threadId))
85
+ } catch (err) {
86
+ console.error('Failed to delete thread:', err)
87
+ throw err
88
+ }
89
+ }, [baseUrl, headers])
90
+
91
+ const updateThreadTitle = useCallback(async (threadId: string, title: string) => {
92
+ // Find the thread and update tags optimistically
93
+ setThreads((prev) => prev.map((t) => {
94
+ if (t.id !== threadId) return t
95
+ // Remove existing title tag and add new one
96
+ const tagsWithoutTitle = t.tags.filter(tag => !tag.startsWith('title:'))
97
+ return { ...t, tags: [...tagsWithoutTitle, `title:${title}`] }
98
+ }))
99
+
100
+ try {
101
+ // Get current thread to preserve other tags
102
+ const thread = threads.find(t => t.id === threadId)
103
+ if (!thread) return
104
+
105
+ const tagsWithoutTitle = thread.tags.filter(tag => !tag.startsWith('title:'))
106
+ const newTags = [...tagsWithoutTitle, `title:${title}`]
107
+
108
+ const response = await fetch(`${baseUrl}/api/threads/${threadId}`, {
109
+ method: 'PATCH',
110
+ headers,
111
+ body: JSON.stringify({ tags: newTags }),
112
+ })
113
+ if (!response.ok) {
114
+ console.error('Failed to update thread title:', response.status)
115
+ fetchThreads() // Revert on failure
116
+ }
117
+ } catch (err) {
118
+ console.error('Failed to update thread:', err)
119
+ fetchThreads() // Revert on failure
120
+ }
121
+ }, [baseUrl, headers, fetchThreads, threads])
122
+
123
+ return {
124
+ threads,
125
+ loading,
126
+ error,
127
+ refreshThreads: fetchThreads,
128
+ deleteThread,
129
+ updateThreadTitle
130
+ }
131
+ }