Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/webclaw/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Route as ApiSendRouteImport } from './routes/api/send'
import { Route as ApiPingRouteImport } from './routes/api/ping'
import { Route as ApiPathsRouteImport } from './routes/api/paths'
import { Route as ApiHistoryRouteImport } from './routes/api/history'
import { Route as ApiAgentsRouteImport } from './routes/api/agents'

const NewRoute = NewRouteImport.update({
id: '/new',
Expand Down Expand Up @@ -70,11 +71,17 @@ const ApiHistoryRoute = ApiHistoryRouteImport.update({
path: '/api/history',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAgentsRoute = ApiAgentsRouteImport.update({
id: '/api/agents',
path: '/api/agents',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/connect': typeof ConnectRoute
'/new': typeof NewRoute
'/api/agents': typeof ApiAgentsRoute
'/api/history': typeof ApiHistoryRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
Expand All @@ -87,6 +94,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/connect': typeof ConnectRoute
'/new': typeof NewRoute
'/api/agents': typeof ApiAgentsRoute
'/api/history': typeof ApiHistoryRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
Expand All @@ -100,6 +108,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/connect': typeof ConnectRoute
'/new': typeof NewRoute
'/api/agents': typeof ApiAgentsRoute
'/api/history': typeof ApiHistoryRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
Expand All @@ -114,6 +123,7 @@ export interface FileRouteTypes {
| '/'
| '/connect'
| '/new'
| '/api/agents'
| '/api/history'
| '/api/paths'
| '/api/ping'
Expand All @@ -126,6 +136,7 @@ export interface FileRouteTypes {
| '/'
| '/connect'
| '/new'
| '/api/agents'
| '/api/history'
| '/api/paths'
| '/api/ping'
Expand All @@ -138,6 +149,7 @@ export interface FileRouteTypes {
| '/'
| '/connect'
| '/new'
| '/api/agents'
| '/api/history'
| '/api/paths'
| '/api/ping'
Expand All @@ -151,6 +163,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConnectRoute: typeof ConnectRoute
NewRoute: typeof NewRoute
ApiAgentsRoute: typeof ApiAgentsRoute
ApiHistoryRoute: typeof ApiHistoryRoute
ApiPathsRoute: typeof ApiPathsRoute
ApiPingRoute: typeof ApiPingRoute
Expand Down Expand Up @@ -232,13 +245,21 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHistoryRouteImport
parentRoute: typeof rootRouteImport
}
'/api/agents': {
id: '/api/agents'
path: '/api/agents'
fullPath: '/api/agents'
preLoaderRoute: typeof ApiAgentsRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConnectRoute: ConnectRoute,
NewRoute: NewRoute,
ApiAgentsRoute: ApiAgentsRoute,
ApiHistoryRoute: ApiHistoryRoute,
ApiPathsRoute: ApiPathsRoute,
ApiPingRoute: ApiPingRoute,
Expand Down
33 changes: 33 additions & 0 deletions apps/webclaw/src/routes/api/agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { gatewayRpc } from '../../server/gateway'

type AgentsListGatewayResponse = {
agents?: Array<Record<string, unknown>>
}

export const Route = createFileRoute('/api/agents')({
server: {
handlers: {
GET: async () => {
try {
const payload = await gatewayRpc<AgentsListGatewayResponse>(
'agents.list',
{},
)
const agents = Array.isArray(payload.agents) ? payload.agents : []
const defaultAgentId =
(process.env.CLAWDBOT_AGENT_ID || 'main').trim() || 'main'
return json({ agents, defaultAgentId })
} catch (err) {
return json(
{
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})
21 changes: 13 additions & 8 deletions apps/webclaw/src/routes/api/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,27 @@ export const Route = createFileRoute('/api/sessions')({
typeof body.label === 'string' ? body.label.trim() : ''
const label = requestedLabel || undefined

const requestedAgentId =
typeof body.agentId === 'string' ? body.agentId.trim() : ''
const agentId = requestedAgentId || 'main'

const friendlyId = randomUUID()
const sessionKey = `agent:${agentId}:${friendlyId}`

const params: Record<string, unknown> = { key: friendlyId }
const params: Record<string, unknown> = { key: sessionKey }
if (label) params.label = label

const payload = await gatewayRpc<SessionsPatchResponse>(
'sessions.patch',
params,
)

const sessionKeyRaw = payload.key
const sessionKey =
typeof sessionKeyRaw === 'string' && sessionKeyRaw.trim().length > 0
? sessionKeyRaw.trim()
: ''
if (sessionKey.length === 0) {
const responseKey = payload.key
const resolvedSessionKey =
typeof responseKey === 'string' && responseKey.trim().length > 0
? responseKey.trim()
: sessionKey
if (resolvedSessionKey.length === 0) {
throw new Error('gateway returned an invalid response')
}

Expand All @@ -121,7 +126,7 @@ export const Route = createFileRoute('/api/sessions')({

return json({
ok: true,
sessionKey,
sessionKey: resolvedSessionKey,
friendlyId,
entry: payload.entry,
})
Expand Down
26 changes: 26 additions & 0 deletions apps/webclaw/src/screens/chat/chat-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type GatewayStatusResponse = {

export const chatQueryKeys = {
sessions: ['chat', 'sessions'] as const,
agents: ['chat', 'agents'] as const,
history: function history(friendlyId: string, sessionKey: string) {
return ['chat', 'history', friendlyId, sessionKey] as const
},
Expand Down Expand Up @@ -56,6 +57,31 @@ export async function fetchGatewayStatus(): Promise<GatewayStatusResponse> {
}
}

type AgentsResult = {
agents: Array<{ id: string; name: string }>
defaultAgentId: string
}

export async function fetchAgents(): Promise<AgentsResult> {
const res = await fetch('/api/agents')
if (!res.ok) throw new Error(await readError(res))
const data = await res.json() as {
agents?: Array<{ id: string; name?: string }>
defaultAgentId?: string
}
const agents = Array.isArray(data.agents)
? data.agents.map((agent) => ({
id: agent.id,
name: agent.name || agent.id,
}))
: []
const defaultAgentId =
typeof data.defaultAgentId === 'string' && data.defaultAgentId.trim().length > 0
? data.defaultAgentId.trim()
: 'main'
return { agents, defaultAgentId }
}

export function updateHistoryMessages(
queryClient: QueryClient,
friendlyId: string,
Expand Down
38 changes: 36 additions & 2 deletions apps/webclaw/src/screens/chat/chat-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Navigate, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'

import {
deriveAgentIdFromKey,
deriveFriendlyIdFromKey,
isMissingGatewayAuth,
isSessionNotFound,
Expand All @@ -13,6 +14,7 @@ import {
appendHistoryMessage,
chatQueryKeys,
clearHistoryMessages,
fetchAgents,
fetchGatewayStatus,
removeHistoryMessageByClientId,
updateHistoryMessageByClientId,
Expand Down Expand Up @@ -68,6 +70,7 @@ export function ChatScreen({
const [sending, setSending] = useState(false)
const [creatingSession, setCreatingSession] = useState(false)
const [isRedirecting, setIsRedirecting] = useState(false)
const [selectedAgentId, setSelectedAgentId] = useState('main')
const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } =
useChatMeasurements()
const [waitingForResponse, setWaitingForResponse] = useState(
Expand Down Expand Up @@ -123,6 +126,33 @@ export function ChatScreen({
},
staleTime: Infinity,
})

const agentsQuery = useQuery({
queryKey: chatQueryKeys.agents,
queryFn: fetchAgents,
staleTime: 5 * 60 * 1000,
})

// Set the correct agent ID for the current context
const currentAgentId = useMemo(() => {
if (isNewChat) return selectedAgentId
return deriveAgentIdFromKey(activeCanonicalKey) || 'main'
}, [isNewChat, selectedAgentId, activeCanonicalKey])

const defaultAgentIdAppliedRef = useRef(false)
useEffect(() => {
if (defaultAgentIdAppliedRef.current) return
const data = agentsQuery.data
if (!data) return
defaultAgentIdAppliedRef.current = true
const { agents, defaultAgentId } = data
const matched = agents.some((agent) => agent.id === defaultAgentId)
if (matched) {
setSelectedAgentId(defaultAgentId)
} else if (agents.length > 0) {
setSelectedAgentId(agents[0].id)
}
}, [agentsQuery.data])
const gatewayStatusQuery = useQuery({
queryKey: ['gateway', 'status'],
queryFn: fetchGatewayStatus,
Expand Down Expand Up @@ -318,7 +348,7 @@ export function ChatScreen({
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ agentId: selectedAgentId }),
})
if (!res.ok) throw new Error(await readError(res))

Expand All @@ -343,7 +373,7 @@ export function ChatScreen({
} finally {
setCreatingSession(false)
}
}, [queryClient])
}, [queryClient, selectedAgentId])

const send = useCallback(
(body: string, helpers: ChatComposerHelpers) => {
Expand Down Expand Up @@ -596,6 +626,10 @@ export function ChatScreen({
showExport={!isNewChat}
usedTokens={activeSession?.totalTokens}
maxTokens={activeSession?.contextTokens}
isNewChat={isNewChat}
agents={agentsQuery.data?.agents}
selectedAgentId={currentAgentId}
onSelectAgent={setSelectedAgentId}
/>

{hideUi ? null : (
Expand Down
31 changes: 30 additions & 1 deletion apps/webclaw/src/screens/chat/components/chat-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ type ChatHeaderProps = {
onExport: (format: ExportFormat) => void
exportDisabled?: boolean
showExport?: boolean
isNewChat?: boolean
agents?: Array<{ id: string; name: string }>
selectedAgentId?: string
onSelectAgent?: (id: string) => void
}

function ChatHeaderComponent({
Expand All @@ -29,6 +33,10 @@ function ChatHeaderComponent({
onExport,
exportDisabled = false,
showExport = true,
isNewChat = false,
agents = [],
selectedAgentId,
onSelectAgent,
}: ChatHeaderProps) {
return (
<div
Expand All @@ -46,8 +54,29 @@ function ChatHeaderComponent({
<HugeiconsIcon icon={Menu01Icon} size={18} strokeWidth={1.6} />
</Button>
) : null}
<div className="flex-1 min-w-0 text-sm font-medium truncate">
<div className="flex-1 min-w-0 text-sm font-medium truncate flex items-center gap-3">
{activeTitle}
{isNewChat && agents.length > 0 ? (
<label className="flex items-center gap-1.5 text-xs text-primary-600 shrink-0">
Agent
<select
value={selectedAgentId}
onChange={(e) => onSelectAgent?.(e.target.value)}
className="text-xs bg-surface border border-primary-200 rounded px-2 py-1 text-primary-900 outline-none focus:border-primary-400"
aria-label="Select an agent"
>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</label>
) : !isNewChat && selectedAgentId ? (
<span className="text-xs text-primary-500 font-normal shrink-0">
Agent: {agents.find((a) => a.id === selectedAgentId)?.name || selectedAgentId}
</span>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
{showExport ? (
Expand Down
13 changes: 13 additions & 0 deletions apps/webclaw/src/screens/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ export function deriveFriendlyIdFromKey(key: string | undefined): string {
return tailTrimmed.length > 0 ? tailTrimmed : trimmed
}

export function deriveAgentIdFromKey(key: string | undefined): string | null {
if (!key) return null
const trimmed = key.trim()
if (trimmed.length === 0) return null
const parts = trimmed.split(':')
// format is typically agent:<agentId>:<uuid>
if (parts.length >= 3 && parts[0] === 'agent') {
const agentId = parts[1]
return agentId && agentId.trim().length > 0 ? agentId.trim() : null
}
return null
}

export function textFromMessage(msg: GatewayMessage): string {
const parts = Array.isArray(msg.content) ? msg.content : []
return parts
Expand Down