feat: Add Claude Code Agent Teams support (#258)

* feat: Add Claude Code Agent Teams support

- Add TeamState schemas and types for team collaboration
- Extract team state from TeamCreate, SendMessage, Task tools
- Add database migration V3→V4 for team_state storage
- Add TeamPanel component to display team members, tasks, messages
- Add team tool icons and presentation rules
- Support vite proxy configuration via VITE_HUB_PROXY env var

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: Add timestamp protection for team_state updates

Prevent old messages from overwriting newer team state by checking
team_state_updated_at before updating.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: Extract team tasks from Task/TaskCreate/TaskUpdate tools

- Enhance processTaskToolWithTeam to also generate task entries from
  the Task tool's description field when spawning teammates
- Add processTaskCreate handler for TaskCreate tool calls
- Add processTaskUpdate handler for TaskUpdate tool calls
- Register both new tools in the extraction switch statement

This fixes the gap where the Tasks section in TeamPanel could never
populate because team task data was not being extracted from the
message stream.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: Skip orphan TaskUpdate without title to prevent schema validation failure

When TaskUpdate arrives before TaskCreate (message ordering), skip inserting
incomplete tasks that lack required title field, preventing entire teamState
from being dropped by schema validation.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: Add unit tests for orphan TaskUpdate handling

Verify that applyTeamStateDelta correctly skips inserting tasks without
title field (orphan TaskUpdate) while still allowing normal task creation
and updates to existing tasks.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: tfq <tfq@gmail.com>
Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
lost
2026-03-08 11:26:11 +08:00
committed by GitHub
co-authored by HAPI tfq
parent d0404d9a4f
commit 06b71dbe98
17 changed files with 709 additions and 6 deletions
+7 -1
View File
@@ -28,7 +28,13 @@ const STANDARD_TOOLS: Record<string, string> = {
'TodoWrite': 'Update Tasks',
'TodoRead': 'Read Tasks',
'Task': 'Launch Agent',
// Team management
'TeamCreate': 'Create Team',
'TeamDelete': 'Delete Team',
'SendMessage': 'Send Message',
'EnterWorktree': 'Enter Worktree',
// Web tools
'WebFetch': 'Fetch Web Page',
'WebSearch': 'Search Web',
@@ -5,6 +5,7 @@ import type { ModelMode, PermissionMode } from '@hapi/protocol/types'
import type { Store, StoredSession } from '../../../store'
import type { SyncEvent } from '../../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos'
import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams'
import type { CliSocketWithData } from '../../socketTypes'
import type { AccessErrorReason, AccessResult } from './types'
@@ -95,6 +96,17 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
}
}
const teamDelta = extractTeamStateFromMessageContent(content)
if (teamDelta) {
const existingSession = store.sessions.getSession(sid)
const existingTeamState = existingSession?.teamState as import('@hapi/protocol/types').TeamState | null | undefined
const newTeamState = applyTeamStateDelta(existingTeamState ?? null, teamDelta)
const updated = store.sessions.setSessionTeamState(sid, newTeamState, msg.createdAt, session.namespace)
if (updated) {
onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } })
}
}
const update = {
id: randomUUID(),
seq: msg.seq,
+24 -1
View File
@@ -22,7 +22,7 @@ export { PushStore } from './pushStore'
export { SessionStore } from './sessionStore'
export { UserStore } from './userStore'
const SCHEMA_VERSION: number = 3
const SCHEMA_VERSION: number = 4
const REQUIRED_TABLES = [
'sessions',
'machines',
@@ -110,6 +110,12 @@ export class Store {
return
}
if (currentVersion === 3 && SCHEMA_VERSION === 4) {
this.migrateFromV3ToV4()
this.setUserVersion(SCHEMA_VERSION)
return
}
if (currentVersion !== SCHEMA_VERSION) {
throw this.buildSchemaMismatchError(currentVersion)
}
@@ -132,6 +138,8 @@ export class Store {
agent_state_version INTEGER DEFAULT 1,
todos TEXT,
todos_updated_at INTEGER,
team_state TEXT,
team_state_updated_at INTEGER,
active INTEGER DEFAULT 0,
active_at INTEGER,
seq INTEGER DEFAULT 0
@@ -280,6 +288,21 @@ export class Store {
return
}
private migrateFromV3ToV4(): void {
const columns = this.getSessionColumnNames()
if (!columns.has('team_state')) {
this.db.exec('ALTER TABLE sessions ADD COLUMN team_state TEXT')
}
if (!columns.has('team_state_updated_at')) {
this.db.exec('ALTER TABLE sessions ADD COLUMN team_state_updated_at INTEGER')
}
}
private getSessionColumnNames(): Set<string> {
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
}
private getMachineColumnNames(): Set<string> {
const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
+5
View File
@@ -8,6 +8,7 @@ import {
getSessionByNamespace,
getSessions,
getSessionsByNamespace,
setSessionTeamState,
setSessionTodos,
updateSessionAgentState,
updateSessionMetadata
@@ -47,6 +48,10 @@ export class SessionStore {
return setSessionTodos(this.db, id, todos, todosUpdatedAt, namespace)
}
setSessionTeamState(id: string, teamState: unknown, updatedAt: number, namespace: string): boolean {
return setSessionTeamState(this.db, id, teamState, updatedAt, namespace)
}
getSession(id: string): StoredSession | null {
return getSession(this.db, id)
}
+36
View File
@@ -18,6 +18,8 @@ type DbSessionRow = {
agent_state_version: number
todos: string | null
todos_updated_at: number | null
team_state: string | null
team_state_updated_at: number | null
active: number
active_at: number | null
seq: number
@@ -37,6 +39,8 @@ function toStoredSession(row: DbSessionRow): StoredSession {
agentStateVersion: row.agent_state_version,
todos: safeJsonParse(row.todos),
todosUpdatedAt: row.todos_updated_at,
teamState: safeJsonParse(row.team_state),
teamStateUpdatedAt: row.team_state_updated_at,
active: row.active === 1,
activeAt: row.active_at,
seq: row.seq
@@ -189,6 +193,38 @@ export function setSessionTodos(
}
}
export function setSessionTeamState(
db: Database,
id: string,
teamState: unknown,
updatedAt: number,
namespace: string
): boolean {
try {
const json = teamState === null || teamState === undefined ? null : JSON.stringify(teamState)
const result = db.prepare(`
UPDATE sessions
SET team_state = @team_state,
team_state_updated_at = @team_state_updated_at,
updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END,
seq = seq + 1
WHERE id = @id
AND namespace = @namespace
AND (team_state_updated_at IS NULL OR team_state_updated_at < @team_state_updated_at)
`).run({
id,
team_state: json,
team_state_updated_at: updatedAt,
updated_at: updatedAt,
namespace
})
return result.changes === 1
} catch {
return false
}
}
export function getSession(db: Database, id: string): StoredSession | null {
const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined
return row ? toStoredSession(row) : null
+2
View File
@@ -11,6 +11,8 @@ export type StoredSession = {
agentStateVersion: number
todos: unknown | null
todosUpdatedAt: number | null
teamState: unknown | null
teamStateUpdatedAt: number | null
active: boolean
activeAt: number | null
seq: number
+17 -1
View File
@@ -1,4 +1,4 @@
import { AgentStateSchema, MetadataSchema } from '@hapi/protocol/schemas'
import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas'
import type { ModelMode, PermissionMode, Session } from '@hapi/protocol/types'
import type { Store } from '../store'
import { clampAliveTime } from './aliveTime'
@@ -104,6 +104,12 @@ export class SessionCache {
return parsed.success ? parsed.data : undefined
})()
const teamState = (() => {
if (stored.teamState === null || stored.teamState === undefined) return undefined
const parsed = TeamStateSchema.safeParse(stored.teamState)
return parsed.success ? parsed.data : undefined
})()
const session: Session = {
id: stored.id,
namespace: stored.namespace,
@@ -119,6 +125,7 @@ export class SessionCache {
thinking: existing?.thinking ?? false,
thinkingAt: existing?.thinkingAt ?? 0,
todos,
teamState,
permissionMode: existing?.permissionMode,
modelMode: existing?.modelMode
}
@@ -327,6 +334,15 @@ export class SessionCache {
)
}
if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) {
this.store.sessions.setSessionTeamState(
newSessionId,
oldStored.teamState,
oldStored.teamStateUpdatedAt,
namespace
)
}
const deleted = this.store.sessions.deleteSession(oldSessionId, namespace)
if (!deleted) {
throw new Error('Failed to delete old session during merge')
+73
View File
@@ -0,0 +1,73 @@
import { describe, test, expect } from 'bun:test'
import { applyTeamStateDelta } from './teams'
import type { TeamState, TeamTask } from '@hapi/protocol/types'
const baseTeamState: TeamState = {
teamName: 'test-team',
members: [{ name: 'lead', status: 'active' }],
tasks: [],
messages: [],
updatedAt: 1000
}
function getTasks(result: TeamState | null | undefined): TeamTask[] {
expect(result).toBeTruthy()
return result!.tasks ?? []
}
describe('applyTeamStateDelta - orphan TaskUpdate', () => {
test('should skip inserting task without title (orphan TaskUpdate)', () => {
const result = applyTeamStateDelta(baseTeamState, {
tasks: [{ id: 'task-1', status: 'in_progress' } as any],
updatedAt: 2000
})
expect(getTasks(result)).toEqual([])
})
test('should insert task when title is present (normal TaskCreate)', () => {
const result = applyTeamStateDelta(baseTeamState, {
tasks: [{ id: 'task-1', title: 'Do something', status: 'pending' }],
updatedAt: 2000
})
const tasks = getTasks(result)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ title: 'Do something' })
})
test('should update existing task even without title (normal TaskUpdate)', () => {
const stateWithTask: TeamState = {
...baseTeamState,
tasks: [{ id: 'task-1', title: 'Do something', status: 'pending' }]
}
const result = applyTeamStateDelta(stateWithTask, {
tasks: [{ id: 'task-1', status: 'completed' } as any],
updatedAt: 2000
})
const tasks = getTasks(result)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ title: 'Do something', status: 'completed' })
})
test('should handle mixed: existing task update + orphan new task', () => {
const stateWithTask: TeamState = {
...baseTeamState,
tasks: [{ id: 'task-1', title: 'Existing task', status: 'pending' }]
}
const result = applyTeamStateDelta(stateWithTask, {
tasks: [
{ id: 'task-1', status: 'in_progress' } as any,
{ id: 'task-2', status: 'completed' } as any,
],
updatedAt: 2000
})
const tasks = getTasks(result)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({ id: 'task-1', status: 'in_progress' })
})
})
+287
View File
@@ -0,0 +1,287 @@
import { isObject } from '@hapi/protocol'
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
import type { TeamState } from '@hapi/protocol/types'
type TeamStateDelta = Partial<TeamState> & { _action?: 'create' | 'delete' | 'update' }
function extractToolBlocks(content: Record<string, unknown>): Array<{ name: string; input: Record<string, unknown> }> {
const blocks: Array<{ name: string; input: Record<string, unknown> }> = []
// Claude output format: { type: 'output', data: { type: 'assistant', message: { content: [...] } } }
if (content.type === 'output') {
const data = isObject(content.data) ? content.data : null
if (!data || data.type !== 'assistant') return blocks
const message = isObject(data.message) ? data.message : null
if (!message) return blocks
const modelContent = message.content
if (!Array.isArray(modelContent)) return blocks
for (const block of modelContent) {
if (!isObject(block) || block.type !== 'tool_use') continue
const name = typeof block.name === 'string' ? block.name : null
if (!name) continue
const input = isObject(block.input) ? block.input as Record<string, unknown> : null
if (!input) continue
blocks.push({ name, input })
}
}
// Codex format: { type: 'codex', data: { type: 'tool-call', name: '...', input: {...} } }
if (content.type === 'codex') {
const data = isObject(content.data) ? content.data : null
if (!data || data.type !== 'tool-call') return blocks
const name = typeof data.name === 'string' ? data.name : null
if (!name) return blocks
const input = isObject(data.input) ? data.input as Record<string, unknown> : null
if (!input) return blocks
blocks.push({ name, input })
}
return blocks
}
function processTeamCreate(input: Record<string, unknown>): TeamStateDelta | null {
const teamName = typeof input.team_name === 'string' ? input.team_name : null
if (!teamName) return null
return {
_action: 'create',
teamName,
description: typeof input.description === 'string' ? input.description : undefined,
members: [],
tasks: [],
messages: [],
updatedAt: Date.now()
}
}
function processTeamDelete(): TeamStateDelta {
return { _action: 'delete' }
}
function processTaskToolWithTeam(input: Record<string, unknown>): TeamStateDelta | null {
const teamName = typeof input.team_name === 'string' ? input.team_name : null
const name = typeof input.name === 'string' ? input.name : null
if (!teamName || !name) return null
const agentType = typeof input.subagent_type === 'string' ? input.subagent_type : undefined
const description = typeof input.description === 'string' ? input.description : null
const delta: TeamStateDelta = {
_action: 'update',
members: [{ name, agentType, status: 'active' }],
updatedAt: Date.now()
}
// Also track the spawned agent's work as a task
if (description) {
delta.tasks = [{
id: `agent:${name}`,
title: description,
status: 'in_progress',
owner: name
}]
}
return delta
}
function processTaskCreate(input: Record<string, unknown>): TeamStateDelta | null {
const id = typeof input.task_id === 'string' ? input.task_id
: typeof input.id === 'string' ? input.id
: null
const title = typeof input.title === 'string' ? input.title
: typeof input.content === 'string' ? input.content
: null
if (!id || !title) return null
const description = typeof input.description === 'string' ? input.description : undefined
const status = typeof input.status === 'string' ? input.status as 'pending' | 'in_progress' | 'completed' | 'blocked' : 'pending'
const owner = typeof input.owner === 'string' ? input.owner : undefined
return {
_action: 'update',
tasks: [{ id, title, description, status, owner }],
updatedAt: Date.now()
}
}
function processTaskUpdate(input: Record<string, unknown>): TeamStateDelta | null {
const id = typeof input.task_id === 'string' ? input.task_id
: typeof input.id === 'string' ? input.id
: null
if (!id) return null
const task: Record<string, unknown> = { id }
if (typeof input.title === 'string') task.title = input.title
if (typeof input.status === 'string') task.status = input.status
if (typeof input.owner === 'string') task.owner = input.owner
if (typeof input.description === 'string') task.description = input.description
// Must have at least one field besides id
if (Object.keys(task).length <= 1) return null
return {
_action: 'update',
tasks: [task as { id: string; title: string; status?: 'pending' | 'in_progress' | 'completed' | 'blocked'; owner?: string }],
updatedAt: Date.now()
}
}
function processSendMessage(input: Record<string, unknown>): TeamStateDelta | null {
const type = typeof input.type === 'string' ? input.type : null
if (!type) return null
const summary = typeof input.summary === 'string' ? input.summary : ''
const recipient = typeof input.recipient === 'string' ? input.recipient : 'all'
const validTypes = ['message', 'broadcast', 'shutdown_request', 'shutdown_response'] as const
const msgType = validTypes.includes(type as typeof validTypes[number])
? type as typeof validTypes[number]
: 'message'
const delta: TeamStateDelta = {
_action: 'update',
messages: [{
from: 'team-lead',
to: msgType === 'broadcast' ? 'all' : recipient,
summary,
type: msgType,
timestamp: Date.now()
}],
updatedAt: Date.now()
}
// If shutdown_request with approve=true, mark member as shutdown
if (msgType === 'shutdown_request' && recipient) {
delta.members = [{ name: recipient, status: 'shutdown' }]
}
return delta
}
export function extractTeamStateFromMessageContent(messageContent: unknown): TeamStateDelta | null {
const record = unwrapRoleWrappedRecordEnvelope(messageContent)
if (!record) return null
if (record.role !== 'agent' && record.role !== 'assistant') return null
if (!isObject(record.content) || typeof record.content.type !== 'string') return null
const blocks = extractToolBlocks(record.content)
if (blocks.length === 0) return null
let result: TeamStateDelta | null = null
for (const block of blocks) {
let delta: TeamStateDelta | null = null
switch (block.name) {
case 'TeamCreate':
delta = processTeamCreate(block.input)
break
case 'TeamDelete':
delta = processTeamDelete()
break
case 'Task':
delta = processTaskToolWithTeam(block.input)
break
case 'TaskCreate':
delta = processTaskCreate(block.input)
break
case 'TaskUpdate':
delta = processTaskUpdate(block.input)
break
case 'SendMessage':
delta = processSendMessage(block.input)
break
}
if (delta) {
result = result ? mergeDelta(result, delta) : delta
}
}
return result
}
function mergeDelta(base: TeamStateDelta, incoming: TeamStateDelta): TeamStateDelta {
// delete action overrides everything
if (incoming._action === 'delete') return incoming
// create action overrides everything
if (incoming._action === 'create') return incoming
const merged = { ...base }
if (incoming.members) {
merged.members = [...(merged.members ?? []), ...incoming.members]
}
if (incoming.tasks) {
merged.tasks = [...(merged.tasks ?? []), ...incoming.tasks]
}
if (incoming.messages) {
merged.messages = [...(merged.messages ?? []), ...incoming.messages]
}
if (incoming.updatedAt) {
merged.updatedAt = incoming.updatedAt
}
return merged
}
export function applyTeamStateDelta(
existing: TeamState | null | undefined,
delta: TeamStateDelta
): TeamState | null {
if (delta._action === 'delete') return null
if (delta._action === 'create') {
const { _action: _, ...state } = delta
return state as TeamState
}
// update: merge into existing
if (!existing) return null
const updated = { ...existing }
if (delta.members) {
const memberMap = new Map((updated.members ?? []).map(m => [m.name, m]))
for (const member of delta.members) {
const existing = memberMap.get(member.name)
if (existing) {
memberMap.set(member.name, { ...existing, ...member })
} else {
memberMap.set(member.name, member)
}
}
updated.members = Array.from(memberMap.values())
}
if (delta.tasks) {
const taskMap = new Map((updated.tasks ?? []).map(t => [t.id, t]))
for (const task of delta.tasks) {
const existing = taskMap.get(task.id)
if (existing) {
taskMap.set(task.id, { ...existing, ...task })
} else if (task.title) {
// Only insert new tasks that have a title (required by schema).
// Orphan TaskUpdate without title is ignored to prevent schema validation failure.
taskMap.set(task.id, task)
}
}
updated.tasks = Array.from(taskMap.values())
}
if (delta.messages) {
const msgs = updated.messages ?? []
updated.messages = [...msgs, ...delta.messages].slice(-50)
}
if (delta.updatedAt) {
updated.updatedAt = delta.updatedAt
}
return updated
}
+40
View File
@@ -98,6 +98,45 @@ export type TodoItem = z.infer<typeof TodoItemSchema>
export const TodosSchema = z.array(TodoItemSchema)
export const TeamMemberSchema = z.object({
name: z.string(),
agentType: z.string().optional(),
status: z.enum(['active', 'idle', 'shutdown']).optional()
})
export type TeamMember = z.infer<typeof TeamMemberSchema>
export const TeamTaskSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
status: z.enum(['pending', 'in_progress', 'completed', 'blocked']).optional(),
owner: z.string().optional()
})
export type TeamTask = z.infer<typeof TeamTaskSchema>
export const TeamMessageSchema = z.object({
from: z.string(),
to: z.string(),
summary: z.string(),
type: z.enum(['message', 'broadcast', 'shutdown_request', 'shutdown_response']),
timestamp: z.number()
})
export type TeamMessage = z.infer<typeof TeamMessageSchema>
export const TeamStateSchema = z.object({
teamName: z.string(),
description: z.string().optional(),
members: z.array(TeamMemberSchema).optional(),
tasks: z.array(TeamTaskSchema).optional(),
messages: z.array(TeamMessageSchema).optional(),
updatedAt: z.number().optional()
})
export type TeamState = z.infer<typeof TeamStateSchema>
export const AttachmentMetadataSchema = z.object({
id: z.string(),
filename: z.string(),
@@ -134,6 +173,7 @@ export const SessionSchema = z.object({
thinking: z.boolean(),
thinkingAt: z.number(),
todos: TodosSchema.optional(),
teamState: TeamStateSchema.optional(),
permissionMode: PermissionModeSchema.optional(),
modelMode: ModelModeSchema.optional()
})
+4
View File
@@ -7,6 +7,10 @@ export type {
Metadata,
Session,
SyncEvent,
TeamMember,
TeamMessage,
TeamState,
TeamTask,
TodoItem,
WorktreeMetadata
} from './schemas'
+5
View File
@@ -13,6 +13,7 @@ import { HappyThread } from '@/components/AssistantChat/HappyThread'
import { useHappyRuntime } from '@/lib/assistant-runtime'
import { createAttachmentAdapter } from '@/lib/attachmentAdapter'
import { SessionHeader } from '@/components/SessionHeader'
import { TeamPanel } from '@/components/TeamPanel'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { useVoiceOptional } from '@/lib/voice-context'
@@ -274,6 +275,10 @@ export function SessionChat(props: {
onSessionDeleted={props.onBack}
/>
{props.session.teamState && (
<TeamPanel teamState={props.session.teamState} />
)}
{sessionInactive ? (
<div className="px-3 pt-3">
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
+135
View File
@@ -0,0 +1,135 @@
import { useState } from 'react'
import type { TeamState } from '@hapi/protocol/types'
function memberStatusDot(status?: string): string {
if (status === 'active') return 'bg-emerald-500'
if (status === 'shutdown') return 'bg-red-500'
return 'bg-gray-400'
}
function taskStatusColor(status?: string): string {
if (status === 'completed') return 'text-emerald-600'
if (status === 'in_progress') return 'text-[var(--app-link)]'
if (status === 'blocked') return 'text-red-500'
return 'text-[var(--app-hint)]'
}
function taskStatusIcon(status?: string): string {
if (status === 'completed') return '\u2611'
if (status === 'in_progress') return '\u25b6'
if (status === 'blocked') return '\u26a0'
return '\u2610'
}
export function TeamPanel(props: { teamState: TeamState }) {
const [expanded, setExpanded] = useState(false)
const { teamState } = props
const members = teamState.members ?? []
const tasks = teamState.tasks ?? []
const messages = teamState.messages ?? []
const completedTasks = tasks.filter(t => t.status === 'completed').length
const activeMembers = members.filter(m => m.status === 'active').length
return (
<div className="mx-3 mt-3">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center gap-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--app-subtle-bg-hover)]"
>
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span className="font-medium text-[var(--app-fg)]">
Team: {teamState.teamName}
</span>
<span className="text-xs text-[var(--app-hint)]">
{members.length} member{members.length !== 1 ? 's' : ''}
{activeMembers > 0 ? ` (${activeMembers} active)` : ''}
{tasks.length > 0 ? ` \u00b7 ${completedTasks}/{tasks.length} tasks` : ''}
</span>
<svg
className={`ml-auto h-3 w-3 shrink-0 text-[var(--app-hint)] transition-transform ${expanded ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{expanded && (
<div className="mt-1 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2">
{teamState.description && (
<p className="mb-2 text-xs text-[var(--app-hint)]">{teamState.description}</p>
)}
{/* Members */}
{members.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Members</div>
<div className="flex flex-wrap gap-2">
{members.map((member) => (
<div
key={member.name}
className="flex items-center gap-1.5 rounded-full bg-[var(--app-subtle-bg)] px-2 py-0.5 text-xs"
>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${memberStatusDot(member.status)}`} />
<span className="text-[var(--app-fg)]">{member.name}</span>
{member.agentType && (
<span className="text-[var(--app-hint)]">({member.agentType})</span>
)}
</div>
))}
</div>
</div>
)}
{/* Tasks */}
{tasks.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Tasks</div>
<div className="flex flex-col gap-0.5">
{tasks.map((task, idx) => (
<div key={task.id ?? String(idx)} className={`text-xs ${taskStatusColor(task.status)}`}>
<span>{taskStatusIcon(task.status)}</span>
{' '}
<span>{task.title}</span>
{task.owner && (
<span className="ml-1 text-[var(--app-hint)]">[{task.owner}]</span>
)}
</div>
))}
</div>
</div>
)}
{/* Recent Messages */}
{messages.length > 0 && (
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Recent Messages</div>
<div className="flex flex-col gap-0.5">
{messages.slice(-5).map((msg, idx) => (
<div key={idx} className="text-xs text-[var(--app-hint)]">
<span className="text-[var(--app-fg)]">{msg.from}</span>
{' \u2192 '}
<span className="text-[var(--app-fg)]">{msg.to}</span>
{': '}
<span>{msg.summary}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
+21
View File
@@ -137,3 +137,24 @@ export function QuestionIcon(props: IconProps) {
props
)
}
export function UsersIcon(props: IconProps) {
return createIcon(
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</>,
props
)
}
export function MessageSquareIcon(props: IconProps) {
return createIcon(
<>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</>,
props
)
}
+34 -1
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import type { SessionMetadataSummary } from '@/types/api'
import { isObject } from '@hapi/protocol'
import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, WrenchIcon } from '@/components/ToolCard/icons'
import { BulbIcon, ClipboardIcon, EyeIcon, FileDiffIcon, GlobeIcon, MessageSquareIcon, PuzzleIcon, QuestionIcon, RocketIcon, SearchIcon, TerminalIcon, UsersIcon, WrenchIcon } from '@/components/ToolCard/icons'
import { basename, resolveDisplayPath } from '@/utils/path'
import { getInputStringAny, truncate } from '@/lib/toolInputUtils'
@@ -56,6 +56,9 @@ export const knownTools: Record<string, {
Task: {
icon: () => <RocketIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const name = getInputStringAny(opts.input, ['name'])
const teamName = getInputStringAny(opts.input, ['team_name'])
if (name && teamName) return `Agent: ${name}`
const description = getInputStringAny(opts.input, ['description'])
return description ?? 'Task'
},
@@ -65,6 +68,36 @@ export const knownTools: Record<string, {
},
minimal: (opts) => opts.childrenCount === 0
},
TeamCreate: {
icon: () => <UsersIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const teamName = getInputStringAny(opts.input, ['team_name'])
return teamName ? `Team: ${teamName}` : 'Create Team'
},
subtitle: (opts) => getInputStringAny(opts.input, ['description']) ?? null,
minimal: false
},
TeamDelete: {
icon: () => <UsersIcon className={DEFAULT_ICON_CLASS} />,
title: () => 'Delete Team',
minimal: true
},
SendMessage: {
icon: () => <MessageSquareIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const recipient = getInputStringAny(opts.input, ['recipient'])
const msgType = getInputStringAny(opts.input, ['type'])
if (msgType === 'broadcast') return 'Broadcast'
if (msgType === 'shutdown_request') return `Shutdown: ${recipient ?? 'agent'}`
if (msgType === 'shutdown_response') return 'Shutdown Response'
return recipient ? `Message: ${recipient}` : 'Send Message'
},
subtitle: (opts) => {
const summary = getInputStringAny(opts.input, ['summary'])
return summary ? truncate(summary, 120) : null
},
minimal: true
},
Bash: {
icon: () => <TerminalIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => opts.description ?? 'Terminal',
+4
View File
@@ -14,6 +14,10 @@ export type {
Session,
SessionSummary,
SessionSummaryMetadata,
TeamMember,
TeamMessage,
TeamState,
TeamTask,
TodoItem,
WorktreeMetadata
} from '@hapi/protocol/types'
+3 -2
View File
@@ -6,6 +6,7 @@ import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const base = process.env.VITE_BASE_URL || '/'
const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006'
export default defineConfig({
define: {
@@ -16,11 +17,11 @@ export default defineConfig({
allowedHosts: ['hapidev.weishu.me'],
proxy: {
'/api': {
target: 'http://127.0.0.1:3006',
target: hubTarget,
changeOrigin: true
},
'/socket.io': {
target: 'http://127.0.0.1:3006',
target: hubTarget,
ws: true
}
}