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
+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'