From 06b71dbe98f8b6c138cfc9ea7a2a4d1b271604f8 Mon Sep 17 00:00:00 2001 From: lost <45652027+0x7551@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:26:11 +0800 Subject: [PATCH] feat: Add Claude Code Agent Teams support (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: tfq Co-authored-by: HAPI --- cli/src/claude/utils/getToolName.ts | 8 +- .../socket/handlers/cli/sessionHandlers.ts | 12 + hub/src/store/index.ts | 25 +- hub/src/store/sessionStore.ts | 5 + hub/src/store/sessions.ts | 36 +++ hub/src/store/types.ts | 2 + hub/src/sync/sessionCache.ts | 18 +- hub/src/sync/teams.test.ts | 73 +++++ hub/src/sync/teams.ts | 287 ++++++++++++++++++ shared/src/schemas.ts | 40 +++ shared/src/types.ts | 4 + web/src/components/SessionChat.tsx | 5 + web/src/components/TeamPanel.tsx | 135 ++++++++ web/src/components/ToolCard/icons.tsx | 21 ++ web/src/components/ToolCard/knownTools.tsx | 35 ++- web/src/types/api.ts | 4 + web/vite.config.ts | 5 +- 17 files changed, 709 insertions(+), 6 deletions(-) create mode 100644 hub/src/sync/teams.test.ts create mode 100644 hub/src/sync/teams.ts create mode 100644 web/src/components/TeamPanel.tsx diff --git a/cli/src/claude/utils/getToolName.ts b/cli/src/claude/utils/getToolName.ts index f14e1fae..cb877645 100644 --- a/cli/src/claude/utils/getToolName.ts +++ b/cli/src/claude/utils/getToolName.ts @@ -28,7 +28,13 @@ const STANDARD_TOOLS: Record = { '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', diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index fd8b15f2..179a5fb4 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -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, diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 94e59675..cfb59a8b 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -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 { + 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 { const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index b8093c52..a4e1090a 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -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) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index c280134d..164b2adf 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -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 diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 9ef422ac..56156492 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -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 diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 05d32e26..c9a0c549 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -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') diff --git a/hub/src/sync/teams.test.ts b/hub/src/sync/teams.test.ts new file mode 100644 index 00000000..968c6b10 --- /dev/null +++ b/hub/src/sync/teams.test.ts @@ -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' }) + }) +}) diff --git a/hub/src/sync/teams.ts b/hub/src/sync/teams.ts new file mode 100644 index 00000000..941f7d13 --- /dev/null +++ b/hub/src/sync/teams.ts @@ -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 & { _action?: 'create' | 'delete' | 'update' } + +function extractToolBlocks(content: Record): Array<{ name: string; input: Record }> { + const blocks: Array<{ name: string; input: Record }> = [] + + // 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 : 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 : null + if (!input) return blocks + blocks.push({ name, input }) + } + + return blocks +} + +function processTeamCreate(input: Record): 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): 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): 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): 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 = { 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): 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 +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 73402684..5fc5c1bb 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -98,6 +98,45 @@ export type TodoItem = z.infer 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 + +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 + +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 + +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 + 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() }) diff --git a/shared/src/types.ts b/shared/src/types.ts index 665c46bb..60f95018 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -7,6 +7,10 @@ export type { Metadata, Session, SyncEvent, + TeamMember, + TeamMessage, + TeamState, + TeamTask, TodoItem, WorktreeMetadata } from './schemas' diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3990cc29..569f40b7 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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 && ( + + )} + {sessionInactive ? (
diff --git a/web/src/components/TeamPanel.tsx b/web/src/components/TeamPanel.tsx new file mode 100644 index 00000000..da065349 --- /dev/null +++ b/web/src/components/TeamPanel.tsx @@ -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 ( +
+ + + {expanded && ( +
+ {teamState.description && ( +

{teamState.description}

+ )} + + {/* Members */} + {members.length > 0 && ( +
+
Members
+
+ {members.map((member) => ( +
+ + {member.name} + {member.agentType && ( + ({member.agentType}) + )} +
+ ))} +
+
+ )} + + {/* Tasks */} + {tasks.length > 0 && ( +
+
Tasks
+
+ {tasks.map((task, idx) => ( +
+ {taskStatusIcon(task.status)} + {' '} + {task.title} + {task.owner && ( + [{task.owner}] + )} +
+ ))} +
+
+ )} + + {/* Recent Messages */} + {messages.length > 0 && ( +
+
Recent Messages
+
+ {messages.slice(-5).map((msg, idx) => ( +
+ {msg.from} + {' \u2192 '} + {msg.to} + {': '} + {msg.summary} +
+ ))} +
+
+ )} +
+ )} +
+ ) +} diff --git a/web/src/components/ToolCard/icons.tsx b/web/src/components/ToolCard/icons.tsx index 24000ad2..ac62953e 100644 --- a/web/src/components/ToolCard/icons.tsx +++ b/web/src/components/ToolCard/icons.tsx @@ -137,3 +137,24 @@ export function QuestionIcon(props: IconProps) { props ) } + +export function UsersIcon(props: IconProps) { + return createIcon( + <> + + + + + , + props + ) +} + +export function MessageSquareIcon(props: IconProps) { + return createIcon( + <> + + , + props + ) +} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 3c7e4407..9bf6eeb1 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -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 , 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 opts.childrenCount === 0 }, + TeamCreate: { + icon: () => , + 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: () => , + title: () => 'Delete Team', + minimal: true + }, + SendMessage: { + icon: () => , + 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: () => , title: (opts) => opts.description ?? 'Terminal', diff --git a/web/src/types/api.ts b/web/src/types/api.ts index fe2c8667..57d720ae 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -14,6 +14,10 @@ export type { Session, SessionSummary, SessionSummaryMetadata, + TeamMember, + TeamMessage, + TeamState, + TeamTask, TodoItem, WorktreeMetadata } from '@hapi/protocol/types' diff --git a/web/vite.config.ts b/web/vite.config.ts index 0e328a04..02bb58cd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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 } }