clawport-ui 0.1.0

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.
Files changed (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,12 @@
1
+ import { getAgents } from '@/lib/agents'
2
+ import { apiErrorResponse } from '@/lib/api-error'
3
+ import { NextResponse } from 'next/server'
4
+
5
+ export async function GET() {
6
+ try {
7
+ const agents = await getAgents()
8
+ return NextResponse.json(agents)
9
+ } catch (err) {
10
+ return apiErrorResponse(err, 'Failed to load agents')
11
+ }
12
+ }
@@ -0,0 +1,139 @@
1
+ export const runtime = 'nodejs'
2
+
3
+ import { getAgent } from '@/lib/agents'
4
+ import { validateChatMessages } from '@/lib/validation'
5
+ import { hasImageContent, extractImageAttachments, buildTextPrompt, sendViaOpenClaw } from '@/lib/anthropic'
6
+ import OpenAI from 'openai'
7
+
8
+ // Route through the OpenClaw gateway — no separate API key needed
9
+ const openai = new OpenAI({
10
+ baseURL: 'http://localhost:18789/v1',
11
+ apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
12
+ })
13
+
14
+ const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || ''
15
+
16
+ export async function POST(
17
+ request: Request,
18
+ { params }: { params: Promise<{ id: string }> }
19
+ ) {
20
+ const { id } = await params
21
+ const agent = await getAgent(id)
22
+
23
+ if (!agent) {
24
+ return new Response(JSON.stringify({ error: 'Agent not found' }), {
25
+ status: 404,
26
+ headers: { 'Content-Type': 'application/json' },
27
+ })
28
+ }
29
+
30
+ let body: unknown
31
+ try {
32
+ body = await request.json()
33
+ } catch {
34
+ return new Response(
35
+ JSON.stringify({ error: 'Invalid JSON in request body.' }),
36
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
37
+ )
38
+ }
39
+
40
+ const result = validateChatMessages(body)
41
+ if (!result.ok) {
42
+ return new Response(
43
+ JSON.stringify({ error: result.error }),
44
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
45
+ )
46
+ }
47
+
48
+ const { messages } = result
49
+
50
+ const rawBody = body as Record<string, unknown>
51
+ const operatorName = typeof rawBody.operatorName === 'string' ? rawBody.operatorName : 'Operator'
52
+
53
+ const systemPrompt = agent.soul
54
+ ? `${agent.soul}\n\nYou are speaking directly with ${operatorName}, your operator. Stay fully in character. Be concise — this is a live chat. 2-4 sentences unless detail is asked for. No em dashes.`
55
+ : `You are ${agent.name}, ${agent.title}. Respond in character. Be concise. No em dashes.`
56
+
57
+ // When the LATEST user message contains images, use the OpenClaw gateway's
58
+ // chat.send pipeline. Only check the last message — older messages with images
59
+ // should not force all future messages through this path.
60
+ const lastUserMsg = [...messages].reverse().find(m => m.role === 'user')
61
+ const latestHasImages = lastUserMsg ? hasImageContent([lastUserMsg]) : false
62
+
63
+ if (latestHasImages && GATEWAY_TOKEN) {
64
+ const attachments = extractImageAttachments([lastUserMsg!])
65
+ const textPrompt = buildTextPrompt(systemPrompt, messages)
66
+
67
+ const response = await sendViaOpenClaw({
68
+ gatewayToken: GATEWAY_TOKEN,
69
+ message: textPrompt,
70
+ attachments,
71
+ })
72
+
73
+ // Return as a non-streaming SSE response (complete text at once)
74
+ const encoder = new TextEncoder()
75
+ const content = response || 'I had trouble processing that image. Could you try again or describe what you see?'
76
+ const streamBody = new ReadableStream({
77
+ start(controller) {
78
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content })}\n\n`))
79
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
80
+ controller.close()
81
+ },
82
+ })
83
+
84
+ return new Response(streamBody, {
85
+ headers: {
86
+ 'Content-Type': 'text/event-stream',
87
+ 'Cache-Control': 'no-cache',
88
+ Connection: 'keep-alive',
89
+ },
90
+ })
91
+ }
92
+
93
+ try {
94
+ const stream = await openai.chat.completions.create({
95
+ model: 'claude-sonnet-4-6',
96
+ stream: true,
97
+ messages: [
98
+ { role: 'system' as const, content: systemPrompt },
99
+ ...messages.map(m => ({ role: m.role, content: m.content })),
100
+ ] as OpenAI.ChatCompletionMessageParam[],
101
+ })
102
+
103
+ const streamBody = new ReadableStream({
104
+ async start(controller) {
105
+ const encoder = new TextEncoder()
106
+ try {
107
+ for await (const chunk of stream) {
108
+ const content = chunk.choices[0]?.delta?.content || ''
109
+ if (content) {
110
+ controller.enqueue(
111
+ encoder.encode(`data: ${JSON.stringify({ content })}\n\n`)
112
+ )
113
+ }
114
+ }
115
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
116
+ } catch (err) {
117
+ console.error('Stream error:', err)
118
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
119
+ } finally {
120
+ controller.close()
121
+ }
122
+ },
123
+ })
124
+
125
+ return new Response(streamBody, {
126
+ headers: {
127
+ 'Content-Type': 'text/event-stream',
128
+ 'Cache-Control': 'no-cache',
129
+ Connection: 'keep-alive',
130
+ },
131
+ })
132
+ } catch (err) {
133
+ console.error('Chat API error:', err)
134
+ return new Response(
135
+ JSON.stringify({ error: 'Chat failed. Make sure OpenClaw gateway is running.' }),
136
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
137
+ )
138
+ }
139
+ }
@@ -0,0 +1,13 @@
1
+ import { getCronRuns } from '@/lib/cron-runs'
2
+ import { apiErrorResponse } from '@/lib/api-error'
3
+ import { NextRequest, NextResponse } from 'next/server'
4
+
5
+ export async function GET(request: NextRequest) {
6
+ try {
7
+ const jobId = request.nextUrl.searchParams.get('jobId') ?? undefined
8
+ const runs = getCronRuns(jobId)
9
+ return NextResponse.json(runs)
10
+ } catch (err) {
11
+ return apiErrorResponse(err, 'Failed to load cron runs')
12
+ }
13
+ }
@@ -0,0 +1,12 @@
1
+ import { getCrons } from '@/lib/crons'
2
+ import { apiErrorResponse } from '@/lib/api-error'
3
+ import { NextResponse } from 'next/server'
4
+
5
+ export async function GET() {
6
+ try {
7
+ const crons = await getCrons()
8
+ return NextResponse.json(crons)
9
+ } catch (err) {
10
+ return apiErrorResponse(err, 'Failed to load cron jobs')
11
+ }
12
+ }
@@ -0,0 +1,119 @@
1
+ export const runtime = 'nodejs'
2
+
3
+ import { getAgent } from '@/lib/agents'
4
+ import OpenAI from 'openai'
5
+
6
+ const openai = new OpenAI({
7
+ baseURL: 'http://localhost:18789/v1',
8
+ apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
9
+ })
10
+
11
+ export async function POST(
12
+ request: Request,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const { id } = await params
16
+ const agent = await getAgent(id)
17
+
18
+ if (!agent) {
19
+ return new Response(JSON.stringify({ error: 'Agent not found' }), {
20
+ status: 404,
21
+ headers: { 'Content-Type': 'application/json' },
22
+ })
23
+ }
24
+
25
+ let body: { messages?: unknown; ticket?: unknown }
26
+ try {
27
+ body = await request.json()
28
+ } catch {
29
+ return new Response(
30
+ JSON.stringify({ error: 'Invalid JSON in request body.' }),
31
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
32
+ )
33
+ }
34
+
35
+ const { messages, ticket } = body as {
36
+ messages: { role: 'user' | 'assistant'; content: string }[]
37
+ ticket: {
38
+ title: string
39
+ description: string
40
+ status: string
41
+ priority: string
42
+ assigneeRole: string | null
43
+ workResult: string | null
44
+ }
45
+ }
46
+
47
+ if (!Array.isArray(messages)) {
48
+ return new Response(
49
+ JSON.stringify({ error: 'messages must be an array' }),
50
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
51
+ )
52
+ }
53
+
54
+ // Build system prompt with ticket context
55
+ const workContext = ticket?.workResult
56
+ ? `\n\nYou already completed work on this ticket. Here is what you produced:\n${ticket.workResult}\n\nReference this work when answering follow-up questions. Build on it, don't repeat it unless asked.`
57
+ : ''
58
+
59
+ const ticketContext = ticket
60
+ ? `You are working on ticket: "${ticket.title}".
61
+ Description: ${ticket.description || 'No description provided.'}
62
+ Status: ${ticket.status}
63
+ Priority: ${ticket.priority}
64
+ Your role: ${ticket.assigneeRole || 'unassigned'}${workContext}
65
+
66
+ Help the user with this ticket. Stay in character as ${agent.name}, ${agent.title}. Be concise — 2-4 sentences unless detail is asked for. No em dashes.`
67
+ : `You are ${agent.name}, ${agent.title}. Respond in character. Be concise. No em dashes.`
68
+
69
+ const systemPrompt = agent.soul
70
+ ? `${agent.soul}\n\n${ticketContext}`
71
+ : ticketContext
72
+
73
+ try {
74
+ const stream = await openai.chat.completions.create({
75
+ model: 'claude-sonnet-4-6',
76
+ stream: true,
77
+ messages: [
78
+ { role: 'system' as const, content: systemPrompt },
79
+ ...messages.map(m => ({ role: m.role, content: m.content })),
80
+ ] as OpenAI.ChatCompletionMessageParam[],
81
+ })
82
+
83
+ const streamBody = new ReadableStream({
84
+ async start(controller) {
85
+ const encoder = new TextEncoder()
86
+ try {
87
+ for await (const chunk of stream) {
88
+ const content = chunk.choices[0]?.delta?.content || ''
89
+ if (content) {
90
+ controller.enqueue(
91
+ encoder.encode(`data: ${JSON.stringify({ content })}\n\n`)
92
+ )
93
+ }
94
+ }
95
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
96
+ } catch (err) {
97
+ console.error('Kanban chat stream error:', err)
98
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
99
+ } finally {
100
+ controller.close()
101
+ }
102
+ },
103
+ })
104
+
105
+ return new Response(streamBody, {
106
+ headers: {
107
+ 'Content-Type': 'text/event-stream',
108
+ 'Cache-Control': 'no-cache',
109
+ Connection: 'keep-alive',
110
+ },
111
+ })
112
+ } catch (err) {
113
+ console.error('Kanban chat API error:', err)
114
+ return new Response(
115
+ JSON.stringify({ error: 'Chat failed. Make sure OpenClaw gateway is running.' }),
116
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
117
+ )
118
+ }
119
+ }
@@ -0,0 +1,36 @@
1
+ import { NextRequest } from 'next/server'
2
+ import { getChatMessages, appendChatMessages, StoredChatMessage } from '@/lib/kanban/chat-store'
3
+ import { apiErrorResponse } from '@/lib/api-error'
4
+
5
+ export async function GET(
6
+ _req: NextRequest,
7
+ { params }: { params: Promise<{ ticketId: string }> },
8
+ ) {
9
+ try {
10
+ const { ticketId } = await params
11
+ const messages = getChatMessages(ticketId)
12
+ return Response.json(messages)
13
+ } catch (err) {
14
+ return apiErrorResponse(err, 'Failed to load chat history')
15
+ }
16
+ }
17
+
18
+ export async function POST(
19
+ req: NextRequest,
20
+ { params }: { params: Promise<{ ticketId: string }> },
21
+ ) {
22
+ try {
23
+ const { ticketId } = await params
24
+ const body = await req.json()
25
+ const messages: StoredChatMessage[] = body.messages
26
+
27
+ if (!Array.isArray(messages) || messages.length === 0) {
28
+ return Response.json({ error: 'messages array required' }, { status: 400 })
29
+ }
30
+
31
+ appendChatMessages(ticketId, messages)
32
+ return Response.json({ ok: true })
33
+ } catch (err) {
34
+ return apiErrorResponse(err, 'Failed to save chat history')
35
+ }
36
+ }
@@ -0,0 +1,12 @@
1
+ import { getMemoryFiles } from '@/lib/memory'
2
+ import { apiErrorResponse } from '@/lib/api-error'
3
+ import { NextResponse } from 'next/server'
4
+
5
+ export async function GET() {
6
+ try {
7
+ const files = await getMemoryFiles()
8
+ return NextResponse.json(files)
9
+ } catch (err) {
10
+ return apiErrorResponse(err, 'Failed to load memory files')
11
+ }
12
+ }
@@ -0,0 +1,37 @@
1
+ export const runtime = 'nodejs'
2
+
3
+ import OpenAI from 'openai'
4
+
5
+ const openai = new OpenAI({
6
+ baseURL: 'http://localhost:18789/v1',
7
+ apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
8
+ })
9
+
10
+ export async function POST(request: Request) {
11
+ let formData: FormData
12
+ try {
13
+ formData = await request.formData()
14
+ } catch {
15
+ return Response.json({ error: 'Expected multipart form data' }, { status: 400 })
16
+ }
17
+
18
+ const audioFile = formData.get('audio')
19
+ if (!audioFile || !(audioFile instanceof File)) {
20
+ return Response.json({ error: 'Missing audio file' }, { status: 400 })
21
+ }
22
+
23
+ try {
24
+ const transcription = await openai.audio.transcriptions.create({
25
+ model: 'whisper-1',
26
+ file: audioFile,
27
+ })
28
+
29
+ return Response.json({ text: transcription.text })
30
+ } catch (err) {
31
+ console.error('Transcription error:', err)
32
+ return Response.json(
33
+ { error: 'Transcription failed. Check OpenClaw gateway.' },
34
+ { status: 500 }
35
+ )
36
+ }
37
+ }
@@ -0,0 +1,42 @@
1
+ export const runtime = 'nodejs'
2
+
3
+ import OpenAI from 'openai'
4
+
5
+ const openai = new OpenAI({
6
+ baseURL: 'http://localhost:18789/v1',
7
+ apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
8
+ })
9
+
10
+ export async function POST(request: Request) {
11
+ try {
12
+ const { text, voice } = await request.json()
13
+
14
+ if (!text || typeof text !== 'string') {
15
+ return new Response(JSON.stringify({ error: 'Missing or invalid "text" field' }), {
16
+ status: 400,
17
+ headers: { 'Content-Type': 'application/json' },
18
+ })
19
+ }
20
+
21
+ const response = await openai.audio.speech.create({
22
+ model: 'tts-1',
23
+ voice: voice || 'alloy',
24
+ input: text,
25
+ })
26
+
27
+ const buffer = Buffer.from(await response.arrayBuffer())
28
+
29
+ return new Response(buffer, {
30
+ headers: {
31
+ 'Content-Type': 'audio/mpeg',
32
+ 'Content-Length': String(buffer.length),
33
+ },
34
+ })
35
+ } catch (err) {
36
+ console.error('TTS API error:', err)
37
+ return new Response(
38
+ JSON.stringify({ error: 'TTS failed. Make sure OpenClaw gateway is running.' }),
39
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
40
+ )
41
+ }
42
+ }
@@ -0,0 +1,10 @@
1
+ 'use client'
2
+ import { use, useEffect } from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+
5
+ export default function ChatRedirect({ params }: { params: Promise<{ id: string }> }) {
6
+ const { id } = use(params)
7
+ const router = useRouter()
8
+ useEffect(() => { router.replace(`/chat?agent=${id}`) }, [id, router])
9
+ return null
10
+ }
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+ import { useEffect, useState, useCallback, Suspense } from 'react'
3
+ import { useSearchParams, useRouter } from 'next/navigation'
4
+ import type { Agent } from '@/lib/types'
5
+ import { AgentList, AgentListMobile } from '@/components/chat/AgentList'
6
+ import { ConversationView } from '@/components/chat/ConversationView'
7
+ import {
8
+ loadConversations, saveConversations, getOrCreateConversation,
9
+ markRead, type ConversationStore
10
+ } from '@/lib/conversations'
11
+
12
+ function MessengerApp() {
13
+ const router = useRouter()
14
+ const searchParams = useSearchParams()
15
+ const [agents, setAgents] = useState<Agent[]>([])
16
+ const [conversations, setConversations] = useState<ConversationStore>({})
17
+ const [activeAgentId, setActiveAgentId] = useState<string | null>(searchParams.get('agent'))
18
+ const [loading, setLoading] = useState(true)
19
+ const [mobileShowConversation, setMobileShowConversation] = useState(!!searchParams.get('agent'))
20
+
21
+ // Load agents
22
+ useEffect(() => {
23
+ fetch('/api/agents').then(r => r.json()).then((data: Agent[]) => {
24
+ setAgents(data)
25
+ setLoading(false)
26
+ })
27
+ }, [])
28
+
29
+ // Load conversations from localStorage
30
+ useEffect(() => {
31
+ setConversations(loadConversations())
32
+ }, [])
33
+
34
+ // Save conversations whenever they change
35
+ useEffect(() => {
36
+ if (Object.keys(conversations).length > 0) {
37
+ saveConversations(conversations)
38
+ }
39
+ }, [conversations])
40
+
41
+ // Set default active agent on desktop only (don't auto-select on mobile)
42
+ useEffect(() => {
43
+ if (!loading && agents.length > 0 && !activeAgentId) {
44
+ // On desktop (>= 768px), select first agent
45
+ if (window.innerWidth >= 768) {
46
+ setActiveAgentId(agents[0].id)
47
+ }
48
+ }
49
+ }, [loading, agents, activeAgentId])
50
+
51
+ const handleSelectAgent = useCallback((agent: Agent) => {
52
+ setActiveAgentId(agent.id)
53
+ setMobileShowConversation(true)
54
+ setConversations(prev => {
55
+ const conv = getOrCreateConversation(prev, agent)
56
+ const next = { ...prev, [agent.id]: conv }
57
+ return markRead(next, agent.id)
58
+ })
59
+ router.replace(`/chat?agent=${agent.id}`, { scroll: false })
60
+ }, [router])
61
+
62
+ const handleConversationUpdate = useCallback((agentId: string, updater: (prev: ConversationStore) => ConversationStore) => {
63
+ setConversations(prev => updater(prev))
64
+ }, [])
65
+
66
+ const handleMobileBack = useCallback(() => {
67
+ setMobileShowConversation(false)
68
+ }, [])
69
+
70
+ const activeAgent = agents.find(a => a.id === activeAgentId) || null
71
+
72
+ // Init conversation for active agent
73
+ useEffect(() => {
74
+ if (activeAgent) {
75
+ setConversations(prev => {
76
+ const conv = getOrCreateConversation(prev, activeAgent)
77
+ return markRead({ ...prev, [activeAgent.id]: conv }, activeAgent.id)
78
+ })
79
+ }
80
+ }, [activeAgent?.id]) // eslint-disable-line react-hooks/exhaustive-deps
81
+
82
+ return (
83
+ <div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
84
+ {/* Desktop sidebar — always visible on md+ */}
85
+ <AgentList
86
+ agents={agents}
87
+ conversations={conversations}
88
+ activeId={activeAgentId}
89
+ onSelect={handleSelectAgent}
90
+ loading={loading}
91
+ />
92
+
93
+ {/* Mobile agent list — shown when no conversation selected */}
94
+ <div
95
+ className={`md:hidden ${mobileShowConversation ? 'hidden' : 'flex flex-col'}`}
96
+ style={{
97
+ flex: 1,
98
+ height: '100%',
99
+ }}
100
+ >
101
+ <AgentListMobile
102
+ agents={agents}
103
+ conversations={conversations}
104
+ onSelect={handleSelectAgent}
105
+ loading={loading}
106
+ />
107
+ </div>
108
+
109
+ {/* Desktop conversation view — visible when agent selected on md+ */}
110
+ <div
111
+ className="hidden md:flex md:flex-col"
112
+ style={{ flex: 1, height: '100%' }}
113
+ >
114
+ {activeAgent && conversations[activeAgent.id] ? (
115
+ <ConversationView
116
+ key={activeAgent.id}
117
+ agent={activeAgent}
118
+ conversation={conversations[activeAgent.id]}
119
+ onUpdate={handleConversationUpdate}
120
+ />
121
+ ) : (
122
+ <EmptyState />
123
+ )}
124
+ </div>
125
+
126
+ {/* Mobile conversation view — shown full width when agent selected */}
127
+ {mobileShowConversation && activeAgent && conversations[activeAgent.id] && (
128
+ <div
129
+ className="flex flex-col md:hidden"
130
+ style={{
131
+ position: 'fixed',
132
+ inset: 0,
133
+ zIndex: 20,
134
+ background: 'var(--bg)',
135
+ }}
136
+ >
137
+ <ConversationView
138
+ key={activeAgent.id}
139
+ agent={activeAgent}
140
+ conversation={conversations[activeAgent.id]}
141
+ onUpdate={handleConversationUpdate}
142
+ onBack={handleMobileBack}
143
+ />
144
+ </div>
145
+ )}
146
+ </div>
147
+ )
148
+ }
149
+
150
+ function EmptyState() {
151
+ return (
152
+ <div style={{
153
+ flex: 1,
154
+ display: 'flex',
155
+ flexDirection: 'column',
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ background: 'var(--bg)',
159
+ gap: 'var(--space-3)',
160
+ padding: 'var(--space-8)',
161
+ }}>
162
+ <div style={{ fontSize: 48, marginBottom: 'var(--space-2)' }}>
163
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
164
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
165
+ </svg>
166
+ </div>
167
+ <div style={{
168
+ fontSize: 'var(--text-title3)',
169
+ fontWeight: 'var(--weight-bold)',
170
+ color: 'var(--text-primary)',
171
+ letterSpacing: '-0.3px',
172
+ }}>
173
+ ClawPort Messages
174
+ </div>
175
+ <div style={{
176
+ fontSize: 'var(--text-subheadline)',
177
+ color: 'var(--text-secondary)',
178
+ textAlign: 'center',
179
+ lineHeight: 'var(--leading-relaxed)',
180
+ }}>
181
+ Select an agent from the sidebar to start chatting
182
+ </div>
183
+ <div style={{
184
+ fontSize: 'var(--text-caption1)',
185
+ color: 'var(--text-quaternary)',
186
+ marginTop: 'var(--space-2)',
187
+ }}>
188
+ Press Cmd+K to search agents
189
+ </div>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ export default function ChatPage() {
195
+ return (
196
+ <Suspense>
197
+ <MessengerApp />
198
+ </Suspense>
199
+ )
200
+ }