mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Desktop sessions were missing a visible request_access approval path and could mis-detect their own app window as an unapproved frontmost target, which caused Computer Use clicks to fail even after opening the intended app. On macOS, text entry was also split across inconsistent clipboard and keystroke paths, making Electron inputs unreliable for Chinese and short strings. This change adds a desktop approval bridge over the existing session websocket, renders a dedicated desktop approval modal, threads the real desktop bundle id into the Computer Use executor, and switches macOS clipboard typing onto the native pasteboard plus system paste shortcut path. It also makes tool error results expandable in the desktop chat UI so frontmost-gate failures are fully visible during debugging. Constraint: Desktop sessions run the CLI over the SDK websocket path, so Ink tool JSX dialogs are not visible there Constraint: macOS IME and Electron text inputs are unreliable with pyautogui.write and generic hotkey synthesis Rejected: Reuse CLI setToolJSX dialogs in desktop mode | no transport for mid-call Ink UI over the SDK bridge Rejected: Keep shell pbcopy/pbpaste for clipboard typing | inconsistent with NSPasteboard path and less reliable for Chinese text Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep desktop Computer Use approvals and macOS text-entry behavior on a single bridge/path; avoid reintroducing separate CLI-only and desktop-only codepaths for the same action Tested: python3 -m unittest runtime/test_helpers.py Tested: bun test src/utils/computerUse/permissions.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test ComputerUsePermissionModal chatStore Tested: cd desktop && bun run test chatBlocks Tested: cd desktop && bun run lint Not-tested: End-to-end manual Computer Use interaction against a live Electron target app on macOS
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { create } from 'zustand'
|
|
import { teamsApi } from '../api/teams'
|
|
import type { TeamSummary, TeamDetail, TeamMember, AgentColor } from '../types/team'
|
|
import { AGENT_COLORS } from '../types/team'
|
|
import type { TeamMemberStatus, UIMessage } from '../types/chat'
|
|
import { useChatStore, mapHistoryMessagesToUiMessages } from './chatStore'
|
|
import { useTabStore } from './tabStore'
|
|
|
|
const MEMBER_POLL_INTERVAL_MS = 1500
|
|
const MEMBER_TRANSCRIPT_MATCH_WINDOW_MS = 120_000
|
|
|
|
/** Generate a synthetic sessionId for team member tabs */
|
|
const memberSessionId = (agentId: string) => `team-member:${agentId}`
|
|
|
|
/** Module-level timer for polling member transcript */
|
|
let memberPollTimer: ReturnType<typeof setInterval> | null = null
|
|
let polledMemberSessionId: string | null = null
|
|
|
|
function createMemberSessionState() {
|
|
return {
|
|
messages: [] as UIMessage[],
|
|
chatState: 'idle' as const,
|
|
connectionState: 'connected' as const,
|
|
streamingText: '',
|
|
streamingToolInput: '',
|
|
activeToolUseId: null,
|
|
activeToolName: null,
|
|
activeThinkingId: null,
|
|
pendingPermission: null,
|
|
pendingComputerUsePermission: null,
|
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
|
elapsedSeconds: 0,
|
|
statusVerb: '',
|
|
slashCommands: [],
|
|
agentTaskNotifications: {},
|
|
elapsedTimer: null,
|
|
}
|
|
}
|
|
|
|
function normalizeMemberStatus(status: string | undefined): TeamMember['status'] {
|
|
if (status === 'running' || status === 'idle' || status === 'completed') {
|
|
return status
|
|
}
|
|
return status === 'failed' ? 'error' : 'idle'
|
|
}
|
|
|
|
function toTeamMember(raw: Record<string, unknown>): TeamMember {
|
|
return {
|
|
agentId: (raw.agentId as string) || '',
|
|
name: raw.name as string | undefined,
|
|
role:
|
|
(raw.name as string) ||
|
|
(raw.agentType as string) ||
|
|
(raw.role as string) ||
|
|
(raw.agentId as string) ||
|
|
'',
|
|
status: normalizeMemberStatus(raw.status as string | undefined),
|
|
currentTask: raw.currentTask as string | undefined,
|
|
color: raw.color as AgentColor | undefined,
|
|
sessionId: raw.sessionId as string | undefined,
|
|
}
|
|
}
|
|
|
|
function isPendingMemberMessage(message: UIMessage): message is Extract<UIMessage, { type: 'user_text' }> & { pending: true } {
|
|
return message.type === 'user_text' && message.pending === true
|
|
}
|
|
|
|
function transcriptAlreadyContainsMessage(
|
|
transcriptMessages: UIMessage[],
|
|
pendingMessage: Extract<UIMessage, { type: 'user_text' }> & { pending: true },
|
|
): boolean {
|
|
return transcriptMessages.some((message) => (
|
|
message.type === 'user_text' &&
|
|
message.pending !== true &&
|
|
message.content === pendingMessage.content &&
|
|
Math.abs(message.timestamp - pendingMessage.timestamp) <= MEMBER_TRANSCRIPT_MATCH_WINDOW_MS
|
|
))
|
|
}
|
|
|
|
function mergeMemberTranscriptMessages(
|
|
existingMessages: UIMessage[],
|
|
transcriptMessages: UIMessage[],
|
|
): UIMessage[] {
|
|
const pendingMessages = existingMessages.filter(isPendingMemberMessage).filter(
|
|
(message) => !transcriptAlreadyContainsMessage(transcriptMessages, message),
|
|
)
|
|
|
|
return pendingMessages.length > 0
|
|
? [...transcriptMessages, ...pendingMessages]
|
|
: transcriptMessages
|
|
}
|
|
|
|
function syncMemberSessionMessages(
|
|
sessionId: string,
|
|
memberStatus: TeamMember['status'],
|
|
messages: UIMessage[],
|
|
) {
|
|
const hasPendingMessages = messages.some(isPendingMemberMessage)
|
|
useChatStore.setState((state) => {
|
|
const existing = state.sessions[sessionId]
|
|
const nextState = existing ?? createMemberSessionState()
|
|
return {
|
|
sessions: {
|
|
...state.sessions,
|
|
[sessionId]: {
|
|
...nextState,
|
|
messages,
|
|
connectionState: 'connected',
|
|
chatState:
|
|
memberStatus === 'running' || hasPendingMessages
|
|
? 'thinking'
|
|
: 'idle',
|
|
},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
type TeamStore = {
|
|
teams: TeamSummary[]
|
|
activeTeam: TeamDetail | null
|
|
memberColors: Map<string, AgentColor>
|
|
error: string | null
|
|
|
|
fetchTeams: () => Promise<void>
|
|
fetchTeamDetail: (name: string) => Promise<void>
|
|
getMemberBySessionId: (sessionId: string) => TeamMember | null
|
|
refreshMemberSession: (sessionId: string) => Promise<void>
|
|
openMemberSession: (member: TeamMember) => void
|
|
sendMessageToMember: (sessionId: string, content: string) => Promise<void>
|
|
startMemberPolling: (sessionId: string, force?: boolean) => void
|
|
stopMemberPolling: () => void
|
|
clearTeam: () => void
|
|
|
|
// WebSocket handlers
|
|
handleTeamCreated: (teamName: string) => void
|
|
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => void
|
|
handleTeamDeleted: (teamName: string) => void
|
|
}
|
|
|
|
export const useTeamStore = create<TeamStore>((set, get) => ({
|
|
teams: [],
|
|
activeTeam: null,
|
|
memberColors: new Map(),
|
|
error: null,
|
|
|
|
fetchTeams: async () => {
|
|
set({ error: null })
|
|
try {
|
|
const { teams } = await teamsApi.list()
|
|
set({ teams })
|
|
} catch (err) {
|
|
set({ error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
},
|
|
|
|
fetchTeamDetail: async (name: string) => {
|
|
set({ error: null })
|
|
try {
|
|
const raw = await teamsApi.get(name) as Record<string, unknown>
|
|
const rawMembers = Array.isArray(raw.members) ? raw.members : []
|
|
const members: TeamMember[] = rawMembers.map((m: Record<string, unknown>) => toTeamMember(m))
|
|
const detail: TeamDetail = {
|
|
name: raw.name as string,
|
|
leadAgentId: raw.leadAgentId as string | undefined,
|
|
leadSessionId: raw.leadSessionId as string | undefined,
|
|
members,
|
|
createdAt: raw.createdAt != null ? String(raw.createdAt) : undefined,
|
|
}
|
|
// Assign colors to members
|
|
const colors = new Map<string, AgentColor>()
|
|
detail.members.forEach((m, i) => {
|
|
colors.set(m.agentId, AGENT_COLORS[i % AGENT_COLORS.length]!)
|
|
})
|
|
set({ activeTeam: detail, memberColors: colors })
|
|
} catch (err) {
|
|
set({ error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
},
|
|
|
|
getMemberBySessionId: (sessionId: string) => {
|
|
const team = get().activeTeam
|
|
if (!team) return null
|
|
return team.members.find(
|
|
(m) => m.sessionId === sessionId || memberSessionId(m.agentId) === sessionId,
|
|
) ?? null
|
|
},
|
|
|
|
refreshMemberSession: async (sessionId) => {
|
|
const team = get().activeTeam
|
|
const member = get().getMemberBySessionId(sessionId)
|
|
if (!team || !member) return
|
|
|
|
try {
|
|
const { messages } = await teamsApi.getMemberTranscript(team.name, member.agentId)
|
|
const asEntries = messages.map((msg) => ({
|
|
id: msg.id,
|
|
type: msg.type,
|
|
content: msg.content,
|
|
timestamp: msg.timestamp,
|
|
model: msg.model,
|
|
parentToolUseId: msg.parentToolUseId,
|
|
}))
|
|
const transcriptMessages = mapHistoryMessagesToUiMessages(
|
|
asEntries as Parameters<typeof mapHistoryMessagesToUiMessages>[0],
|
|
{ includeTeammateMessages: true },
|
|
)
|
|
const existingMessages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
|
const mergedMessages = mergeMemberTranscriptMessages(
|
|
existingMessages,
|
|
transcriptMessages,
|
|
)
|
|
syncMemberSessionMessages(sessionId, member.status, mergedMessages)
|
|
} catch {
|
|
const existingMessages = useChatStore.getState().sessions[sessionId]?.messages ?? []
|
|
syncMemberSessionMessages(sessionId, member.status, existingMessages)
|
|
}
|
|
},
|
|
|
|
openMemberSession: (member: TeamMember) => {
|
|
const team = get().activeTeam
|
|
if (!team) return
|
|
|
|
get().stopMemberPolling()
|
|
|
|
const tabId = memberSessionId(member.agentId)
|
|
useTabStore.getState().openTab(tabId, member.role, 'session')
|
|
void get().refreshMemberSession(tabId)
|
|
get().startMemberPolling(tabId)
|
|
},
|
|
|
|
sendMessageToMember: async (sessionId, content) => {
|
|
const team = get().activeTeam
|
|
const member = get().getMemberBySessionId(sessionId)
|
|
if (!team || !member) {
|
|
throw new Error('Team member session is no longer available')
|
|
}
|
|
|
|
await teamsApi.sendMemberMessage(team.name, member.agentId, content)
|
|
get().startMemberPolling(sessionId, true)
|
|
await get().refreshMemberSession(sessionId)
|
|
},
|
|
|
|
startMemberPolling: (sessionId, force = false) => {
|
|
const member = get().getMemberBySessionId(sessionId)
|
|
if (!member) return
|
|
|
|
const hasPendingMessages =
|
|
useChatStore.getState().sessions[sessionId]?.messages.some(isPendingMemberMessage) ?? false
|
|
|
|
if (!force && polledMemberSessionId === sessionId && memberPollTimer) {
|
|
return
|
|
}
|
|
|
|
if (member.status !== 'running' && !hasPendingMessages) {
|
|
get().stopMemberPolling()
|
|
return
|
|
}
|
|
|
|
get().stopMemberPolling()
|
|
polledMemberSessionId = sessionId
|
|
memberPollTimer = setInterval(() => {
|
|
const currentTabId = useTabStore.getState().activeTabId
|
|
if (currentTabId !== sessionId) {
|
|
get().stopMemberPolling()
|
|
return
|
|
}
|
|
void get().refreshMemberSession(sessionId)
|
|
}, MEMBER_POLL_INTERVAL_MS)
|
|
},
|
|
|
|
stopMemberPolling: () => {
|
|
if (memberPollTimer) {
|
|
clearInterval(memberPollTimer)
|
|
memberPollTimer = null
|
|
}
|
|
polledMemberSessionId = null
|
|
},
|
|
|
|
clearTeam: () => {
|
|
get().stopMemberPolling()
|
|
set({ activeTeam: null, memberColors: new Map() })
|
|
},
|
|
|
|
handleTeamCreated: (teamName: string) => {
|
|
set((s) => ({
|
|
teams: [...s.teams, { name: teamName, memberCount: 0 }],
|
|
}))
|
|
get().fetchTeamDetail(teamName)
|
|
setTimeout(() => get().fetchTeamDetail(teamName), 1500)
|
|
setTimeout(() => get().fetchTeamDetail(teamName), 4000)
|
|
setTimeout(() => get().fetchTeamDetail(teamName), 8000)
|
|
},
|
|
|
|
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => {
|
|
const team = get().activeTeam
|
|
if (team && team.name === teamName) {
|
|
if (members.length === 0) return
|
|
|
|
if (members.length > team.members.length) {
|
|
get().fetchTeamDetail(teamName)
|
|
}
|
|
|
|
const colors = get().memberColors
|
|
const existingMap = new Map(team.members.map((m) => [m.agentId, m]))
|
|
const incomingIds = new Set(members.map((m) => m.agentId))
|
|
const kept = team.members.filter((m) => !incomingIds.has(m.agentId))
|
|
const updatedMembers: TeamMember[] = [
|
|
...kept,
|
|
...members.map((m, i) => {
|
|
const existing = existingMap.get(m.agentId)
|
|
return {
|
|
...(existing ?? {}),
|
|
name: existing?.name,
|
|
agentId: m.agentId,
|
|
role: m.role,
|
|
status: normalizeMemberStatus(m.status),
|
|
currentTask: m.currentTask,
|
|
color: colors.get(m.agentId) ?? AGENT_COLORS[i % AGENT_COLORS.length]!,
|
|
sessionId: existing?.sessionId,
|
|
}
|
|
}),
|
|
]
|
|
set({ activeTeam: { ...team, members: updatedMembers } })
|
|
|
|
const currentTabId = useTabStore.getState().activeTabId
|
|
if (currentTabId) {
|
|
const viewedMember = get().getMemberBySessionId(currentTabId)
|
|
if (viewedMember) {
|
|
void get().refreshMemberSession(currentTabId)
|
|
get().startMemberPolling(currentTabId)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
handleTeamDeleted: (teamName: string) => {
|
|
get().stopMemberPolling()
|
|
set((s) => ({
|
|
teams: s.teams.filter((t) => t.name !== teamName),
|
|
activeTeam: s.activeTeam?.name === teamName ? null : s.activeTeam,
|
|
}))
|
|
},
|
|
}))
|