From fcca3649bf01d24340c5e417951ac043dc65af47 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 13:19:56 +0300 Subject: [PATCH 01/83] feat: add member log stream v2 --- ...ember-log-stream-v2-implementation-plan.md | 1231 ++++++++++ .../member-log-stream-v2-research-addendum.md | 2046 +++++++++++++++++ .../member-log-stream/contracts/api.ts | 10 + .../member-log-stream/contracts/channels.ts | 2 + .../member-log-stream/contracts/dto.ts | 72 + .../member-log-stream/contracts/index.ts | 4 + .../member-log-stream/contracts/normalize.ts | 44 + .../core/application/ports/ClockPort.ts | 3 + .../core/application/ports/LoggerPort.ts | 5 + .../ports/MemberLogStreamSource.ts | 40 + .../ports/MemberLogStreamTrackingPort.ts | 3 + .../use-cases/GetMemberLogStreamUseCase.ts | 144 ++ .../SetMemberLogStreamTrackingUseCase.ts | 9 + .../GetMemberLogStreamUseCase.test.ts | 145 ++ .../domain/models/MemberLogStreamBudget.ts | 35 + .../memberLogStreamMergePolicy.test.ts | 111 + .../policies/memberLogStreamMergePolicy.ts | 147 ++ .../registerMemberLogStreamIpc.test.ts | 195 ++ .../input/ipc/registerMemberLogStreamIpc.ts | 183 ++ .../ClaudeMemberTranscriptStreamSource.ts | 214 ++ .../CodexNativeMemberTraceStreamSource.ts | 41 + .../OpenCodeMemberRuntimeStreamSource.ts | 230 ++ .../__tests__/memberLogSources.test.ts | 272 +++ .../sources/memberLogStreamSourceUtils.ts | 75 + .../createMemberLogStreamFeature.ts | 75 + .../member-log-stream/main/featureGates.ts | 18 + src/features/member-log-stream/main/index.ts | 8 + .../__tests__/memberLogMessageBudget.test.ts | 98 + .../infrastructure/memberLogMessageBudget.ts | 255 ++ .../createMemberLogStreamBridge.test.ts | 82 + .../preload/createMemberLogStreamBridge.ts | 42 + .../member-log-stream/preload/index.ts | 1 + .../adapters/MemberLogStreamSection.tsx | 78 + .../__tests__/useMemberLogStream.test.tsx | 326 +++ .../renderer/hooks/useMemberLogStream.ts | 197 ++ .../member-log-stream/renderer/index.ts | 7 + .../renderer/ui/ExecutionLogStreamView.tsx | 369 +++ .../renderer/utils/featureGates.ts | 18 + src/main/index.ts | 15 + .../runtime/ClaudeMultimodelBridgeService.ts | 7 +- .../services/team/TeamLogSourceTracker.ts | 1 + .../services/team/TeamMemberLogsFinder.ts | 43 +- .../stream/OpenCodeRuntimeProjectionMapper.ts | 126 + .../stream/OpenCodeTaskLogStreamSource.ts | 126 +- src/preload/index.ts | 2 + src/renderer/api/httpClient.ts | 13 + .../team/members/MemberDetailDialog.tsx | 28 +- .../team/taskLogs/TaskLogStreamSection.tsx | 386 +--- src/shared/types/api.ts | 4 + src/shared/types/team.ts | 14 +- .../ClaudeMultimodelBridgeService.test.ts | 63 +- .../team/TeamMemberLogsFinder.test.ts | 193 +- .../team/members/MemberDetailDialog.test.ts | 121 +- ...emberLogStreamSection.fixture-e2e.test.tsx | 378 +++ 54 files changed, 7827 insertions(+), 528 deletions(-) create mode 100644 docs/team-management/member-log-stream-v2-implementation-plan.md create mode 100644 docs/team-management/member-log-stream-v2-research-addendum.md create mode 100644 src/features/member-log-stream/contracts/api.ts create mode 100644 src/features/member-log-stream/contracts/channels.ts create mode 100644 src/features/member-log-stream/contracts/dto.ts create mode 100644 src/features/member-log-stream/contracts/index.ts create mode 100644 src/features/member-log-stream/contracts/normalize.ts create mode 100644 src/features/member-log-stream/core/application/ports/ClockPort.ts create mode 100644 src/features/member-log-stream/core/application/ports/LoggerPort.ts create mode 100644 src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts create mode 100644 src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts create mode 100644 src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts create mode 100644 src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts create mode 100644 src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts create mode 100644 src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts create mode 100644 src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts create mode 100644 src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts create mode 100644 src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts create mode 100644 src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts create mode 100644 src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts create mode 100644 src/features/member-log-stream/main/featureGates.ts create mode 100644 src/features/member-log-stream/main/index.ts create mode 100644 src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts create mode 100644 src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts create mode 100644 src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts create mode 100644 src/features/member-log-stream/preload/createMemberLogStreamBridge.ts create mode 100644 src/features/member-log-stream/preload/index.ts create mode 100644 src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx create mode 100644 src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx create mode 100644 src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts create mode 100644 src/features/member-log-stream/renderer/index.ts create mode 100644 src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx create mode 100644 src/features/member-log-stream/renderer/utils/featureGates.ts create mode 100644 src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts create mode 100644 test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx diff --git a/docs/team-management/member-log-stream-v2-implementation-plan.md b/docs/team-management/member-log-stream-v2-implementation-plan.md new file mode 100644 index 00000000..ef9b460f --- /dev/null +++ b/docs/team-management/member-log-stream-v2-implementation-plan.md @@ -0,0 +1,1231 @@ +# Member Log Stream V2 Implementation Plan + +## Goal + +Сделать в попапе участника новый формат логов, визуально и поведенчески близкий к `Task Log Stream`, но со scope "все доступные логи выбранного участника", а не "логи конкретной задачи". + +Ключевой продуктовый ответ: + +- Показываем логи только выбранного участника, а не всех агентов команды. +- Если у выбранного участника есть безопасно доступные Claude/OpenCode/Codex источники, они могут быть показаны в одном stream. +- Вариант 2 не обещает абсолютно полный provider-wide audit log для всех runtime. Для этого нужен вариант 3. +- Старый `MemberLogsTab` не удаляем на первом шаге. Он остается fallback, чтобы снизить риск регрессий. + +## Decision + +Выбран вариант 2: + +🎯 8.5 🛡️ 8.5 🧠 6 +Оценка изменений: примерно 1500-2300 строк вместе с тестами, если делать canonical feature slice, аккуратную source-port архитектуру, dedupe cumulative subagent snapshots, вынести OpenCode projection mapper, покрыть fallback boundary, добавить member tracking activation, OpenCode/renderer in-flight protection и аккуратно отделить provider-neutral message hygiene от board/task sanitization. + +Почему не frontend-only: + +- Старый `MemberLogsTab` работает с session summaries и раскрытием `MemberExecutionLog`. +- Новый stream требует готовый normalized response: participants, segments, chunks, source metadata. +- Делать это в renderer означало бы тащить parsing/composition туда, где уже нет нужных main-process primitives и где выше риск UI freeze. + +Почему не вариант 3: + +- Полный member-wide Codex native stream требует отдельного trace index/read path по owner/member без taskId. +- Полный OpenCode multi-lane stream требует либо безопасного list-all-lanes contract, либо явного lane resolution в desktop. +- Это полезно, но заметно повышает риск смешать чужие логи или показать неполную картину как полную. + +## Options Considered + +| Вариант | Оценка | Пример LOC | Что делает | Главный риск | +| --- | --- | ---: | --- | --- | +| 1. Frontend-only reuse старых member logs | 🎯 6 🛡️ 5 🧠 3 | 150-250 | Renderer берет `getMemberLogs` и пытается отрисовать похоже на stream | Старый формат данных не равен stream, легко получить UI-псевдопаритет без реального качества | +| 2. Canonical feature slice плюс source ports | 🎯 8.5 🛡️ 9 🧠 7 | 1500-2300 | `src/features/member-log-stream` owns contracts/core/source ports/renderer, app shell only integrates it | Нужно держать provider sources маленькими, dedupe cumulative logs, жестко ограничить response и не мигрировать task logs в этом PR | +| 3. Maximum provider/runtime log stream | 🎯 6 🛡️ 6 🧠 9 | 1000-1800+ | Отдельный provider-wide member log index для Claude/OpenCode/Codex | Высокий риск ошибок в Codex/OpenCode attribution и долгий rollout | + +## Current Code Facts + +Точки интеграции в `claude_team`: + +- `src/renderer/components/team/members/MemberDetailDialog.tsx` сейчас показывает ``. +- `src/renderer/components/team/members/MemberLogsTab.tsx` вызывает `api.teams.getMemberLogs(...)` и рендерит старые session logs. +- `src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx` вызывает `api.teams.getTaskLogStream(teamName, taskId)` и рендерит новый stream через `MemberExecutionLog`. +- `TaskLogStreamSection` only subscribes to `onTeamChange`; `TaskLogsPanel` separately enables `setTaskLogStreamTracking`. Member popup needs its own tracking activation. +- `TeamLogSourceTracker` tracks activity only while at least one consumer is active. Its consumer counts are per team and per consumer name, so member stream should add a semantic `member_log_stream` consumer instead of starting a separate watcher layer. +- `TaskLogStreamSection.normalizeResponse()` preserves only known task response fields, so a reused copy can drop member fields like `coverage`, `warnings`, `metadata`, `truncated`, `generatedAt` and `segment.source`. +- `BoardTaskLogParticipant` is actor identity, not source identity. Provider/session/lane labels for member logs must stay in `MemberLogStreamSegment.source`, not in fake provider-specific participants. +- `src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts` возвращает `BoardTaskLogStreamResponse`. +- `src/shared/types/team.ts` уже содержит `BoardTaskLogParticipant`, `BoardTaskLogSegment`, `BoardTaskLogStreamResponse`. +- `src/shared/types/team.ts` уже содержит `TeamMemberSnapshot.providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`, но `ResolvedTeamMember` пока не объявляет эти поля. +- `src/renderer/store/slices/teamSlice.ts` в `buildResolvedMember()` возвращает `{ ...snapshot, status, messageCount, lastActiveAt }`, поэтому runtime object уже несет эти поля. Это почти type-contract change, а не новая data mapping ветка. +- `src/main/services/team/TeamMemberLogsFinder.ts` уже умеет искать member logs и содержит attribution precedence. +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts` умеет strict-parse JSONL transcript files с cache и concurrency. +- `BoardTaskExactLogStrictParser.parseFiles()` вызывает `cache.retainOnly(uniquePaths)`, а underlying `BoardTaskActivityParseCache.retainOnly()` удаляет не только parsed cache, но и `inFlight` entries вне текущего набора файлов. Поэтому parser instance нельзя шарить между task stream и member stream без изменения cache ownership. +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts` строит `EnhancedChunk[]`, которые уже понимает `MemberExecutionLog`. +- `src/main/services/runtime/ClaudeMultimodelBridgeService.ts` уже имеет `getOpenCodeTranscript(...)`, но пока без `laneId`. +- `CodexNativeTraceReader.readTaskRuns()` is task-keyed and scans `processed//` plus optional `incoming//`, then caps recent candidates before parsing. It has no member-wide owner index. +- `CodexNativeTraceProjector` only projects native tool events (`toolSource === 'native'`), not full Codex chat/runtime history. +- `src/main/ipc/teams.ts`, `src/preload/index.ts`, `src/preload/constants/ipcChannels.ts` уже имеют аналогичные handlers/channels для `getTaskLogStream`. +- `initializeTeamHandlers()` сейчас принимает много optional dependencies позиционно, и `test/main/ipc/teams.test.ts` передает их в этом порядке. Новую feature нельзя встраивать туда как очередной owned team service. +- `src/renderer/api/httpClient.ts` имеет browser-mode stub для `getTaskLogStream`; для member stream нужен такой же safe fallback, иначе можно сломать browser compile/test path. +- `findRecentMemberLogFileRefsByMember()` сейчас принимает третий аргумент только как numeric/null `mtimeSinceMs`, не object options; не передает `forceRefresh`; добавляет lead transcript до mtime filter; и возвращает refs без `kind`/`sizeBytes`/`messageCount`. +- В OpenCode сейчас есть два mapper-like `toParsedMessage()`: richer private mapper в `OpenCodeTaskLogStreamSource` и более lossy mapper в `OpenCodeTaskStallEvidenceSource`. Канонический источник для shared mapper - task stream source, не stall monitor. + +Дополнительный deep research зафиксирован в: + +- `docs/team-management/member-log-stream-v2-research-addendum.md`. + +Самые важные уточнения оттуда: + +- для Claude/member transcript использовать `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()`, не `findMemberLogPaths()`; +- для OpenCode передавать `laneId` из member snapshot в `getMemberLogStream` options; +- `ResolvedTeamMember` type нужно явно расширить runtime/lane fields: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`; +- Codex native в варианте 2 должен быть skipped или partial native-tool trace, но не "полный Codex log"; +- member stream source stack должен иметь отдельный `BoardTaskExactLogStrictParser`, потому что `parseFiles().retainOnly()` вычищает cache и `inFlight` parse entries вне текущего набора файлов. +- архитектурно лучше выбрать canonical feature slice: `src/features/member-log-stream` owns contracts/core/application/source ports/renderer, while existing team surfaces remain thin app-shell integration points; +- response нужно ограничивать на backend: `maxTranscriptFiles=40`, `maxSegments=30`, `maxChunks=250`, `maxSourceMessages=1200`, `maxMessagesPerSegment=300`, `openCodeMessageLimit=400`; +- `laneId` нельзя валидировать как member name, нужен отдельный optional validator с поддержкой `secondary:opencode:`. +- `findRecentMemberLogFileRefsByMember()` dedupe только by `filePath`, а cumulative subagent snapshots могут дублировать turn history; +- лучше расширить `MemberLogFileRef` optional `kind`/`sizeBytes` и dedupe subagent refs by `memberName + sessionId` до parse; +- OpenCode projection conversion сейчас private внутри task-specific source, поэтому нужен маленький shared mapper вместо копипаста. +- shared OpenCode mapper нужно извлекать из `OpenCodeTaskLogStreamSource`; mapper в stall monitor не подходит как источник, потому что он теряет часть content/tool-result семантики. +- `maxChunks` недостаточен как единственный budget, потому что один AI chunk может содержать сотни tool calls/messages; +- member popup live refresh должен слушать same-team `log-source-change` и `task-log-change`, но не `tool-activity`. +- `setMemberLogStreamTracking()` should map to a new `TeamLogSourceTracker` consumer `member_log_stream`. Reusing the task-named API would work technically but creates semantic coupling. +- Codex native member stream remains skipped in first PR because current trace reader is task-first and current projector is native-tool-only. +- новый IPC handler нужно регистрировать feature-owned способом и не сдвигать existing positional dependencies в `initializeTeamHandlers()`; +- browser-mode API fallback должен возвращать полный empty `MemberLogStreamResponse`, а не бросать ошибку. + +Точки интеграции в orchestrator: + +- `runtime transcript` уже принимает `--team`, `--member`, `--lane`, `--projection-only`, `--limit`. +- `resolveOpenCodeSessionRecordForCli()` специально требует `--lane`, если есть несколько session records для одного `team/member`. +- Поэтому orchestrator менять кодом не нужно для варианта 2, если desktop начнет передавать `laneId` там, где он надежно известен. + +## Target User Experience + +В попапе участника раздел `Logs` должен выглядеть как новый stream: + +- header остается понятным для member popup: `Logs`, не `Task Log Stream`; +- визуальный формат chunks, tool calls, message blocks и spacing должен быть как в `Task Log Stream`; +- описание source не должно говорить "Task-scoped"; +- при пустом stream показываем спокойный empty state; +- при ошибке нового stream показываем fallback-кнопку или inline fallback на старый `MemberLogsTab`; +- для одного участника chips обычно не нужны; +- если у участника несколько sessions/lanes, segment headers должны оставаться видимыми, чтобы не превратить разные сессии в один непрозрачный блок. + +⚠️ Важное поведение: stream не должен показывать "логи всех агентов вместе". Он показывает только выбранного member. Если внутри выбранного member есть разные provider/runtime источники, они могут быть объединены только после безопасной attribution/resolution. + +## Target Architecture + +```mermaid +flowchart LR + A["MemberDetailDialog"] --> B["MemberLogStreamSection"] + B --> C["member-log-stream renderer hook"] + C --> D["feature preload/API bridge"] + D --> E["feature IPC getMemberLogStream"] + E --> S["GetMemberLogStreamUseCase"] + S --> F["MemberLogStreamBudget"] + S --> G["ClaudeMemberTranscriptStreamSource"] + S --> H["OpenCodeMemberRuntimeStreamSource"] + S --> I["CodexNativeMemberTraceStreamSource"] + G --> J["TeamMemberLogsFinder recent refs"] + G --> K["Ref dedupe by member/session/kind"] + G --> L["Dedicated BoardTaskExactLogStrictParser"] + G --> M["BoardTaskExactLogChunkBuilder"] + H --> N["ClaudeMultimodelBridgeService"] + H --> O["OpenCodeRuntimeProjectionMapper"] + N --> P["agent_teams_orchestrator runtime transcript --lane"] + I --> R["skipped coverage in first PR"] + S --> Q["MemberLogStreamResponse"] + Q --> B +``` + +Main-process feature adapters own source discovery, parsing, chunking, cache, provider fallback, response budget and truncation semantics. Renderer feature code owns loading state, visual rendering, source text and fallback UI. + +🧭 Architecture decision: create a canonical `src/features/member-log-stream` slice in the first implementation. This feature spans process boundaries, owns merge/budget/provider policy, needs transport wiring and has a provider roadmap, so it matches `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +Compatibility rule: + +- do not migrate existing task log stream or legacy `MemberLogsTab` in this PR; +- app shell may keep small compatibility methods in `api.teams` if that is the least disruptive integration point, but those methods should delegate to feature-owned contracts/channels/use case; +- outside callers import only feature public entrypoints: `@features/member-log-stream/contracts`, `@features/member-log-stream/main`, `@features/member-log-stream/preload`, `@features/member-log-stream/renderer`; +- provider logic must live behind source ports, not in IPC handlers or React components. + +Expected feature layout: + +```text +src/features/member-log-stream/ + contracts/ + api.ts + channels.ts + dto.ts + index.ts + normalize.ts + core/ + domain/ + policies/ + models/ + application/ + ports/ + use-cases/ + main/ + composition/ + adapters/ + input/ipc/ + output/sources/ + output/presenters/ + infrastructure/ + preload/ + renderer/ + adapters/ + hooks/ + ui/ + utils/ +``` + +## Architecture Compliance Checklist + +This feature must follow `docs/FEATURE_ARCHITECTURE_STANDARD.md` exactly enough to be reviewable by structure, imports and tests. + +Clean Architecture mapping: + +- `contracts/`: `MemberLogStreamResponse`, request options, API fragment, IPC channels and normalize helpers only. +- `core/domain/`: pure policies for merge order, dedupe keys, source coverage, truncation decisions and render-safe source metadata. +- `core/application/`: `GetMemberLogStreamUseCase`, `SetMemberLogStreamTrackingUseCase`, source ports, cache/clock/logger ports and budget models. +- `main/adapters/input/ipc/`: validate IPC input, reject unknown option keys, call use cases and normalize IPC output. +- `main/adapters/output/sources/`: Claude/OpenCode/Codex source adapters that implement `MemberLogStreamSource`. +- `main/infrastructure/`: TTL/in-flight cache helpers, parser wrappers, bridge/runtime clients and filesystem-specific helpers. +- `main/composition/`: `createMemberLogStreamFeature(...)` wires dependencies and exposes a small facade. +- `preload/`: `createMemberLogStreamBridge(...)` only invokes feature channels and depends on contracts. +- `renderer/hooks/`: loading, tracking, team-change subscription, reload coalescing and fallback state. +- `renderer/adapters/`: DTO-to-view mapping and small renderer-only normalization. +- `renderer/ui/`: presentational components only, no store/API/Electron access. + +Dependency direction: + +- core/domain imports no process, framework, adapter or infrastructure code. +- core/application depends on domain models and ports, not on main/preload/renderer. +- main adapters depend inward on core/application and contracts. +- renderer UI depends on contracts/view models and local props only. +- app shell imports only public feature entrypoints. +- `src/main/ipc/teams.ts`, `src/preload/index.ts` and `src/renderer/components/team/members/MemberDetailDialog.tsx` may integrate the feature, but must not own member stream policy. + +SOLID guardrails: + +- SRP: one class/module has one reason to change. Provider IO changes source adapters; merge/budget changes core policies/use case; transport changes IPC/preload; visual changes renderer UI. +- OCP: adding `CodexPartialMemberTraceSource` later means adding a new source adapter and registering it, not changing renderer or the use case switch logic. +- LSP: every `MemberLogStreamSource` must return the same result contract with `included`, `partial` or `skipped`; no adapter should throw for expected provider absence. +- ISP: keep narrow ports: source loading, tracking activation, clock/logger/cache. Do not pass `TeamDataService` or renderer member objects through core. +- DIP: use cases depend on source/cache/logger ports. Concrete `TeamMemberLogsFinder`, `ClaudeMultimodelBridgeService`, parsers and trace readers stay in main adapters/infrastructure. + +DRY guardrails: + +- Reuse the stream renderer through a public `@features/member-log-stream/renderer` entrypoint instead of copying `TaskLogStreamSection`. +- Extract the OpenCode projection mapper from `OpenCodeTaskLogStreamSource` once and reuse it for task/member streams. +- Extract provider-neutral message hygiene helpers once, but keep board/task JSON cleanup scoped to task logs. +- Do not duplicate DTOs in `src/shared/types/team.ts` and feature contracts. Feature contracts are the owner. +- Do not duplicate feature API methods across `api.teams` and feature bridge unless the `api.teams` method is a thin compatibility delegate. + +Lint guardrails: + +- The repo already has generic feature boundary rules in `eslint.config.js` for `src/features/*`. +- If implementation needs stricter member-log-stream-specific messages, mirror the `recent-projects` feature-specific guard rails. +- Targeted verification should include `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content` plus full `pnpm lint` before merge. + +### Project Standard Traceability Matrix + +This matrix is the implementation checklist for `CLAUDE.md`, `docs/FEATURE_ARCHITECTURE_STANDARD.md`, existing `eslint.config.js` feature rules and the `src/features/recent-projects` reference slice. + +| Standard requirement | Member-log-stream implementation rule | Verification | +| --- | --- | --- | +| New medium/large cross-process features live in `src/features/` | Create `src/features/member-log-stream` and keep legacy team files as integration points only | `rg "@features/member-log-stream" src/main src/preload src/renderer` shows public entrypoint imports only | +| `contracts/` owns cross-process API | DTOs, API fragment types, channel constants and normalize helpers live only in feature contracts | No duplicated member stream DTOs in `src/shared/types/team.ts`; contracts tests cover fallback shape | +| `core/domain/` is pure | Merge order, dedupe keys, coverage state, budget/truncation policy and source metadata are pure functions/models | Domain tests plus `feature-core-domain-guards` | +| `core/application/` owns use cases and ports | `GetMemberLogStreamUseCase`, `SetMemberLogStreamTrackingUseCase`, source/cache/clock/logger/tracking ports | Use-case tests with fake ports; no Electron, Fastify, React, Zustand or child process imports | +| `main/adapters/input/` owns transport translation | IPC adapter validates unknown option keys, exact optional `laneId`, response normalization and fail-soft errors | Feature IPC tests register/remove feature channels without `initializeTeamHandlers()` ownership | +| `main/adapters/output/` owns source adapters | Claude/OpenCode/Codex adapters implement `MemberLogStreamSource` and translate runtime data to core models | Adapter mapping tests for Claude refs, OpenCode projection and Codex skipped coverage | +| `main/infrastructure/` owns concrete IO | Parser wrappers, runtime bridge clients, TTL/in-flight cache and filesystem helpers stay outside core | Source adapters are thin around infrastructure helpers | +| `main/composition/` is the composition root | `createMemberLogStreamFeature(...)` wires sources, use cases, cache, logger and tracker facade | App main imports `@features/member-log-stream/main`, not feature internals | +| `preload/` is a thin bridge | `createMemberLogStreamBridge()` invokes feature channels and depends on contracts | Preload does not import main composition or renderer code | +| `renderer/hooks/` orchestrate interaction | Loading, tracking activation, reload coalescing, fallback and team-change cleanup live in hooks | Hook tests cover mount/unmount, team changes, reload coalescing and browser fallback | +| `renderer/ui/` is presentational | `ExecutionLogStreamView` and section UI receive props/view models only | `feature-renderer-ui-guards`; no imports from `@renderer/api`, store, Electron or main | +| Public entrypoints only | External code imports only `@features/member-log-stream/contracts`, `/main`, `/preload`, `/renderer` | `feature-public-entrypoints-only` and targeted eslint | +| Reference feature shape matches `recent-projects` | Public indexes mirror the reference pattern: contracts/main/preload/renderer expose only supported surface | Review tree against `src/features/recent-projects` before implementation PR | + +## Public API And Types + +Add feature-owned contracts in `src/features/member-log-stream/contracts`, not by mutating task stream semantics: + +```ts +export type MemberLogStreamProvider = 'claude_transcript' | 'opencode_runtime' | 'codex_native_trace'; +export type MemberLogStreamSource = + | 'member_transcript' + | 'member_mixed_runtime' + | 'member_runtime_only' + | 'member_empty'; + +export interface MemberLogStreamCoverage { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + reason?: string; +} + +export interface MemberLogStreamWarning { + code: + | 'opencode_ambiguous_lane' + | 'opencode_missing_runtime_session' + | 'opencode_runtime_unavailable' + | 'opencode_runtime_timeout' + | 'codex_member_wide_not_supported' + | 'large_log_window_limited' + | 'segment_message_window_limited' + | 'message_content_limited' + | 'unreadable_transcript_file'; + message: string; +} + +export interface MemberLogStreamMetadata { + scannedTranscriptFileCount: number; + includedTranscriptFileCount: number; + droppedSegmentCount: number; + droppedChunkCount: number; + droppedMessageCount: number; +} + +export interface MemberLogStreamSegmentSource { + provider: MemberLogStreamProvider; + label: string; + sessionId?: string; + laneId?: string; + messageCount?: number; + truncated?: boolean; +} + +export interface MemberLogStreamSegment extends BoardTaskLogSegment { + source: MemberLogStreamSegmentSource; +} + +export interface MemberLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: 'all' | string; + segments: MemberLogStreamSegment[]; + source: MemberLogStreamSource; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + generatedAt: string; + metadata: MemberLogStreamMetadata; +} +``` + +Important TS boundary: + +- Do not make `MemberLogStreamResponse extends BoardTaskLogStreamResponse`. +- `BoardTaskLogStreamResponse.source` is a task-only union: `transcript`, OpenCode task fallback values and Codex task trace values. +- Reusing that response type would either reject member-specific source values or pollute task stream semantics with member values. +- Share the low-level render units instead: `BoardTaskLogParticipant`, `BoardTaskLogSegment` and `EnhancedChunk`. +- Prefer feature contracts for `MemberLogStreamResponse`; export them through `@features/member-log-stream/contracts`. +- If `src/shared/types/api.ts` needs a temporary compatibility reference for `api.teams`, import the feature contract type there or use a narrow adapter type. Do not duplicate DTO definitions in `src/shared/types/team.ts` and feature contracts. + +Internal budget: + +```ts +interface MemberLogStreamBudget { + maxTranscriptFiles: number; + maxSegments: number; + maxChunks: number; + maxSourceMessages: number; + maxMessagesPerSegment: number; + maxTotalContentChars: number; + maxMessageContentChars: number; + maxToolResultContentChars: number; + openCodeMessageLimit: number; + openCodeTimeoutMs: number; +} +``` + +Extend finder metadata in a backward-compatible way: + +```ts +interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + sizeBytes?: number; + messageCount?: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; +} +``` + +This is needed because cumulative subagent snapshots can duplicate history. Existing callers can ignore the optional fields. + +Also adjust `findRecentMemberLogFileRefsByMember()` to include requested member names in the attribution `knownMembers` set. Syntax-only IPC validation is not enough for removed or historical members if they are no longer present in current config/meta/inbox. + +Do this without breaking existing finder callers. `TeamMemberRuntimeAdvisoryService` and existing tests already pass the third argument as positional `mtimeSinceMs`, including numeric values and `null`. + +```ts +type FindRecentMemberLogFileRefsOptions = + | number + | null + | { + mtimeSinceMs?: number | null; + forceRefresh?: boolean; + }; +``` + +Rules: + +- numeric third arg still means `mtimeSinceMs`; +- `null` still means no mtime window; +- object form is added for member stream and can pass `forceRefresh`; +- only object form should bypass `discoverProjectSessions()` cache; +- `mtimeSinceMs` must apply to lead transcript refs as well as member/subagent candidates; +- do not migrate advisory callers in the same PR unless needed by tests. + +Recommended default: + +```ts +const DEFAULT_MEMBER_LOG_STREAM_BUDGET = { + maxTranscriptFiles: 40, + maxSegments: 30, + maxChunks: 250, + maxSourceMessages: 1200, + maxMessagesPerSegment: 300, + maxTotalContentChars: 800_000, + maxMessageContentChars: 80_000, + maxToolResultContentChars: 120_000, + openCodeMessageLimit: 400, + openCodeTimeoutMs: 5_000, +}; +``` + +Add IPC/preload API: + +```ts +getMemberLogStream( + teamName: string, + memberName: string, + options?: { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; + } +): Promise +``` + +Add member stream tracking API: + +```ts +setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise +``` + +Why this is separate from `setTaskLogStreamTracking()`: + +- `TaskLogStreamSection` listens to `onTeamChange`, but `TaskLogsPanel` is what enables `TeamLogSourceTracker`. +- Member popup has no `TaskLogsPanel` wrapper. +- Reusing the task-named method from member UI would work mechanically, but it creates semantic drift. +- A dedicated member tracking API can map to the same underlying watcher with a separate `member_log_stream` consumer count. + +Default behavior: + +- `limitSegments` defaults to 30 and is clamped to `1..80`. +- `since` is optional and only used as a performance hint, but invalid dates should be rejected by IPC validation. +- First renderer implementation should not pass `since` for background reload replacement. Without an explicit incremental-merge contract, replacing the visible stream with a since-filtered partial response can hide older segments. +- `laneId` is optional and used only for safe OpenCode runtime projection. +- `forceRefresh` is optional and should be used only by renderer background reloads after `log-source-change`. +- Handler validates `teamName` and `memberName`. +- Handler validates `laneId` separately from `memberName`, because valid runtime lanes can contain `:`. +- Handler must trim but otherwise preserve `laneId`; do not lowercase or rewrite it. +- Handler validates `forceRefresh` as boolean when present. +- Put new validators in `src/main/ipc/guards.ts` as exported functions, or define local validator result types. `ValidationResult` is currently not exported from `guards.ts`. +- Handler should not reject deleted/removed members just because they are absent from current config. Old logs are still useful. +- Handler rejects unknown option keys, matching the stricter `TEAM_GET_DATA` options policy. +- Tracking handler validates `teamName` and boolean `enabled`. + +Suggested lane validator: + +```ts +export function validateOptionalRuntimeLaneId(value: unknown) { + if (value == null) return { valid: true, value: undefined }; + if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' }; + const trimmed = value.trim(); + if (!trimmed) return { valid: true, value: undefined }; + if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' }; + if (/[\0-\x1F\x7F/\\]/.test(trimmed)) { + return { valid: false, error: 'laneId contains invalid characters' }; + } + return { valid: true, value: trimmed }; +} +``` + +## Main Process Implementation + +Create the application use case and source ports inside `src/features/member-log-stream/core/application`. Main-process source adapters live in `src/features/member-log-stream/main/adapters/output/sources`. + +Existing task log stream services can still be reused as infrastructure dependencies where appropriate, but member stream orchestration must not live inside `src/main/ipc/teams.ts` or a monolithic `src/main/services/team` class. + +Recommended internal source-port interface: + +```ts +interface MemberLogStreamSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogStreamBudget; + sinceMs?: number | null; + forceRefresh?: boolean; +} + +interface MemberLogStreamSourceResult { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + segments: MemberLogStreamSegment[]; + warnings: MemberLogStreamWarning[]; +} + +interface MemberLogStreamSource { + readonly provider: MemberLogStreamProvider; + load(input: MemberLogStreamSourceInput): Promise; +} +``` + +First PR sources: + +- `ClaudeMemberTranscriptStreamSource`; +- `OpenCodeMemberRuntimeStreamSource`; +- `CodexNativeMemberTraceStreamSource`, but only as skipped coverage adapter in first PR. + +Recommended responsibilities: + +- service normalizes already-validated options and budget; +- source classes discover and load their provider/runtime data; +- service merges source results in deterministic provider order; +- service calls provider sources fail-soft through `Promise.allSettled()` or equivalent per-source try/catch; +- service joins identical active requests by team/member/lane/limit/since key, with no long-lived response cache in the first PR; +- service enforces `maxSourceMessages`, `maxMessagesPerSegment`, `maxSegments` and `maxChunks`; +- service enforces content-size budgets before chunk build, because one huge tool result can freeze the renderer even when message count is low; +- service uses provider-neutral `ParsedMessage` hygiene/truncation helpers and must not apply task/board-specific JSON payload cleanup across all member logs; +- service sorts segments by timestamp ascending in the response; +- service normalizes participant key to selected member; +- service returns structured coverage/warnings instead of throwing for partial provider failures. + +Claude source responsibilities: + +- discover transcript files for selected member through `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()`; +- extend/use optional ref metadata `kind` and `sizeBytes`; +- ensure finder `mtimeSinceMs` filters lead transcript refs too, not only collected member/subagent candidates; +- dedupe cumulative subagent refs by `memberName + sessionId`, keeping largest `messageCount` when available, otherwise largest `sizeBytes`, then newest `mtimeMs`; +- do not parse every candidate just to compute `messageCount`; use `messageCount` only when existing attribution logic already knows it cheaply, otherwise use `sizeBytes` as the cumulative snapshot proxy; +- cap candidate refs by `maxTranscriptFiles` after dedupe; +- strict-parse only candidate files; +- trim very large parsed files with pair-aware message truncation before chunk build; +- trim or summarize oversized message content/tool-result content before chunk build, preserving tool ids and result pairing; +- do not reuse `sanitizeJsonLikeToolResultPayloads()` wholesale for member stream, because board/task-specific sanitization can hide legitimate JSON tool outputs; +- build one segment per session/log file by default; +- populate segment `source` metadata with provider/session/message counts, but never absolute file paths; +- include sidechain chunks through existing chunk builder. + +OpenCode source responsibilities: + +- use `laneId` when present; +- call `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` with `--lane`, `--limit` and a popup-specific timeout; +- keep a small source-local TTL cache and `inFlight` join keyed by team/member/lane/limit, matching the task source pattern closely enough to avoid repeated bridge calls; +- treat ambiguous lane as skipped provider with warning; +- treat runtime timeout/binary errors as skipped or partial provider warnings, not whole-popup failure; +- map projection output into `MemberLogStreamSegment[]` through extracted `OpenCodeRuntimeProjectionMapper`. + +Codex source responsibilities in first PR: + +- return coverage status `skipped`; +- add warning `codex_member_wide_not_supported` only when useful for diagnostics; +- do not scan all Codex trace directories yet. +- do not call `CodexNativeTraceReader.readTaskRuns()` from member stream, because it requires task ids and returns only task-keyed native tool traces. +- if a follow-up adds partial Codex support, call it `codex_native_trace_partial` and cap `maxTaskDirs`, `maxTraceCandidates` and `maxTraceRuns` before parsing. + +Important implementation constraints: + +- Do not reuse one `BoardTaskExactLogStrictParser` instance between task stream and member stream unless cache ownership is changed. `parseFiles()` currently calls `retainOnly()`, so sharing the parser can evict both task-stream parsed cache and task-stream `inFlight` parse dedupe. +- Do not use `findMemberLogPaths()` as the main source. It lacks `sessionId`, `mtimeMs`, and sorted recent refs. +- Do not directly import renderer code into main. +- Do not manually construct `EnhancedChunk` if `BoardTaskExactLogChunkBuilder` can build it. +- Do not use task-specific filtering rules for member stream. Member stream scope is source/member attribution, not task interval. +- Do not reuse `CodexNativeTaskLogStreamSource` for member logs. It is task-owner scoped and relies on `readTaskRuns({ taskIds })`. +- Do not use `OpenCodeTaskLogStreamSource` directly for member logs. It is task-window and task-marker aware. +- Do not copy `toParsedMessage()` into member source. Extract generic OpenCode projection mapping from `OpenCodeTaskLogStreamSource` and let both task/member sources import it. Do not base the shared mapper on `OpenCodeTaskStallEvidenceSource`, because that mapper is intentionally narrower and does not preserve all task-stream rendering data. +- Keep main handler fail-soft. A single unreadable transcript file should become a warning, not a failed popup. +- Keep response bounded. Any cap hit sets `truncated: true` and warning `large_log_window_limited`. +- If a single file/session exceeds message budget, use `segment_message_window_limited` and drop the oldest message window before chunk build. +- Do not add member stream policy or service ownership to `initializeTeamHandlers()`. The feature should have its own main composition and IPC registration path. + +Segment rules: + +- Segment id should be stable across refreshes: include provider, normalized team/member, session id or hashed file fingerprint, first timestamp. +- Do not put absolute file paths in segment ids. +- Segment timestamps come from first/last parsed message. +- Empty parsed files are ignored. +- Very large files are parsed through existing stream parser, not loaded wholesale in renderer. +- If chunks are empty after normalization, skip segment. +- If total chunks exceed `maxChunks`, keep recent useful chunks and mark response truncated. +- Do not text-truncate inside `EnhancedChunk` in first PR. +- Do not leave orphan tool results after message trimming. Keep matching assistant tool call by `sourceToolUseID`, or drop the orphan result. +- Do not synthesize fake `Process` entries for OpenCode. Its projection should render through normal tool/output chunks. +- Do not rely on collapsed UI state for safety. `MemberExecutionLog` keeps AI groups expanded by default and expanded tool rows can render full content. + +## Transport And Composition Integration + +This part is easy to underestimate. The feature is not only a service and renderer component; it must pass through feature contracts, preload, browser fallback and IPC registration without disturbing existing task-log handlers. + +Required transport edits: + +- Add feature channel constants in `src/features/member-log-stream/contracts/channels.ts`, for example `MEMBER_LOG_STREAM_GET` and `MEMBER_LOG_STREAM_SET_TRACKING`. +- Prefer feature channel names such as `member-log-stream:getMemberLogStream` over new `TEAM_*` constants. If a compatibility alias is needed, it should still point to feature-owned contracts. +- Add `MemberLogStreamApi` in `src/features/member-log-stream/contracts/api.ts`. +- Add `createMemberLogStreamBridge()` in `src/features/member-log-stream/preload`. +- Add feature IPC registration in `src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts`. +- Register/remove feature IPC handlers from app main composition through `@features/member-log-stream/main`, not through `registerTeamHandlers()`. +- Add `createMemberLogStreamFeature(...)` in `src/features/member-log-stream/main/composition`. +- Instantiate the feature facade in `src/main/index.ts` or the existing main composition root after finder/parser/bridge/tracker dependencies are available. +- Expose renderer access through a feature-friendly API path, for example `api.memberLogStream`. If `api.teams.getMemberLogStream(...)` is kept for compatibility, it must be a thin delegate to the feature API. +- Add browser-mode fallback returning a complete empty `MemberLogStreamResponse`, not `null` and not a thrown error. +- Add browser-mode no-op fallback for member stream tracking. +- Add `member_log_stream` to `TeamLogSourceTrackingConsumer`. +- Map feature tracking enable to `TeamLogSourceTracker.enableTracking(teamName, 'member_log_stream')`. +- Map feature tracking disable to `TeamLogSourceTracker.disableTracking(teamName, 'member_log_stream')`. +- Keep the tracker consumer team-scoped, not member-scoped. `TeamLogSourceTracker` already scopes watch targets by team sessions and uses reference counts, so multiple open member popups for the same team should increment/decrement the same `member_log_stream` consumer count. +- Cleanup must run for the exact team used on mount. If `teamName` changes while the dialog stays mounted, disable tracking for the previous team before enabling it for the next team. + +Important integration rule: + +🎯 8.5 🛡️ 9 🧠 4 +Примерно 80-180 LOC. + +Use feature-owned composition: + +```ts +const memberLogStreamFeature = createMemberLogStreamFeature({ + logsFinder, + logSourceTracker, + runtimeBridge, + logger, +}); + +registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); +``` + +Do not insert member stream dependencies into `initializeTeamHandlers()`. Current tests and likely other setup code pass optional team services positionally; adding this feature there would create a false ownership boundary and risk confusing service assignment failures. + +Alternative: + +🎯 7 🛡️ 9 🧠 7 +Примерно 250-450 LOC. + +Refactor all existing `initializeTeamHandlers()` dependencies to a dependency object. This is cleaner long-term for legacy team IPC, but it is a separate IPC hygiene PR and should not be bundled unless the implementation intentionally pays that cost. + +Browser fallback shape: + +```ts +const emptyMemberLogStreamResponse: MemberLogStreamResponse = { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: new Date().toISOString(), + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, +}; +``` + +This keeps browser-mode tests honest while making it obvious that real stream loading is Electron-only. + +## Renderer Implementation + +Refactor the stream UI so `TaskLogStreamSection` is not copied wholesale. + +Recommended shape: + +- Extract a generic render-only component, for example `ExecutionLogStreamView`. +- It receives `title`, `description`, `stream`, `loading`, `error`, `emptyTitle`, `emptyDescription`, `teamName`, `forceSegmentHeaders`, `boundedHistoryNote`, and optional `buildSegmentRenderKey`. +- It must not import `api.teams`, `MemberLogsTab`, feature gates, provider sources or task/member loading hooks. +- It should receive already loaded `teamMembers` from the container, so pure view tests do not need the app store. +- `TaskLogStreamSection` keeps task-specific loading logic and task-specific descriptions. +- New `MemberLogStreamSection` owns member-specific loading logic and descriptions. +- `ExecutionLogStreamView` should be exported from `@features/member-log-stream/renderer` as a public render primitive if legacy `TaskLogStreamSection` needs to reuse it. +- `MemberDetailDialog` renders `MemberLogStreamSection` in the existing Logs tab area. +- `MemberLogsTab` remains available as fallback. +- Do not modify `MemberLogsTab` behavior in the first PR. It is also used by `ExecutionSessionsSection` in task logs, so changing it would widen the regression surface. +- Put the renderer feature-gate decision at the `MemberDetailDialog` Logs tab boundary. `MemberLogStreamSection` should not import or own the legacy `MemberLogsTab`. +- On first-load failure in the new stream, show the member stream error and an explicit legacy fallback panel below it. Do not silently replace the new stream with old logs, because that hides QA failures. +- Do not add virtualization in the first PR. `MemberExecutionLog` currently renders all groups, so protection must come from backend `maxSegments`/`maxChunks`. +- If `stream.truncated` is true or warning `large_log_window_limited` exists, show a short note that the popup is showing the recent bounded stream. +- Keep `normalizeResponse()` generic enough to convert every segment `chunks` through `asEnhancedChunkArray`. +- Generic response normalization must preserve the original stream shape with object spread. Do not reconstruct only task fields, because member-only metadata and `segment.source` are part of the contract. +- Keep participant identity actor-based. For a selected member, prefer one member participant plus per-segment `source` labels; do not create provider/session pseudo-participants just to show Claude/OpenCode/Codex labels. +- If source filtering is needed later, add a separate source filter. Do not overload participant chips with provider identity. +- Preserve current task-stream render-key behavior by default: `participantKey:firstChunkId`. This keeps expanded state stable when a task segment grows. +- For member stream, pass a source-aware render key based on safe `segment.id` plus first chunk id or start timestamp. This avoids collisions between providers/sessions for the same participant. +- Keep date handling consistent with task stream. Electron IPC should preserve `Date` objects through structured clone, but renderer tests and browser-mode stubs may use JSON-like strings. +- If `ExecutionLogStreamView` starts accepting JSON-like fixtures, add one shared chunk date normalizer instead of ad hoc `new Date()` calls in each component. +- Keep `describeStreamSource()` source-specific. Do not reuse task copy in member popup. + +Member-specific UI text: + +- Loading: `Loading member log stream...` +- Empty: `No log stream entries were found for this member yet.` +- Error: `Failed to load member log stream` +- Source description should mention member-scoped transcript/runtime logs, not task-scoped logs. + +Live refresh: + +- Enable feature tracking bridge while `MemberLogStreamSection` is mounted, then disable it on unmount. If `api.teams.setMemberLogStreamTracking` is kept for compatibility, it delegates to the feature bridge. +- Do not rely on `TaskLogsPanel` to keep `TeamLogSourceTracker` active. Member popup can be opened without task logs panel mounted. +- Do not call `setTaskLogStreamTracking()` from member UI. It would work technically, but it couples member stream lifecycle to task stream naming and tests. +- Reuse the existing team change subscription path for now. Do not create a second event bus in this PR. +- Reload on same-team `log-source-change` with `forceRefresh: true` to bypass the finder discovery cache. +- Reload on same-team `task-log-change`, because that is a log freshness signal and does not include memberName. +- Do not reload on `tool-activity`. +- Debounce 500-750ms, slightly more conservative than task stream. +- Background reload replaces the full bounded response. Do not request a `since`-only response from renderer until the service exposes a response merge contract. +- Coalesce duplicate background reloads while one member stream request is active. Keep at most one pending reload and run it after the active request settles. +- Background reload with `forceRefresh: true` should not start a parallel request if a request for the same member key is already running. +- Do not clear old stream on background refresh failure. + +## Provider Coverage + +| Provider/runtime | Variant 2 coverage | Reliability | Notes | +| --- | --- | --- | --- | +| Claude JSONL transcript | Good | 🛡️ 9 | Existing member attribution, recent refs, session ids and JSONL parsing are enough for first release | +| OpenCode runtime projection | Good if session/lane is resolvable | 🛡️ 8 | Safe when desktop passes `laneId`; ambiguous records are skipped, not guessed | +| Codex native trace | Explicitly skipped in first PR | 🛡️ 8 | Honest coverage is safer than partial trace presented as full Codex logs | + +Claude transcript path: + +- Use `TeamMemberLogsFinder` as the source locator. +- Do not re-implement member attribution in renderer. +- Trust existing precedence: process/team metadata over routing sender over teammate id over text mention. + +OpenCode path: + +- Extend `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` params with optional `laneId`. +- If desktop knows a safe lane for the member, pass `--lane`. Renderer can pass `member.laneId` from `TeamMemberSnapshot` through `getMemberLogStream` options. +- If lane is unknown, calling without lane is allowed only as best-effort and must catch the orchestrator "pass --lane" error. +- On ambiguity, skip OpenCode runtime projection and add `opencode_ambiguous_lane` warning. +- Do not merge multiple OpenCode lanes unless a later orchestrator contract exposes safe list-by-member output. + +Codex native path: + +- Do not claim full member-wide Codex support in variant 2. +- Existing trace headers include `ownerName`, so partial member-wide native tool trace is possible as a follow-up. +- Current `CodexNativeTraceReader` is task-first and `CodexNativeTraceProjector` is native-tool-only. +- First PR should return coverage status `skipped` with `codex_member_wide_not_supported`. +- Optional second PR can add `readMemberRuns()` and label it as partial native tool trace, not full Codex logs. + +## Edge Cases And Mitigations + +1. Member removed from team. + - Keep handler validation syntax-only for `memberName`. + - Allow logs finder to search historical logs. + - UI should still render if popup can open from historical data. + +2. Member renamed. + - Variant 2 uses exact current/historical name passed by caller. + - Do not guess aliases. + - Future work can add alias map from team config history. + +3. Lead member. + - Use existing `isLeadMember` and participant role conventions. + - Do not assume lead means non-sidechain in every transcript. + +4. Multiple members with similar names. + - Do not use loose substring matching in the new service. + - Rely on `TeamMemberLogsFinder` attribution signals. + +5. OpenCode multiple lanes. + - Treat as ambiguous unless lane is known. + - Do not silently pick first record. + +6. OpenCode runtime unavailable or slow. + - Missing binary returns transcript-only stream with warning, not hard error. + - Use popup-specific `openCodeTimeoutMs`. + - Return Claude transcript segments even when OpenCode times out. + - Add `opencode_runtime_timeout` warning. + +7. Huge member history. + - Limit transcript refs, source messages, per-segment messages, segment count and total chunks. + - Limit content characters as well as message count. + - Prefer most recent files first if finder supports mtime. + - Expose warning `large_log_window_limited`. + - Set `truncated: true`. + - Avoid rendering thousands of chunks in one popup. + +7.1 Huge single tool result or output. + - A single parsed message can contain a massive tool result or markdown output. + - Apply `maxMessageContentChars`, `maxToolResultContentChars` and `maxTotalContentChars` before chunk build. + - Preserve tool call/result ids and replace truncated content with a clear bounded placeholder. + - Add `message_content_limited` warning and set `truncated: true`. + - Extract provider-neutral message hygiene helpers instead of applying board/task-specific JSON sanitization to every member log. + - JSON-looking Bash/API outputs should remain visible unless they exceed content budget, in which case they are truncated, not blanked. + +8. Partial or malformed JSONL. + - Strict parser already skips invalid/unreadable records. + - Service should continue with remaining files. + +9. Empty logs. + - Return `member_empty`, no exception. + - UI empty state should not look like failure. + +10. Provider duplication. + - If OpenCode runtime projection duplicates transcript-derived entries, prefer transcript when there is a stable message uuid. + - If no stable uuid, do not attempt risky fuzzy dedupe in first release. + - Deduplicate cumulative Claude subagent snapshots before parse, using ref metadata. + +11. Sorting across sessions. + - Response sorted ascending. + - Renderer may reverse for newest-first, matching task stream behavior. + - Mixed provider segments should be sorted by timestamp, not provider priority. + +12. Cache invalidation. + - Cache key should include team, member, candidate file mtimes/sizes and runtime projection identity. + - Do not keep stale stream after log-source-change event. + - If no explicit service-level cache is added in first PR, document that dedicated parser/runtime caches are the only caches. + - `TeamMemberLogsFinder` discovery cache has a 30s TTL, so member stream needs a force-refresh path after `log-source-change`. + - OpenCode member runtime source should have its own short TTL and in-flight join so repeated live refreshes do not spawn duplicate `runtime transcript` calls. + - `forceRefresh` may bypass completed OpenCode cache, but should still join an existing in-flight call for the same team/member/lane/limit. + +13. Renderer memory pressure. + - Keep `limitSegments` plus backend `maxChunks` and message budgets. + - Avoid storing raw logs in React state. + - Store only normalized stream response. + - Defer virtualization until there is a real need for audit-sized popup history. + +14. Old fallback hiding new bugs. + - Fallback should be visible as fallback, not silently replace new stream in a way that masks errors during QA. + +15. Browser mode API client. + - `src/renderer/api/httpClient.ts` should add unavailable stub like existing `getTaskLogStream`. + - Do not break browser-mode compile. + +16. Unsafe or malformed `laneId`. + - Do not validate with `validateMemberName`. + - Allow colon-separated runtime lane ids. + - Reject NUL/newline and oversized values. + - Treat invalid `laneId` as IPC validation error, not as "no lane". + +17. OpenCode mapper drift. + - Do not duplicate projection message mapping in the new member source. + - Extract generic mapper from `OpenCodeTaskLogStreamSource`. + - Keep task marker/window filtering inside the task source. + +18. Tool call/result split by trimming. + - Truncate parsed messages before chunk build with pair-aware logic. + - If a retained tool result has `sourceToolUseID`, retain the matching assistant tool call when possible. + - If the matching call cannot fit the hard budget, drop the orphan result. + +19. Live refresh noise. + - Member stream cannot filter `task-log-change` by member because the event has no `memberName`. + - Reload on same-team `task-log-change` only while popup is mounted and debounced. + - Skip `tool-activity` reloads to avoid heavy parser churn. + +20. Feature IPC ownership drift. + - `initializeTeamHandlers()` has many optional dependencies passed positionally. + - Adding member stream there would both risk positional service drift and put new feature policy in legacy team IPC. + - Register member stream through feature-owned IPC/composition instead. Leave legacy dependency-object cleanup as a separate PR. + +21. Browser mode fallback drift. + - Browser mode does not support Electron IPC, but the renderer API still must satisfy the feature API. + - Return a complete empty `MemberLogStreamResponse` from the feature/browser fallback path. + - Add a small test or compile check so shared type changes do not break browser fallback. + +22. Historical member not in current config. + - `TeamMemberLogsFinder` attribution uses `knownMembers`. + - If the requested member is removed from config/meta/inbox, attribution can miss old files. + - Add requested member names into the finder attribution set for `findRecentMemberLogFileRefsByMember()`. + +23. Mixed-provider segment headers. + - Plain `BoardTaskLogSegment` has no provider/session label for renderer. + - Use `MemberLogStreamSegment.source` metadata for safe labels. + - Do not expose absolute transcript paths in segment metadata or warnings. + +24. Path leakage through stable ids. + - Segment ids are visible to renderer and can end up in test snapshots. + - Use a short hash/fingerprint of `filePath + mtimeMs + sizeBytes`, not the absolute path itself. + - Warnings should say counts/provider/session labels, not local filesystem paths. + +25. Discovery cache stale after launch/log source changes. + - Finder discovery cache can keep old session ids briefly. + - Renderer should pass `forceRefresh: true` after same-team `log-source-change`. + - Service should pass that through with object-form finder options: `{ mtimeSinceMs, forceRefresh }`. + - For OpenCode runtime calls, keep source-local TTL/in-flight protection. `forceRefresh` should bypass completed cache only, not duplicate an active bridge call. + +26. Validator placement/type drift. + - `src/main/ipc/guards.ts` currently keeps `ValidationResult` local. + - Prefer exporting concrete validators such as `validateOptionalRuntimeLaneId()` and `validateOptionalBooleanOption()`. + - If validators stay local in `teams.ts`, avoid importing the private `ValidationResult` type. + +27. Date object assumptions in renderer. + - `groupTransformer` calls `.getTime()` on chunk/step dates. + - Existing task stream relies on Electron structured clone preserving dates. + - New shared tests should cover Date-shaped chunks and avoid accidentally feeding raw JSON date strings into `MemberExecutionLog`. + - If JSON-like chunks are needed in tests or browser mode, add one shared `normalizeEnhancedChunkDates()` helper. + +28. Fallback boundary drift. + - `MemberLogsTab` is shared by the member popup and task `Execution Sessions`. + - Changing `MemberLogsTab` itself can break task-log legacy browsing. + - Keep the new-vs-old decision in `MemberDetailDialog` only. + - Test renderer gate on/off at dialog level. + +29. Hidden stream failure during QA. + - If the new stream error silently switches to old logs, failures can ship unnoticed. + - Show the error and a clearly labeled fallback. + - Background refresh failures can preserve the previous good stream, but initial load failures should be visible. + +30. Resolved member lane type drift. + - `TeamMemberSnapshot` already has runtime/lane fields: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`. + - `ResolvedTeamMember` currently does not declare them, even though `buildResolvedMember()` spreads the snapshot. + - Add explicit fields to the shared type instead of using casts in `MemberLogStreamSection`. + - Do not add duplicate renderer mapping unless tests prove a real runtime gap. + +31. OpenCode bridge call churn. + - Live refresh can fire more than once while a popup is open. + - Mirror the task OpenCode source pattern with a short TTL cache and `inFlight` join. + - Cache both success and null briefly, and keep timeout warnings fail-soft. + +32. Board/task sanitization leakage. + - Task stream has private helpers that clean board-tool JSON payloads. + - Member stream is wider and may legitimately contain JSON output from Bash/API tools. + - Reuse only provider-neutral clone/prune/truncate helpers, or rename board-specific cleanup so it cannot be accidentally applied globally. + +33. Finder third-arg compatibility drift. + - `findRecentMemberLogFileRefsByMember()` already has numeric/null positional callers. + - Adding `forceRefresh` as object-only third arg can break runtime advisory or live tests. + - Add a compatibility parser that accepts `number`, `null` and `{ mtimeSinceMs, forceRefresh }`. + - Member stream should use object form, existing advisory code can keep numeric/null form. + +34. Lead transcript bypasses `mtimeSinceMs`. + - Current recent-ref finder applies `mtimeSinceMs` to collected candidates, but lead transcript is pushed before that scan. + - If selected member is lead and `since` is used as a performance window, old lead logs can be returned unexpectedly. + - Apply the same mtime filter to lead transcript stat before pushing the lead ref. + +35. Renderer duplicate reload pressure. + - `TaskLogStreamSection` ignores stale responses with `requestSeqRef`, but still can start parallel IPC calls. + - Member stream requests are heavier because they can parse many files and call OpenCode runtime transcript. + - Coalesce active member stream loads in `MemberLogStreamSection`, and join identical active requests in `GetMemberLogStreamUseCase`. + +36. Segment render key collision. + - Task stream default render key uses `participantKey:firstChunkId` to preserve expanded state when a segment tail grows. + - Member stream can have multiple providers/sessions for one participant, so that default can collide. + - Keep the task default, but let member stream pass a source-aware `buildSegmentRenderKey`. + +37. Live tracking not activated. + - `TaskLogStreamSection` subscribes to `onTeamChange`, but `TaskLogsPanel` enables `setTaskLogStreamTracking`. + - Member popup has no equivalent parent, so it can miss `task-log-change` and `log-source-change` events if no other UI consumer is active. + - Add `setMemberLogStreamTracking()` and a `member_log_stream` tracker consumer. + - Enable tracking only while `MemberLogStreamSection` is mounted and disable it on unmount. + +38. IPC option typo drift. + - Existing `TEAM_GET_DATA` rejects unknown option keys before dispatching. + - If `getMemberLogStream` silently accepts unknown keys, typos in `laneId`, `since` or `forceRefresh` can create stale or ambiguous logs while tests still pass. + - Use an allow-list: `limitSegments`, `since`, `laneId`, `forceRefresh`. + - Return `Unknown getMemberLogStream option: ${key}` before calling the service. + +39. Participant/source identity drift. + - `BoardTaskLogParticipant` drives actor chips, color lookup and member labels. + - If provider/session is encoded as participant identity, one selected member can appear as multiple people and filters become misleading. + - Keep `participantKey` actor-based, for example `member:` or existing lead/unknown conventions. + - Put provider/session/lane details only in `MemberLogStreamSegment.source`. + +40. Since-only reload hides older visible segments. + - `since` is useful as a backend performance hint, but a since-filtered response is partial unless the API says otherwise. + - Renderer background reload currently replaces state, it does not merge old and new segments. + - First PR should do full bounded background reloads, with `forceRefresh` on `log-source-change`. + - Use `since` only in tests/service paths where the expected partial semantics are explicit. + +41. Parser `retainOnly()` in-flight eviction. + - `BoardTaskExactLogStrictParser.parseFiles()` calls `retainOnly()` before parsing. + - `BoardTaskActivityParseCache.retainOnly()` deletes `inFlight` entries outside the requested file set. + - If task and member streams share a parser, one stream can remove the other's in-flight dedupe and cause duplicate reads or cache churn. + - Treat parser instance ownership as per stream source stack. Do not inject the existing task parser into the member stream source stack. + +42. Wrong OpenCode mapper source. + - `OpenCodeTaskLogStreamSource.toParsedMessage()` preserves content blocks, sanitized display text, `toolUseResult`, `sourceToolUseID`, `sourceToolAssistantUUID`, `isMeta`, subtype and level. + - `OpenCodeTaskStallEvidenceSource.toParsedMessage()` is narrower and maps non-string content to an empty array. + - Extract the shared mapper from task stream source only; stall monitor migration can be a separate cleanup if needed. + +43. Finder metadata overreach. + - Adding `messageCount` to `MemberLogFileRef` is useful only when that value is already cheaply known from existing attribution records. + - Parsing every candidate file inside the finder just to count messages would double the IO before the strict parser runs. + - Keep finder metadata lightweight: `kind`, `sizeBytes`, `mtimeMs` and optional existing `messageCount`. + +44. Tracking consumer leaks or semantic drift. + - `TeamLogSourceTracker` keeps reference counts by team and consumer name. + - Reusing `task_log_stream` from member popup is technically possible but makes task/member lifecycles indistinguishable in tests and diagnostics. + - Add `member_log_stream` as a separate consumer and ensure every mount enable has a matching unmount or team-change disable. + +45. Generic renderer view grows side effects. + - `TaskLogStreamSection` currently owns fetch, debounce, stale-response protection and task copy. + - If `ExecutionLogStreamView` imports `api.teams`, feature gates or `MemberLogsTab`, it stops being a reusable render primitive. + - Keep the generic view pure: render normalized stream, participants, segment headers and state. Containers own loading, tracking and fallback. + +46. Codex partial trace presented as complete. + - `CodexNativeTraceReader` is task-keyed and `CodexNativeTraceProjector` keeps only native tool events. + - Reusing the task source for member logs would either require a task id or scan all task dirs, and the result still would not be full Codex chat history. + - First PR keeps Codex coverage `skipped`. A later partial trace must be explicitly labeled `codex_native_trace_partial`. + +47. Runtime lane validation too strict or too loose. + - `validateMemberName()` rejects `secondary:opencode:` because of `:`. + - Do not lowercase or rewrite `laneId`; orchestrator/session records may expect exact lane ids. + - Add an optional lane validator that trims, accepts `primary` and colon-separated runtime lanes, rejects empty strings, NUL/newlines/control characters, path separators and length over 256. + +48. DTO inheritance/source union drift. + - `BoardTaskLogStreamResponse.source` is task-scoped and does not include member values. + - Do not extend `BoardTaskLogStreamResponse` for member logs or broaden its `source` union. + - Define standalone `MemberLogStreamResponse` with shared participant/segment primitives, then test that task stream source values stay unchanged. + +49. Feature boundary drift. + - The easiest regression is to put policy in `src/main/ipc/teams.ts`, preload glue or React containers because those files already exist. + - Keep the feature-owned policy in `src/features/member-log-stream/core` and provider IO in feature main adapters. + - App-shell files may register, delegate or mount the feature, but they must not know merge order, budgets, source coverage rules or provider-specific parsing details. + +## Rollout And Feature Gates + +Add separate gates: + +- Main: `CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED`, default true. +- Renderer: `VITE_MEMBER_LOG_STREAM_UI_ENABLED`, default true. +- Put main gate in `src/features/member-log-stream/main` or feature contracts/helpers, following existing truthy/falsy parsing semantics. +- Put renderer gate in `src/features/member-log-stream/renderer` or a feature renderer helper. Do not read `import.meta.env` outside renderer code. + +Fallback behavior: + +- If main gate is off, handler returns empty response or unavailable result consistently. +- If renderer gate is off, `MemberDetailDialog` uses old `MemberLogsTab`. +- If new stream errors, show error plus old logs fallback. +- If task log `Execution Sessions` renders old session logs, it stays unchanged. It imports `MemberLogsTab` directly and should not be coupled to member stream rollout. + +Rollback: + +- Flip renderer gate off to restore old member logs UI. +- Flip main gate off if parsing causes unexpected load. +- No data migration needed. + +## Testing Plan + +Main-process tests: + +- Core domain policy tests cover merge order, source coverage status, budget decisions, dedupe keys and DTO-safe source metadata without main/preload/renderer imports. +- Core application use-case tests inject fake `MemberLogStreamSource` ports, fake clock/logger/cache ports and verify fail-soft source behavior. +- Core application tests prove adding a new source adapter does not require changing renderer or transport code. +- `GetMemberLogStreamUseCase` returns empty stream for no files. +- It builds one or more segments from synthetic Claude JSONL files for selected member. +- It does not include logs attributed to another member. +- It skips unreadable/malformed files and records warnings. +- It respects `limitSegments`. +- It respects `maxTranscriptFiles`, `maxSegments` and `maxChunks`. +- It respects `maxSourceMessages` and `maxMessagesPerSegment`. +- It respects `maxTotalContentChars`, `maxMessageContentChars` and `maxToolResultContentChars`. +- It sets `truncated: true` and `large_log_window_limited` when budget is hit. +- It records `message_content_limited` when content budget is hit. +- It preserves JSON-looking tool output unless content budget requires truncation. +- It does not apply board/task-specific JSON payload sanitization globally. +- It records `segment_message_window_limited` when trimming one oversized segment. +- It preserves tool call/result pairs when trimming, or drops orphan results. +- It preserves tool ids and source ids when truncating oversized content. +- It dedupes cumulative subagent refs by member/session before parse. +- It keeps segment ids stable across repeated calls. +- It does not expose absolute file paths in segment ids, metadata or warnings. +- It does not share parser cache with task stream service. +- It uses a member-owned `BoardTaskExactLogStrictParser` so member parsing cannot call `retainOnly()` on the task stream parser or clear task `inFlight` entries. +- A task-stream parse in flight stays joined after a member-stream parse starts for a different file set. +- It handles removed member name without requiring current team membership. +- It augments finder attribution with requested member names for historical/removed members. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` can return optional `kind` and `sizeBytes` without breaking existing callers. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` does not parse candidate transcript files just to compute `messageCount`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` keeps legacy numeric/null third-arg behavior. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` supports object options `{ mtimeSinceMs, forceRefresh }`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` passes `forceRefresh` to `discoverProjectSessions()`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` applies `mtimeSinceMs` to lead transcript refs. +- It merges source results in deterministic order. +- It joins identical active `GetMemberLogStreamUseCase` requests and does not add long-lived response cache. +- It returns `MemberLogStreamSegment.source` metadata without leaking file paths. +- Provider source tests cover Claude included, OpenCode skipped, Codex skipped. +- First-PR Codex skipped adapter does not call `CodexNativeTraceReader.readTaskRuns()` and does not scan trace dirs. +- `OpenCodeRuntimeProjectionMapper` preserves tool calls, tool results, `sourceToolUseID`, `sourceToolAssistantUUID`, `toolUseResult`, `isMeta` and sanitized text content. +- `OpenCodeTaskLogStreamSource` and `OpenCodeMemberRuntimeStreamSource` both use `OpenCodeRuntimeProjectionMapper`. +- Mapper fixture tests cover non-string content blocks so member stream does not regress to the narrower stall-monitor conversion. +- `OpenCodeMemberRuntimeStreamSource` joins duplicate in-flight bridge calls for the same team/member/lane/limit. +- `OpenCodeMemberRuntimeStreamSource` honors `forceRefresh` by bypassing completed TTL cache but still joining active in-flight work. +- `ResolvedTeamMember` exposes runtime/lane fields without renderer casts: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`. +- `MemberLogStreamSection` passes `laneId` only for OpenCode members whose lane is owned by OpenCode. +- `MemberLogStreamSection` does not pass stale lane-like fields for non-OpenCode members. + +IPC/preload tests: + +- Feature IPC tests register/remove feature channels through `registerMemberLogStreamIpc()` and do not rely on `src/main/ipc/teams.ts` owning the handler. +- Feature preload tests cover `createMemberLogStreamBridge()` and import only feature contracts plus preload-safe helpers. +- `getMemberLogStream` validates `teamName` and `memberName`. +- It accepts colon-separated `laneId`, for example `secondary:opencode:alice`. +- It accepts `primary` as a lane id. +- It preserves exact lane id casing and punctuation when passing to the service. +- It rejects `laneId` with NUL/newline/control characters/path separators or length over 256. +- It clamps `limitSegments` to `1..80`. +- It rejects invalid `since`. +- It accepts boolean `forceRefresh` and rejects non-boolean values. +- It rejects unknown option keys before dispatching to `GetMemberLogStreamUseCase`. +- It registers and removes feature `MEMBER_LOG_STREAM_GET` channel. +- It registers and removes feature `MEMBER_LOG_STREAM_SET_TRACKING` channel. +- It calls the feature facade/use case with normalized options. +- It maps member stream tracking to `TeamLogSourceTracker` consumer `member_log_stream`. +- `TeamLogSourceTracker` supports `member_log_stream` as a separate consumer from `task_log_stream`. +- Multiple member stream mounts for the same team increment/decrement the same team-level consumer count without closing the watcher early. +- Disabling member stream tracking leaves the watcher alive if task stream or another consumer is still active. +- It does not add feature-owned services to existing positional `initializeTeamHandlers()` dependencies. +- Existing task stream IPC test still calls `BoardTaskLogStreamService.getTaskLogStream()` with the correct arguments after adding separate feature IPC registration. +- Browser-mode `httpClient` returns a complete empty `MemberLogStreamResponse`. +- Browser-mode `httpClient` exposes a no-op `setMemberLogStreamTracking`. + +OpenCode tests: + +- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` passes `--lane` when `laneId` is provided. +- It keeps old behavior when `laneId` is omitted. +- It accepts popup-specific `timeoutMs` and passes it to `execCli`. +- Member stream records `opencode_ambiguous_lane` when orchestrator reports multiple records. +- Member stream records `opencode_runtime_timeout` without failing Claude transcript segments. +- Member stream includes OpenCode projection only for the selected member. + +Renderer tests: + +- `renderer/ui` component tests pass props and never mock `@renderer/api` or `@renderer/store`. +- `renderer/hooks` tests own API calls, tracking, team-change subscription and reload coalescing. +- Feature renderer public entrypoint exports only supported components/hooks, and app shell does not deep-import `renderer/ui` internals. +- `MemberLogStreamSection` shows loading, empty, error and populated states. +- It renders chunks through the same execution-log renderer as task stream. +- It does not display `Task Log Stream` copy. +- It keeps old stream during failed background refresh. +- It shows fallback old logs path when new stream fails. +- `MemberDetailDialog` renders `MemberLogStreamSection` when renderer gate is on. +- `MemberDetailDialog` renders old `MemberLogsTab` when renderer gate is off. +- `MemberDetailDialog` first-load stream failure leaves the Logs tab active and shows explicit old logs fallback. +- Existing `test/renderer/components/team/members/MemberDetailDialog.test.ts` mocks `MemberLogsTab`; add a mock for `MemberLogStreamSection` instead of depending on the full stream component there. +- It shows bounded-history note when `truncated` is true. +- It reloads on same-team `log-source-change` and `task-log-change`. +- It sends `forceRefresh: true` for `log-source-change` reloads. +- It does not pass `since` for renderer background replacement reloads in the first PR. +- It calls the feature tracking bridge on mount and disables it on unmount. +- It disables member stream tracking for the previous team when `teamName` changes. +- It coalesces duplicate reloads while a member stream request is active. +- It does not reload on `tool-activity`. +- It keeps OpenCode segments with no `Process[]` rendering normal tool/output items. +- `TaskLogStreamSection` still renders unchanged after extracting shared view. +- `ExecutionLogStreamView` renders task and member streams from the same segment/chunk shape without task-specific text. +- `ExecutionLogStreamView` does not import `api.teams`, feature gates, `MemberLogsTab` or store-bound loading hooks. +- `ExecutionLogStreamView` handles the same Date-shaped chunk objects that `TaskLogStreamSection` handles today. +- `ExecutionLogStreamView` normalizes chunks without dropping unknown stream fields or member-specific `segment.source`. +- `ExecutionLogStreamView` preserves task tail-growth expanded state with the existing default render key behavior. +- `ExecutionLogStreamView` accepts member source-aware `buildSegmentRenderKey` to avoid provider/session key collisions. +- Shared type tests or compile checks prevent `MemberLogStreamResponse` from extending or mutating task `BoardTaskLogStreamResponse.source`. +- Member stream participant identity remains actor/member identity; provider/session/lane labels render from `segment.source`. +- Member stream does not create provider-specific pseudo-participants for Claude/OpenCode/Codex. + +Architecture/lint tests: + +- `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content` passes. +- Full `pnpm lint` passes with generic `src/features/*` guard rails. +- No file outside `src/features/member-log-stream` deep-imports feature internals such as `core/**`, `main/adapters/**`, `preload/**` or `renderer/ui/**`. +- `core/domain` has no imports from `@main`, `@renderer`, `@preload`, Electron, Fastify or child process modules. +- `core/application` imports only domain, contracts and ports, not concrete source adapters. +- `renderer/ui` has no imports from `@renderer/api`, `@renderer/store`, `@main` or Electron. + +Regression tests: + +- Existing `BoardTaskLogStreamService` tests stay green. +- Existing `TaskLogStreamSection` tests stay green. +- Existing `MemberLogsTab` tests stay green because the fallback component remains. + +Suggested commands: + +```bash +pnpm vitest test/features/member-log-stream/core/application/GetMemberLogStreamUseCase.test.ts +pnpm vitest test/features/member-log-stream/main/adapters/input/registerMemberLogStreamIpc.test.ts +pnpm vitest test/features/member-log-stream/renderer/MemberLogStreamSection.test.tsx +pnpm vitest test/main/services/team/TeamMemberLogsFinder.test.ts +pnpm vitest test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +pnpm vitest test/renderer/components/team/taskLogs/TaskLogStreamSection.test.tsx +pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content +pnpm typecheck +``` + +Manual QA: + +- Open participant popup for a Claude member with existing logs. +- Confirm UI looks like task stream but title/copy are member-specific. +- Open a member with no logs. +- Open a member while task activity/log-source events arrive. +- Test OpenCode member where one lane is resolvable. +- Test OpenCode member with ambiguous lane and confirm warning/fallback instead of wrong logs. + +## Acceptance Criteria + +- Participant popup Logs section uses the new stream format. +- Selected member stream does not include unrelated teammates. +- Claude transcript logs render in the same chunk UI as Task Log Stream. +- OpenCode is included only when runtime session resolution is safe. +- Codex native limitation is visible in coverage/warnings and not misrepresented. +- Large histories are capped and marked truncated instead of freezing the popup. +- Oversized single segments are capped by message budget, not only chunk count. +- `laneId` is passed and validated separately from member name. +- Cumulative subagent snapshot duplicates are not shown as repeated turns. +- Lead transcript refs respect `since`/`mtimeSinceMs` the same way member/subagent refs do. +- OpenCode projection rendering is shared with task stream through a mapper, not copy-pasted. +- Shared OpenCode mapper is extracted from task stream source, not from the narrower stall-monitor mapper. +- Member stream parser ownership is isolated, including parsed cache and in-flight parse dedupe. +- Member stream reloads coalesce instead of launching parallel expensive IPC requests. +- Member stream activates log-source tracking while mounted, without depending on Task Logs panel. +- Member stream segment render keys are source-aware and do not collide across provider/session segments. +- Repeated OpenCode member refreshes join in-flight bridge calls instead of spawning duplicate `runtime transcript` processes. +- Old `MemberLogsTab` remains available as fallback. +- No orchestrator code change is required for variant 2 unless desktop cannot pass existing `--lane`. +- Feature can be disabled with renderer/main gates. +- `src/features/member-log-stream` follows `docs/FEATURE_ARCHITECTURE_STANDARD.md`: contracts own DTO/channels, core is side-effect free, source ports live in application, provider IO lives in main adapters, renderer UI is presentational. +- App shell imports only public feature entrypoints and does not deep-import feature internals. +- Clean Architecture dependency direction is preserved: core has no framework/runtime imports, adapters depend inward, and concrete provider IO is hidden behind ports. +- SOLID is visible in code structure: provider adapters are replaceable, use cases depend on abstractions, and UI/transport/provider parsing have separate reasons to change. +- DRY is visible in shared renderer primitive, shared OpenCode projection mapper and single owner for member stream DTOs. +- Tests cover the main risky branches above. + +## Explicit Non-goals + +- Do not build a full provider-wide log audit system in variant 2. +- Do not add Codex member-wide trace index in variant 2. +- Do not merge all team agents into one participant popup stream. +- Do not remove old member logs UI in the first release. +- Do not silently pick an OpenCode lane when orchestrator says the session is ambiguous. + +## Implementation Order + +1. Create `src/features/member-log-stream` with contracts, core/application source ports, main composition, preload bridge and renderer public entrypoints. +2. Add feature-owned member stream DTOs, `MemberLogStreamSegment.source` metadata and `ResolvedTeamMember` runtime/lane fields. +3. Add feature IPC channel constants, feature preload bridge, browser-mode fallback and optional compatibility methods on `api.teams` if the existing member popup integration needs them. +4. Add IPC validation for `limitSegments`, `since`, `forceRefresh`, optional runtime `laneId` and unknown option keys in the feature input adapter or shared guard helpers. +5. Add feature main facade/composition and register IPC handlers without shifting existing positional `initializeTeamHandlers()` dependencies. +6. Add `MemberLogStreamBudget` and source-port interfaces. +7. Extend `MemberLogFileRef` with optional `kind`/`sizeBytes`/`messageCount`, add backward-compatible finder options parsing, apply `mtimeSinceMs` to lead refs, augment finder attribution with requested member names and add tests around cumulative subagent refs. Keep metadata cheap: do not parse every candidate file inside the finder just to compute `messageCount`. +8. Extract provider-neutral `ParsedMessage` hygiene helpers without moving board/task JSON cleanup into member stream. +9. Add pair-aware message trimming helper for oversized transcript windows. +10. Add content-size truncation helper that clones affected `ParsedMessage` objects before chunk build. +11. Add `ClaudeMemberTranscriptStreamSource` using recent member refs, ref dedupe, message/content trimming and dedicated parser. +12. Extract `OpenCodeRuntimeProjectionMapper` from `OpenCodeTaskLogStreamSource` without moving task-marker logic. Do not extract from `OpenCodeTaskStallEvidenceSource`; its mapper is lossy for stream rendering. +13. Add `GetMemberLogStreamUseCase` merge/sort/budget/truncation layer, identical active request join, and export/instantiate it in feature main composition. +14. Add `CodexNativeMemberTraceStreamSource` as skipped coverage adapter. +15. Add renderer feature `ExecutionLogStreamView` render-only extraction with shape-preserving normalization, source-aware key override and `MemberLogStreamSection` with tracking activation plus reload coalescing. +16. Wire `MemberDetailDialog` behind renderer feature gate with old fallback, importing only `@features/member-log-stream/renderer`. +17. Add OpenCode bridge `laneId` and timeout support. +18. Add `OpenCodeMemberRuntimeStreamSource` with mapper reuse, short TTL cache and in-flight join. Leave stall-monitor mapper migration out of scope unless tests show a shared helper can be adopted without changing stall evidence behavior. +19. Add coverage/warnings and empty/error states. +20. Add tests, including feature architecture import-boundary checks if the repo has a matching lint pattern. +21. Manual QA with Claude and OpenCode teams. +22. Decide separately whether to add partial Codex native tool trace or start variant 3. diff --git a/docs/team-management/member-log-stream-v2-research-addendum.md b/docs/team-management/member-log-stream-v2-research-addendum.md new file mode 100644 index 00000000..f7c58664 --- /dev/null +++ b/docs/team-management/member-log-stream-v2-research-addendum.md @@ -0,0 +1,2046 @@ +# Member Log Stream V2 Research Addendum + +## Scope + +Этот документ углубляет места с самой низкой уверенностью из основного плана: + +- OpenCode lane/session resolution; +- Codex native member-wide feasibility; +- Claude/member transcript attribution; +- parser/cache safety; +- architecture placement; +- renderer performance budget; +- oversized message/content budget; +- IPC validation and abuse limits; +- IPC registration/composition safety; +- exact stream DTO/render contract; +- cumulative subagent snapshot dedupe; +- OpenCode projection mapper extraction; +- message/window truncation semantics; +- member popup live refresh event policy; +- API shape между renderer, preload и main. + +Вывод после code research: вариант 2 остается правильным, но детали нужно сделать строже. Особенно важно не использовать `findMemberLogPaths()` как основной источник и не вызывать OpenCode transcript без `laneId`, если lane уже известен. + +## Executive Findings + +| Зона | Было | Стало после research | Решение | +| --- | --- | --- | --- | +| Claude/member transcript | Уверенность высокая | Еще выше: есть готовый `findRecentMemberLogFileRefsByMember()` с mtime, sessionId и сортировкой | Использовать его, не `findMemberLogPaths()` | +| OpenCode lane | Средняя уверенность | `TeamMemberSnapshot` уже несет `providerBackendId/selectedFastMode/resolvedFastMode/laneId/laneKind/laneOwnerProviderId`, renderer получает это через spread | Типизировать `ResolvedTeamMember`, передавать `laneId` в `getMemberLogStream` и bridge | +| Codex native | Низкая уверенность | `readTaskRuns()` физически task-keyed, а projector keeps only native tool events | First PR skipped; partial trace only as separate phase with honest label | +| Parser cache | Средняя уверенность | `parseFiles()` вызывает `retainOnly()`, а `retainOnly()` чистит и cache, и `inFlight` entries | Держать отдельный parser instance для member stream | +| UI reuse | Средняя уверенность | `TaskLogStreamSection` содержит task-specific copy и loading logic | Вынести generic view, оставить source-specific containers | +| Architecture placement | Уверенность высокая | Feature standard прямо подходит: cross-process feature, own policy, transport wiring, more than one adapter, provider roadmap | Делать `src/features/member-log-stream` canonical slice, with thin app-shell integration | +| Render pressure | Низкая уверенность | `MemberExecutionLog` рендерит все groups и держит их expanded by default | Ограничивать backend response budget | +| IPC validation | Средняя уверенность | `laneId` содержит `:`, значит `validateMemberName` не подходит | Добавить отдельную optional lane validator | +| Cumulative subagent logs | Низкая уверенность | `findRecentMemberLogFileRefsByMember()` dedupe только by filePath, а subagent snapshots могут быть cumulative | Добавить/использовать ref metadata и dedupe by member/session/kind перед parse | +| OpenCode projection mapping | Средняя уверенность | Есть richer mapper в task source и lossy mapper в stall monitor | Вынести generic mapper из task source, не из stall monitor | +| Finder ref metadata | Средняя уверенность | Current refs не несут `kind`/`sizeBytes`, но full parse ради `messageCount` удвоит IO | Добавлять lightweight metadata и не считать `messageCount` полным parse | +| Renderer extraction | Средняя уверенность | `TaskLogStreamSection` смешивает generic stream view и task-specific copy/reload | Вынести `ExecutionLogStreamView`, оставить containers source-specific | +| Budget semantics | Низкая уверенность | `maxChunks` не ограничивает большой AI chunk с сотнями tool calls | Добавить message budget и pair-aware trimming before chunk build | +| Content budget | Низкая уверенность | One huge tool result can render through `DisplayItemList` even with low message count | Add content-char budgets before chunk build | +| Live refresh | Средняя уверенность | member stream не знает taskId, а `task-log-change` не несет memberName | Reload on same-team `log-source-change` and `task-log-change`, debounced | +| IPC composition | Уверенность высокая | `initializeTeamHandlers()` positional deps make legacy team IPC a bad owner for this feature | Register feature IPC through `@features/member-log-stream/main`, do not add service to team handlers | +| Browser fallback | Средняя уверенность | Browser API must satisfy feature API even without Electron IPC | Вернуть complete empty `MemberLogStreamResponse`, как task stream fallback | +| Historical members | Средняя уверенность | Finder attribution uses `knownMembers` from config/meta/inbox, not necessarily removed popup member | Add requested names to attribution set inside recent-ref finder | +| Finder options compatibility | Средняя уверенность | `findRecentMemberLogFileRefsByMember()` уже используется positional `mtimeSinceMs` callers | Add backward-compatible third-arg parser, not object-only signature | +| Lead transcript mtime window | Новая находка | `mtimeSinceMs` сейчас фильтрует candidates, но lead transcript добавляется до этого filter | Apply mtime window consistently to lead and candidate refs | +| Segment metadata | Средняя уверенность | `BoardTaskLogSegment` has no provider/session label, so mixed sources become opaque | Use `MemberLogStreamSegment.source` metadata without file paths | +| Segment render keys | Новая находка | Task stream key uses `participantKey:firstChunkId`, good for tail growth but risky across member sources | Generic view gets caller-provided segment key builder | +| Renderer reload pressure | Новая находка | Task stream drops stale responses by request seq but still allows parallel IPC calls | Member stream should coalesce in-flight reloads | +| Live tracking activation | Новая находка | `TaskLogStreamSection` subscribes, but `TaskLogsPanel` enables `TeamLogSourceTracker`; member popup has no equivalent parent | Add member stream tracking activation while popup section is mounted | +| IPC option strictness | Новая находка | `TEAM_GET_DATA` rejects unknown option keys before dispatch | Reject unknown member-stream option keys too | +| Participant/source identity | Новая находка | `BoardTaskLogParticipant` is actor identity, while provider/session/lane is source identity | Keep participant actor-based and render provider/session from `segment.source` | +| Since reload semantics | Новая находка | Renderer state replacement plus since-filtered partial response can hide older visible segments | First PR uses full bounded background reloads, not client-side incremental merge | +| Chunk date shape | Средняя уверенность | Renderer group transformer expects Date objects, while tests can use JSON-like fixtures | Keep task stream assumption, add shared normalizer only if needed | +| Parser in-flight ownership | Новая находка | Shared parser can delete another stream's active parse dedupe through `retainOnly()` | Parser ownership is per stream service unless cache API is redesigned | +| OpenCode mapper source | Новая находка | Stall-monitor mapper drops non-string content blocks and `toolUseResult` | Shared mapper source must be `OpenCodeTaskLogStreamSource` | +| Tracker activation | Новая находка | `TeamLogSourceTracker` uses team/consumer reference counts and only runs while consumers are active | Add `member_log_stream` consumer, not a member-specific watcher | +| Runtime lane validation | Новая находка | Existing member validator rejects `:`, but lane ids can be `secondary:opencode:` | Add optional lane validator that preserves exact lane id | +| Generic renderer purity | Новая находка | `TaskLogStreamSection` mixes fetch/debounce/copy/render helpers | Extract render-only view and keep API/fallback/gates in containers | + +## 0. Architecture Boundary Decision + +### Repo Standard Tension + +`docs/FEATURE_ARCHITECTURE_STANDARD.md` говорит, что full feature slice нужен, когда feature spans process boundaries, имеет business rules, transport bridge, more than one adapter и provider roadmap. + +`Member Log Stream V2` подходит под этот стандарт: + +- feature spans main/preload/renderer; +- feature owns merge, dedupe, budget and provider coverage policy; +- feature needs transport wiring; +- feature has multiple source adapters: Claude, OpenCode, Codex skipped/partial later; +- roadmap явно ведет к variant 3/provider extensibility. + +Текущая реальность кода все еще важна: + +- task stream уже живет в `src/main/services/team/taskLogs/stream`; +- member popup уже живет в `src/renderer/components/team/members`; +- team IPC уже централизован в `src/main/ipc/teams.ts`; +- `api.teams` уже содержит `getMemberLogs`, `getTaskLogStream`, `getLogsForTask`. + +Поэтому правильная стратегия не "перетащить весь team logs слой", а создать feature slice для новой member stream capability and keep old surfaces as integration points. + +### Options + +#### A. Canonical feature slice with thin legacy integration + +🎯 8.5 🛡️ 9 🧠 7 +Примерно 1500-2300 LOC. + +Плюсы: + +- соответствует feature standard; +- contracts/core/application/source ports становятся clean architecture boundary; +- provider sources расширяются по OCP; +- старый task stream and legacy `MemberLogsTab` не мигрируются в этом PR; +- app shell imports only public feature entrypoints. + +Минусы: + +- больше файлов и немного больше boilerplate; +- нужно аккуратно сделать compatibility with existing `MemberDetailDialog` and app API; +- нужно добавить import-boundary discipline from the start. + +#### B. Legacy extension in current team services + +🎯 7 🛡️ 6.5 🧠 5 +Примерно 550-850 LOC. + +Плюсы: + +- минимальный blast radius; +- ближе к существующему `Task Log Stream`; +- быстрее внедряется. + +Минусы: + +- легко получить большой `MemberLogStreamService` с provider-specific ветками; +- хуже соответствует feature architecture standard; +- future variant 3 будет сложнее выделять. + +#### C. Existing team surface plus source ports + +🎯 8 🛡️ 8 🧠 6 +Примерно 1300-2050 LOC, если включить ref metadata dedupe, mapper extraction, renderer shared view, member tracking activation, dialog-level fallback tests, OpenCode in-flight protection, renderer reload coalescing и provider-neutral message hygiene extraction. + +Плюсы: + +- renderer-facing API is feature-owned, for example `api.memberLogStream`; existing `api.teams` can remain only as a thin compatibility delegate if needed by current popup wiring; +- implementation остается рядом с existing `taskLogs/stream`; +- provider/runtime логика делится на отдельные source classes; +- часть будущей миграции к feature slice будет подготовлена. + +Минусы: + +- это все еще не full canonical feature slice; +- нужно дисциплинированно не смешать orchestration, provider IO и UI DTO в одном файле. + +### Recommendation + +Выбрать A для первого PR. + +Правило: + +- `src/features/member-log-stream/contracts` owns DTOs, channels, normalize helpers and API fragment types; +- `core/domain` owns pure policies: merge order, budget decisions, dedupe keys and coverage semantics; +- `core/application` owns `MemberLogStreamSource` ports and the use case; +- `main/adapters/output/sources` owns Claude/OpenCode/Codex source adapters; +- `main/adapters/input/ipc` owns validation and IPC translation; +- `preload` owns thin bridge; +- `renderer/ui` owns presentational components only; +- old `MemberLogsTab`, task stream and task exact logs are not migrated in this PR; +- if existing `api.teams` is needed for compatibility, it delegates to feature contracts/use case and does not own DTO/policy. + +### Exact Standard Compliance + +The feature should be reviewable against `docs/FEATURE_ARCHITECTURE_STANDARD.md` without special exceptions: + +- `contracts/` owns only DTOs, API fragment types, channel constants and normalize helpers. +- `core/domain/` owns pure policies: source merge order, source coverage state, dedupe keys, budget decisions and safe source metadata. +- `core/application/` owns use cases and ports: `MemberLogStreamSource`, clock/logger/cache/tracking ports and response models. +- `main/composition/` wires concrete adapters and exposes `MemberLogStreamFeatureFacade`. +- `main/adapters/input/ipc/` owns IPC validation and transport translation. +- `main/adapters/output/sources/` owns Claude/OpenCode/Codex source adapters. +- `main/infrastructure/` owns runtime bridge helpers, parser wrappers, TTL/in-flight cache and filesystem details. +- `preload/` owns only a thin feature bridge. +- `renderer/hooks/` owns API calls, tracking activation, team-change subscriptions and reload coalescing. +- `renderer/ui/` owns presentational components only. + +Clean Architecture dependency rule: + +- domain imports no application/adapters/infrastructure/framework/process code; +- application imports no main/preload/renderer or concrete IO; +- adapters import inward to application/domain and outward to infrastructure; +- renderer UI receives props and view models, never direct API/store/Electron access; +- app shell imports only public entrypoints. + +SOLID and DRY application: + +- SRP: `GetMemberLogStreamUseCase` coordinates use case flow only; provider IO stays in source adapters; renderer UI only renders. +- OCP: adding Codex partial/full later means adding or swapping a source adapter, not rewriting renderer or use case branches. +- LSP: all sources honor the same `included | partial | skipped` result shape and fail softly for expected absence. +- ISP: core ports stay narrow. Do not pass `TeamDataService`, Electron events or renderer member snapshots through core. +- DIP: use cases depend on ports; concrete finder/bridge/parser implementations live outside core. +- DRY: one DTO owner in feature contracts, one stream render primitive, one OpenCode projection mapper, one provider-neutral message hygiene helper set. + +Lint status: + +- Generic feature guard rails already exist in `eslint.config.js` for `src/features/*`. +- Implementation should rely on those first and add member-log-stream-specific guard rails only if generic messages are not strict enough. +- Targeted lint command: `pnpm exec eslint src/features/member-log-stream --cache --cache-location .eslintcache --cache-strategy content`. + +Project standard cross-check: + +- Top-level `CLAUDE.md` says new medium/large features default to `src/features/` and must follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`. +- `docs/FEATURE_ARCHITECTURE_STANDARD.md` requires the full slice when a feature spans process boundaries, owns business policy, has transport wiring, has multiple adapters or has a provider roadmap. Member log stream matches all five. +- `eslint.config.js` already enforces generic public-entrypoint, core-domain, core-application, preload and renderer-UI guards for `src/features/*`. +- `src/features/recent-projects` confirms the public entrypoint pattern: `contracts/index.ts`, `main/index.ts`, `preload/index.ts` and `renderer/index.ts` expose only supported surface. +- `src/features/CLAUDE.md` is referenced by `CLAUDE.md`, but is absent in this worktree, so the binding standard for this plan is the top-level `CLAUDE.md` plus `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +## 0.1 Source-Port Design + +Чтобы не нарушить SRP/OCP, `GetMemberLogStreamUseCase` не должен сам знать все детали Claude/OpenCode/Codex. + +Нужен internal interface: + +```ts +interface MemberLogStreamSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogStreamBudget; + sinceMs?: number | null; + forceRefresh?: boolean; +} + +interface MemberLogStreamSourceResult { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + segments: MemberLogStreamSegment[]; + warnings: MemberLogStreamWarning[]; +} + +interface MemberLogStreamSource { + readonly provider: MemberLogStreamProvider; + load(input: MemberLogStreamSourceInput): Promise; +} +``` + +First PR sources: + +- `ClaudeMemberTranscriptStreamSource`; +- `OpenCodeMemberRuntimeStreamSource`; +- `CodexNativeMemberTraceStreamSource` only as skipped coverage adapter, no heavy trace scan. + +`GetMemberLogStreamUseCase` responsibilities: + +- normalize already-validated options; +- call sources fail-soft, preferably with `Promise.allSettled()` plus deterministic merge order; +- merge source results; +- enforce global budget; +- sort final segments; +- build response metadata and warnings. + +It should not: + +- parse OpenCode CLI output directly; +- scan Codex trace directories directly; +- know renderer copy; +- know old `MemberLogsTab` fallback details. + +## 0.2 Renderer Performance Budget + +### Facts + +`MemberExecutionLog`: + +- transforms all chunks into conversation groups; +- reverses all groups; +- renders all groups; +- keeps everything expanded by default unless user collapses groups; +- has no virtualization. + +Repo already depends on `@tanstack/react-virtual`, and `ActivityTimeline` uses it. But adding virtualization to log stream in first PR would be a separate UI behavior project. + +### Decision + +Do not add virtualization in first PR. + +Instead enforce backend budget: + +```ts +const DEFAULT_MEMBER_LOG_STREAM_BUDGET = { + maxTranscriptFiles: 40, + maxSegments: 30, + maxChunks: 250, + maxSourceMessages: 1200, + maxMessagesPerSegment: 300, + maxTotalContentChars: 800_000, + maxMessageContentChars: 80_000, + maxToolResultContentChars: 120_000, + openCodeMessageLimit: 400, + openCodeTimeoutMs: 5_000, +}; +``` + +Budget semantics: + +- candidate transcript refs are newest-first before capping; +- source segments are sorted by timestamp before final response; +- global merge keeps newest useful content if sources exceed budget; +- message budget is enforced before chunk build when one file/session is too large; +- chunk budget is enforced after chunk build by dropping oldest whole chunks/segments first; +- response includes `truncated: true` and warning `large_log_window_limited` when any cap is hit; +- renderer shows a short "showing recent log stream" note from warnings. +- content-char budgets are enforced before chunk build so a single huge markdown/tool result cannot freeze the popup after expansion. + +Why this is safer: + +- prevents popup freezes; +- avoids new virtualization bugs; +- keeps v2 focused on correct data/source behavior. + +Future: + +- add `Load older` only after source cursor semantics exist; +- add virtualization only if product needs audit-sized member streams in popup. + +## 0.2.1 Pair-Aware Truncation + +### Facts + +`ChunkBuilder` groups all "AI" category messages until the next real user/system/compact boundary. A single AI chunk can contain many assistant messages, tool calls and tool results. + +So this is not enough: + +```ts +maxChunks: 250 +``` + +One chunk can still become a large `displayItems` list in `MemberExecutionLog`. + +### Decision + +Add message-level budget: + +```ts +maxSourceMessages: 1200; +maxMessagesPerSegment: 300; +``` + +Truncation should happen in this order: + +1. Dedupe transcript refs. +2. Sort candidate refs newest-first and cap files. +3. Parse selected files. +4. For each file/session, trim parsed messages before chunk build if it exceeds `maxMessagesPerSegment`. +5. Build chunks. +6. Drop oldest whole segments/chunks to satisfy `maxSegments`, `maxChunks`, and `maxSourceMessages`. + +Pair-aware rule: + +- if retaining a meta user tool-result message with `sourceToolUseID`, also retain the assistant message containing the matching `toolCalls.id`; +- if retaining the matching assistant would exceed the hard message budget, drop the orphan result instead of rendering an unpaired result; +- preserve chronological order after expansion; +- mark response `truncated: true`; +- add warning `segment_message_window_limited`. + +Do not attempt text-level truncation inside `EnhancedChunk` in first PR. It is safer to drop old message windows than to mutate renderer-specific chunk internals. + +## 0.2.3 Content-Size Budget + +### Facts + +`MemberExecutionLog` renders AI groups expanded by default. `DisplayItemList` previews are short, but expanded items can render full markdown/tool result content. So these budgets are not sufficient by themselves: + +```ts +maxChunks: 250; +maxMessagesPerSegment: 300; +``` + +One tool result can still contain hundreds of thousands of characters. + +### Decision + +Add content budgets before chunk build: + +```ts +maxTotalContentChars: 800_000; +maxMessageContentChars: 80_000; +maxToolResultContentChars: 120_000; +``` + +Rules: + +- apply after message-window trimming and before `BoardTaskExactLogChunkBuilder.buildBundleChunks()`; +- preserve `uuid`, `sourceToolUseID`, `sourceToolAssistantUUID`, tool call ids and tool result ids; +- replace oversized text/content fields with a short placeholder that states the content was truncated for popup display; +- do not mutate file-backed parser cache arrays in place. Clone the affected `ParsedMessage` objects before truncating; +- set `truncated: true`; +- add warning `message_content_limited`. + +Risk rating: + +🎯 8 🛡️ 8 🧠 5 +Approx 120-220 LOC. + +This is safer than relying on UI collapse/expand behavior, because the current execution log starts expanded. + +## 0.2.4 ParsedMessage Hygiene Boundary + +### Facts + +`BoardTaskLogStreamService` already has useful private message hygiene helpers: + +- `cloneBlock()`; +- `cloneMessageContent()`; +- `mergeMessages()`; +- `pruneEmptyInternalToolResultMessages()`; +- `retainSyntheticToolUseAssistants()`. + +It also has task-specific cleanup: + +- `sanitizeJsonLikeToolResultPayloads()`; +- `sanitizeToolResultContent()`; +- `sanitizeToolResultPayloadValue()`. + +That task cleanup is not fully provider-neutral. It is designed around board/task tool payloads and can replace JSON-like tool result payloads with `''` when it cannot extract a board-tool display string. In member-wide logs, a JSON-looking tool result can be a legitimate Bash/API/tool output. Applying task cleanup wholesale would risk hiding useful member logs. + +### Options + +#### A. Reuse task sanitization as-is for member stream + +🎯 5 🛡️ 4 🧠 3 +Approx 20-80 LOC. + +Fast, but unsafe. It can hide useful non-board JSON outputs in a member-wide stream. + +#### B. Extract provider-neutral hygiene and add member-specific truncation + +🎯 8.5 🛡️ 9 🧠 6 +Approx 180-340 LOC. + +Create a small shared main-process helper, for example: + +```ts +src/main/services/team/taskLogs/stream/ParsedMessageStreamHygiene.ts +``` + +Provider-neutral exports: + +- clone message/content blocks without mutating parser-cache objects; +- prune empty internal tool-result messages; +- retain synthetic tool-use assistants by clearing only the synthetic model marker; +- count/truncate message content by char budget; +- preserve `uuid`, `toolCalls.id`, `toolResults.toolUseId`, `sourceToolUseID`, `sourceToolAssistantUUID` and `toolUseResult.toolUseId`. + +Keep board/task cleanup either private to `BoardTaskLogStreamService` or export it with a name that makes the scope explicit, such as `sanitizeBoardToolResultPayloads()`. + +#### C. Skip content hygiene and rely only on message-window limits + +🎯 6 🛡️ 5 🧠 2 +Approx 0-60 LOC. + +Too weak. One retained tool result can still be huge. + +### Decision + +Use option B. + +Implementation rules: + +- do not run board-specific JSON payload cleanup across all member logs; +- truncate oversized strings/arrays recursively but preserve linking ids; +- when truncating `toolUseResult.content`, `toolUseResult.message`, `toolUseResult.file.content`, `oldString`, `newString`, or `stderr/stdout`, keep the surrounding object shape; +- replace text with a compact placeholder that includes original char count and retained char count; +- clone only changed messages to keep memory reasonable; +- test with a JSON Bash output to confirm it is truncated if huge but not blanked just because it is JSON. + +Risk rating: + +🎯 8.5 🛡️ 9 🧠 6 +Approx 180-340 LOC. + +## 0.2.2 MemberExecutionLog Process Filtering + +### Facts + +`MemberExecutionLog` passes `memberName` into `AIExecutionGroup`. + +`AIExecutionGroup` filters only `group.processes` by `p.team?.memberName`; it does not filter the raw AI steps or tool executions. + +That means: + +- OpenCode projection segments with no `Process[]` still render normal tools/output; +- Claude chunks with team `Process[]` avoid showing another member's subagent panels; +- synthetic/fake `Process` objects are not required for member stream. + +### Decision + +Do not synthesize fake `Process` entries for OpenCode member stream. + +Render OpenCode through normal tool/output chunks from projected messages. Only use `Process[]` when real process metadata already exists. + +Add a renderer regression test that a segment with `actor.memberName` and no `processes` still displays tool/output items. + +## 0.3 IPC Validation And Limits + +### Facts + +Existing validators: + +- `validateTeamName` fits `teamName`; +- `validateMemberName` fits member names; +- `validateTaskId` is task-only; +- `laneId` can contain `:`, for example `secondary:opencode:alice`, so `validateMemberName` is wrong for lane. + +### Correct IPC Policy + +Add a local helper near the handler, or a shared guard if reused: + +```ts +export function validateOptionalRuntimeLaneId(value: unknown) { + if (value == null) return { valid: true, value: undefined }; + if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' }; + const trimmed = value.trim(); + if (!trimmed) return { valid: true, value: undefined }; + if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' }; + if (/[\0-\x1F\x7F/\\]/.test(trimmed)) { + return { valid: false, error: 'laneId contains invalid characters' }; + } + return { valid: true, value: trimmed }; +} +``` + +Options policy: + +- `limitSegments`: integer, clamp to `1..80`, default 30; +- `since`: if present, must parse as valid date, otherwise return IPC error; +- `laneId`: optional, max 256, no control chars or path separators, allow `primary` and colon-separated ids like `secondary:opencode:alice`; +- `forceRefresh`: optional boolean; +- unknown option keys rejected. + +Do not lowercase or otherwise normalize `laneId`. It should be trimmed only, because the orchestrator/runtime record lookup may treat lane ids as exact identities. + +Additional code research: `TEAM_GET_DATA` validates options with an allow-list and rejects unknown keys before dispatching to the service/worker. Member log stream should follow that stricter style because option typos can otherwise disable `laneId`, `since` or `forceRefresh` silently. + +Recommended allow-list: + +```ts +const allowed = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']); +``` + +Return `Unknown getMemberLogStream option: ${key}` for extra keys. + +Security: + +- do not expose transcript file paths in renderer response; +- warnings can mention counts and provider names, not absolute paths; +- service can log paths to main logger if needed. + +## 0.4 Exact Stream DTO And Renderer Contract + +### Facts + +`BoardTaskLogSegment` is already the right low-level render unit: + +```ts +interface BoardTaskLogSegment { + id: string; + participantKey: string; + actor: BoardTaskLogActor; + startTimestamp: string; + endTimestamp: string; + chunks: EnhancedChunk[]; +} +``` + +`MemberExecutionLog` only needs `EnhancedChunk[]` plus optional member visual props. It does not know task ids, provider ids, or source metadata. + +Additional type research: + +- `BoardTaskLogStreamResponse.source` is a task-only union: transcript, OpenCode task runtime fallback/attribution and Codex task trace fallback values. +- `BoardTaskLogStreamResponse.runtimeProjection` is also task-specific, with attribution/heuristic/trace counters that are not the right member coverage model. +- `BoardTaskLogSegment` has no `source` field, so it is safe as the render primitive but not enough as the member DTO by itself. +- Therefore `MemberLogStreamResponse` should be a standalone shared response type, not `extends BoardTaskLogStreamResponse` and not an `Omit` unless the omitted fields are fully replaced. + +Recommended DTO shape: + +```ts +type MemberLogStreamSource = + | 'member_transcript' + | 'member_mixed_runtime' + | 'member_runtime_only' + | 'member_empty'; + +interface MemberLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: 'all' | string; + segments: MemberLogStreamSegment[]; + source: MemberLogStreamSource; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + generatedAt: string; + metadata: MemberLogStreamMetadata; +} +``` + +This keeps task stream source semantics untouched and avoids having member-only source values leak into task UI copy or task tests. + +`TaskLogStreamSection` currently owns too much: + +- response normalization through `asEnhancedChunkArray`; +- participant visual mapping; +- segment key building; +- segment headers; +- participant chips; +- loading, empty and error copy; +- live reload behavior; +- task-specific `describeStreamSource()` text. + +### Decision + +Extract generic render-only view, not a generic data loader: + +```ts +interface ExecutionLogStreamViewProps { + title: string; + description: string; + stream: TStream | null; + loading: boolean; + error: string | null; + emptyTitle: string; + emptyDescription?: string; + teamName: string; + forceSegmentHeaders?: boolean; + boundedHistoryNote?: string | null; +} +``` + +Keep loaders separate: + +- `TaskLogStreamSection` loads task stream and keeps task-status reload behavior. +- `MemberLogStreamSection` loads member stream and passes `laneId`/budget options. + +This avoids accidentally bringing task-specific reload and copy into member popup. + +## 0.4.1 Member Segment Metadata + +### Facts + +`BoardTaskLogSegment` is enough for rendering chunks, but it does not identify provider source or safe session/lane labels: + +```ts +interface BoardTaskLogSegment { + id: string; + participantKey: string; + actor: BoardTaskLogActor; + startTimestamp: string; + endTimestamp: string; + chunks: EnhancedChunk[]; +} +``` + +For task stream this is acceptable because the whole section is task-scoped and source text is global. For member stream, multiple Claude sessions and OpenCode lane projection can appear in one popup. Without segment metadata, the UI either hides important context or parses `segment.id`, which is brittle. + +### Decision + +Use a member-specific extension: + +```ts +interface MemberLogStreamSegmentSource { + provider: MemberLogStreamProvider; + label: string; + sessionId?: string; + laneId?: string; + messageCount?: number; + truncated?: boolean; +} + +interface MemberLogStreamSegment extends BoardTaskLogSegment { + source: MemberLogStreamSegmentSource; +} +``` + +Then: + +- `MemberLogStreamResponse.segments` uses `MemberLogStreamSegment[]`; +- `MemberLogStreamResponse` stays standalone and does not extend `BoardTaskLogStreamResponse`; +- `ExecutionLogStreamView` remains generic over base `BoardTaskLogSegment`; +- `MemberLogStreamSection` can pass a segment label renderer that uses `segment.source`; +- task stream stays unchanged; +- segment source metadata never includes absolute file paths. +- segment ids use provider, normalized team/member, session id and a short hash/fingerprint, not raw absolute paths. + +Risk rating: + +🎯 8.5 🛡️ 8.5 🧠 4 +Approx 40-90 LOC. + +This is safer than overloading `segment.id` or adding provider labels into chunk text. + +Path rule: + +- hash `filePath + mtimeMs + sizeBytes` if a file fingerprint is needed; +- never send `filePath` to renderer in `id`, `source`, `warnings` or `metadata`; +- main logger may record file paths for diagnostics, but response DTO should not. + +## 0.4.2 Chunk Date Shape + +### Facts + +`MemberExecutionLog` calls `transformChunksToConversation()`, and `groupTransformer` uses `Date` methods on chunk and semantic-step timestamps. Existing task stream works because Electron IPC/preload returns structured-clone data and the service tests usually pass Date-shaped chunks. + +Additional code research: `src/renderer/api/httpClient.ts` already has an ISO date JSON reviver and comments that Electron IPC preserves `Date` instances via structured clone while HTTP JSON needs rehydration. So the runtime date risk is lower than expected for normal app and browser-mode API paths. + +The remaining risk is mostly tests and ad hoc fixtures. Renderer unit tests often use lightweight segment fixtures, and browser fallback has no real chunks. If the new generic view starts accepting JSON-like chunk fixtures with ISO strings without going through the HTTP client's reviver, it can fail in `durationMs = endTime.getTime() - startTime.getTime()`. + +### Decision + +Do not add broad date rehydration by default in the first implementation. Keep the same runtime assumption as task stream. + +But add a clear guardrail: + +- `ExecutionLogStreamView` tests should use Date-shaped `EnhancedChunk` fixtures when they expect real rendering; +- if a JSON-like fixture is needed, add one shared helper such as `normalizeEnhancedChunkDates()` and use it from both task/member stream normalization; +- do not scatter ad hoc `new Date()` calls across render components. + +Updated risk rating: + +🎯 9 🛡️ 8.5 🧠 3 +Approx 0-120 LOC depending on whether a normalizer is needed. + +This keeps variant 2 aligned with existing task stream behavior without hiding a serialization assumption. + +## 0.4.3 Renderer Normalization And Source Headers + +### Facts + +`TaskLogStreamSection.normalizeResponse()` currently reconstructs only known task-stream fields: + +```ts +return { + participants: response.participants, + defaultFilter: response.defaultFilter, + source: response.source, + runtimeProjection: response.runtimeProjection, + segments: ... +}; +``` + +If this exact function is reused for `MemberLogStreamResponse`, it can accidentally drop member-only fields: + +- `coverage`; +- `warnings`; +- `truncated`; +- `generatedAt`; +- `metadata`; +- `segment.source`. + +`TaskLogStreamSection` also hides segment headers when there is only one participant: + +```ts +participants.length > 1 || selectedParticipantKey !== 'all' +``` + +That is correct for task logs, but wrong for member logs. Member stream often has one participant, while the important context is per-segment provider/session/lane source. + +Additional UI-model research: `BoardTaskLogParticipant` is actor identity. The renderer uses participant labels for chips, member badges and color lookup. Provider/runtime/session identity is a different axis. If member stream encodes provider/session as participant keys, one selected member can appear as multiple people and the "All" filter becomes misleading. + +### Options + +#### A. Reuse task view logic as-is + +🎯 5 🛡️ 4 🧠 2 +Approx 40-100 LOC. + +Low effort, but it can hide provider/session labels and silently drop response metadata. + +#### B. Generic view preserves stream shape and accepts a segment-header renderer + +🎯 9 🛡️ 9 🧠 5 +Approx 120-240 LOC. + +The generic view should: + +- normalize chunks with `asEnhancedChunkArray`; +- preserve the rest of the stream object with object spread, including `coverage`, `warnings`, `metadata`, `truncated`, `generatedAt` and `segment.source`; +- let callers pass `forceSegmentHeaders`; +- let callers pass `renderSegmentMarker` or `getSegmentMetaLabel`; +- keep task copy/source text in `TaskLogStreamSection`; +- keep member provider/session labels in `MemberLogStreamSection`. +- not import `api.teams`, feature gates, `MemberLogsTab`, provider sources or task/member loading hooks; +- receive already loaded `teamMembers` from the container so the pure view can be tested without the global store; +- keep fallback UI outside the generic view, because fallback policy is different for task vs member. + +For member stream: + +- `forceSegmentHeaders: true`; +- label each segment with `segment.source.label`; +- optionally show safe `sessionId` short prefix or `laneId`, never file path; +- show bounded-history/coverage warnings outside the repeated segment blocks. +- keep `participantKey` actor-based, for example `member:`, and do not create `claude:`/`opencode:` pseudo-participants; +- if source filtering is needed later, add a separate source filter instead of overloading participant chips. + +Additional renderer key research: current `TaskLogStreamSection.buildStableSegmentRenderKey()` uses `participantKey:firstChunkId`, not `segment.id`. That preserves expanded/collapsed React state when a live refresh extends the tail of the same segment and changes the segment id. Existing tests cover this behavior. + +For member stream, the same default key can collide more easily because different providers/sessions can have one selected participant and similar first chunk ids. Do not change the task default blindly. + +Add a caller-provided key strategy: + +```ts +buildSegmentRenderKey?: (segment: TSegment) => string; +``` + +Rules: + +- task stream default keeps current `participantKey:firstChunkId` behavior; +- member stream passes a source-aware key such as `${segment.id}:${segment.chunks[0]?.id ?? segment.startTimestamp}`; +- `segment.id` must already be path-safe and stable across refreshes; +- tests should cover both task tail-growth state preservation and member source collision avoidance. + +#### C. Duplicate task stream UI into member component + +🎯 7 🛡️ 6 🧠 4 +Approx 180-320 LOC. + +Works quickly, but drift from task stream UI will grow and future variant 3 migration gets harder. + +### Decision + +Use option B. + +Risk rating: + +🎯 9 🛡️ 9 🧠 5 +Approx 160-300 LOC. + +## 0.4.4 Renderer Request Coalescing + +### Facts + +`TaskLogStreamSection` uses `requestSeqRef` to ignore stale responses. That prevents stale UI writes, but it does not prevent parallel IPC calls when visibility reload, `log-source-change`, and `task-log-change` happen close together. + +For task logs this is acceptable because the source is task-scoped. For member stream, one request can parse many files and may also call OpenCode runtime transcript. Parallel duplicate calls are a bigger performance risk. + +### Decision + +`MemberLogStreamSection` should coalesce active loads: + +- keep one active request for the current `teamName/memberName/laneId/options` key; +- if a background reload is requested while active, set a pending reload flag instead of starting a second IPC call; +- when the active request finishes, run at most one pending background reload; +- if the component unmounts or key changes, ignore old results and clear pending reload state; +- initial foreground load still shows loading, background reload keeps old stream visible. + +This complements backend/source in-flight protection. It is not a replacement for it, because multiple windows or tests can still call IPC directly. + +Risk rating: + +🎯 8.5 🛡️ 8.5 🧠 5 +Approx 80-160 LOC with tests. + +## 0.4.4.1 Since And Full-Response Replacement + +### Facts + +`getMemberLogStream` options include `since` as a useful backend performance hint. But the renderer state model is full response replacement: `setStream(response)`. A since-filtered response is partial unless the API explicitly says it includes enough prior segments to replace the visible stream. + +### Decision + +First PR should not do client-side incremental merge. + +- Initial load requests a full bounded stream. +- Background reload requests a full bounded stream too. +- `log-source-change` background reload adds `forceRefresh: true`, but not `since`. +- `since` remains validated at IPC and covered in service tests, but UI should not use it for replacement reloads until there is an explicit merge contract. +- If incremental reload is added later, response metadata must say whether it is `complete` or `partial`, and renderer must merge by source-aware segment id. + +Risk rating: + +🎯 8.5 🛡️ 9 🧠 3 +Approx 0-60 LOC, mostly tests and avoiding a tempting optimization. + +This prevents a subtle UI regression where fresh logs appear, but older visible segments disappear after a background reload. + +## 0.4.5 Member Popup Fallback Boundary + +### Facts + +`MemberDetailDialog` currently renders old logs directly: + +```tsx + +``` + +The same old `MemberLogsTab` is also used by task logs through `ExecutionSessionsSection`, where it is labeled as legacy session-centric transcript browsing. That means changing `MemberLogsTab` itself is not a member-popup-only change. + +Existing dialog tests live at: + +- `test/renderer/components/team/members/MemberDetailDialog.test.ts` + +That test file mocks `MemberLogsTab`, so replacing the import without updating the mock can cause noisy test failures that are unrelated to the stream logic. + +### Options + +#### A. Replace `MemberLogsTab` internals with new stream + +🎯 5 🛡️ 5 🧠 4 +Approx 120-260 LOC. + +This looks small, but it affects task `Execution Sessions` too. It also removes the clean rollback path. + +#### B. Gate at `MemberDetailDialog` and keep `MemberLogsTab` untouched + +🎯 9 🛡️ 9 🧠 4 +Approx 80-180 LOC. + +This is the safest first implementation: + +- renderer feature gate on: render `MemberLogStreamSection`; +- renderer feature gate off: render old `MemberLogsTab`; +- initial stream error: show error and explicit old logs fallback; +- background refresh error: keep last good stream and surface the error state unobtrusively. + +Task `Execution Sessions` remains untouched because it still imports `MemberLogsTab` directly. + +#### C. Let `MemberLogStreamSection` import and own `MemberLogsTab` + +🎯 6 🛡️ 6 🧠 5 +Approx 100-220 LOC. + +This hides fallback inside the new component, but makes the boundary muddier. It also makes it easier to accidentally couple legacy UI copy and new stream copy. + +### Decision + +Use option B. + +Implementation guardrails: + +- keep fallback decision in `MemberDetailDialog`, not inside the stream renderer; +- do not change `MemberLogsTab` behavior in the first PR; +- add a `MemberLogStreamSection` mock in `MemberDetailDialog` tests; +- test gate-on and gate-off rendering; +- test that first-load stream failure keeps the Logs tab active and shows explicit old logs fallback; +- keep `ExecutionSessionsSection` expectations unchanged. + +Risk rating: + +🎯 9 🛡️ 9 🧠 4 +Approx 80-180 LOC. + +This reduces the highest accidental renderer regression risk without changing the backend design. + +## 0.5 Cumulative Subagent Snapshot Dedupe + +### Facts + +`findLogsForTask()` already has this warning in code: + +- in-process teammates can produce cumulative JSONL snapshots; +- the largest file is a superset of smaller files; +- task flow dedupes subagent snapshots by `sessionId + memberName` and keeps the largest `messageCount`. + +But `findRecentMemberLogFileRefsByMember()` currently returns: + +```ts +interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; +} +``` + +It dedupes only exact `filePath`. That is not enough for member stream, because parsing multiple cumulative snapshots can duplicate the same turn several times. + +Another discovered edge case: attribution uses `knownMembers`, built from current config, member meta and inbox names. If the popup is opened for a historical/removed member that is no longer present in those sources, syntax-only IPC validation still will not make attribution recognize that name. + +### Options + +#### A. Use refs as-is and rely on chunk/message ids + +🎯 5 🛡️ 5 🧠 3 +Approx 0-80 LOC. + +Low implementation cost, but duplicate stream entries are likely in cumulative snapshot cases. + +#### B. Extend `MemberLogFileRef` with optional metadata and dedupe before parse + +🎯 8 🛡️ 8 🧠 5 +Approx 120-220 LOC. + +Add optional fields without breaking existing callers: + +```ts +interface MemberLogFileRef { + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + sizeBytes?: number; + messageCount?: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; +} +``` + +Then `ClaudeMemberTranscriptStreamSource` can: + +- group subagent refs by `memberName + sessionId`; +- keep largest `messageCount` when available, otherwise largest `sizeBytes`, tie-break by newest `mtimeMs`; +- keep root member session refs separately; +- cap after dedupe, not before. + +Do not stream every candidate only to compute `messageCount` in first PR. Use it when a caller already has it; otherwise `sizeBytes` is the cheap proxy for cumulative JSONL snapshots. + +Also change `findRecentMemberLogFileRefsByMember()` so it adds requested member names to the attribution set before calling `getCachedSubagentAttribution()` / `getCachedMemberSessionAttribution()`. + +Suggested shape: + +```ts +const attributionMembers = new Set(knownMembers); +for (const key of requestedMembersByKey.keys()) { + attributionMembers.add(key); +} +``` + +Use `attributionMembers` only for attribution. Keep the returned `memberName` as the caller's requested casing from `requestedMembersByKey`. + +#### C. Parse all refs, then dedupe by message uuid + +🎯 6 🛡️ 7 🧠 7 +Approx 180-350 LOC. + +More robust when metadata is missing, but expensive and can still miss duplicates if synthetic/tool result uuids differ. + +### Recommendation + +Use B. + +This is the most important correction to the earlier plan. Without it, variant 2 can be correct by attribution but noisy by duplication. + +Add the requested-member attribution augmentation to B. It is small, but it is what makes removed-member popup logs actually possible instead of only syntactically allowed. + +## 0.6 OpenCode Projection Mapper Boundary + +### Facts + +`ClaudeMultimodelBridgeService.getOpenCodeTranscript()` returns `transcript.logProjection.messages`. + +`OpenCodeTaskLogStreamSource` already has working private mapping from `OpenCodeRuntimeTranscriptLogMessage` to `ParsedMessage`: + +- content block conversion; +- tool call/result conversion; +- `sourceToolUseID`; +- `sourceToolAssistantUUID`; +- `toolUseResult`; +- sanitized text content. + +There is also a separate `toParsedMessage()` in `OpenCodeTaskStallEvidenceSource`, but it is not equivalent. It maps non-string content to `[]` and does not build `toolUseResult`. That is acceptable for stall evidence rows, but not for a user-visible log stream. + +But that file also contains task-specific logic: + +- task marker matching; +- task windows; +- attribution records; +- foreign team marker filtering; +- task fallback heuristics. + +### Options + +#### A. Copy the private mapper into member source + +🎯 7 🛡️ 6 🧠 4 +Approx 150-220 LOC. + +Fast, but mapper drift is likely. + +#### B. Extract generic mapper and make both task/member sources use it + +🎯 8.5 🛡️ 8.5 🧠 6 +Approx 180-300 LOC. + +Create a small file near stream sources, for example: + +```ts +// src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts +export function mapOpenCodeProjectionMessagesToParsedMessages( + messages: OpenCodeRuntimeTranscriptLogMessage[] +): ParsedMessage[]; + +export function countProjectionToolCalls(messages: ParsedMessage[]): ProjectionToolCounts; +``` + +Only move generic projection conversion. Do not move task marker/window logic. + +Extraction source must be `OpenCodeTaskLogStreamSource`, not `OpenCodeTaskStallEvidenceSource`. + +Implementation detail: + +- first move the task-source mapper to the shared file without behavior changes; +- update `OpenCodeTaskLogStreamSource` to call the shared mapper; +- then let `OpenCodeMemberRuntimeStreamSource` call the same mapper; +- leave stall monitor as-is unless a separate test-backed cleanup proves identical behavior is intended there. + +#### C. Reuse `OpenCodeTaskLogStreamSource` + +🎯 3 🛡️ 3 🧠 4 +Approx 80-160 LOC. + +Wrong abstraction. It is task-scoped by design. + +### Recommendation + +Use B. This is a small refactor, but it reduces long-term drift and gives member stream the exact same OpenCode rendering semantics as task stream. + +Important: do not use the stall-monitor mapper as the shared base. That would make the implementation look shared while silently degrading member stream content rendering. + +## 0.6.1 OpenCode Runtime Call Budget + +### Facts + +`OpenCodeTaskLogStreamSource` already protects task stream fallback with: + +- a small cache keyed by task/window/attribution; +- an `inFlight` map so concurrent calls join the same bridge request; +- `CACHE_TTL_MS = 1_500`; +- transcript limits of `200` for heuristic and `500` for attributed mode. + +Member stream will reload on same-team `log-source-change` and `task-log-change`. Even with debounce, those events can arrive close together while a popup is open. Calling `runtime transcript` for every refresh can make the popup feel slow and can add unnecessary process churn. + +### Options + +#### A. No OpenCode member-source cache + +🎯 7 🛡️ 6 🧠 2 +Approx 0-40 LOC. + +Simple, but repeated refreshes can spawn repeated bridge calls. Timeout protects the popup from hanging, but not from extra work. + +#### B. Add member-source TTL cache and in-flight join + +🎯 8.5 🛡️ 8.5 🧠 4 +Approx 80-160 LOC. + +Use a cache key like: + +```txt +teamName::memberName::laneId-or-none::limit +``` + +Rules: + +- default TTL `1_500ms`, matching task OpenCode fallback; +- `forceRefresh` can bypass completed cache, but should still join an existing in-flight request for the same key; +- cache both `null` and successful responses briefly, because repeated failures should not spawn repeated CLI calls; +- do not share this cache with `OpenCodeTaskLogStreamSource`; +- keep timeout handling inside the bridge/source, not renderer. + +#### C. Add a long cache in `ClaudeMultimodelBridgeService` + +🎯 5 🛡️ 5 🧠 6 +Approx 120-240 LOC. + +This centralizes caching but risks changing behavior for status/stall/task callers that use the same bridge for different freshness expectations. + +### Decision + +Use option B. + +This keeps OpenCode member stream responsive without changing bridge semantics for other callers. + +Risk rating: + +🎯 8.5 🛡️ 8.5 🧠 4 +Approx 80-160 LOC. + +## 0.7 Live Refresh Policy + +### Facts + +`TeamChangeEvent` has: + +- `log-source-change` without task id; +- `task-log-change` with `taskId`, but no `memberName`; +- `tool-activity`, which can be frequent and is intended for live tool indicators; +- no event that means "logs for member X changed". + +Task stream can reload only for one `taskId`. Member stream cannot do that safely. + +Additional code research: task logs do not rely on `onTeamChange` subscription alone. + +- `TaskLogStreamSection` listens to `onTeamChange` and schedules reloads. +- `TaskLogsPanel` separately calls `api.teams.setTaskLogStreamTracking(teamName, true)` while task log activity tracking is relevant. +- That IPC maps to `TeamLogSourceTracker.enableTracking(teamName, 'task_log_stream')`. +- `TeamLogSourceTracker` only watches transcript/log freshness sources while at least one consumer is active. +- Member popup has no `TaskLogsPanel` parent, so just adding `onTeamChange` in `MemberLogStreamSection` can miss fresh events when no other UI has enabled tracking. + +### Decision + +For an open member popup: + +- enable log-source tracking while the stream section is mounted; +- reload on same-team `log-source-change`; +- pass `forceRefresh: true` for `log-source-change`, because `TeamMemberLogsFinder` discovery cache has a 30s TTL; +- do not pass `since` from renderer for replacement reloads in the first PR; +- reload on same-team `task-log-change`, because it is still a log-source freshness signal; +- do not reload on `tool-activity`; +- debounce at least as strongly as task stream, preferably 500-750ms for member stream; +- do not clear existing stream on background refresh failure; +- reload on visibility return only if popup is still open. + +Risk rating: + +🎯 8 🛡️ 8 🧠 4 +Approx 40-80 LOC. + +This may reload for tasks owned by other members, but only while the member popup is open and with debounce. That is safer than missing fresh logs because `task-log-change` lacks member attribution. + +### Tracking Activation Options + +#### A. Reuse `setTaskLogStreamTracking()` from member popup + +🎯 8 🛡️ 7 🧠 2 +Approx 20-50 LOC. + +This works because it increments the same `task_log_stream` consumer count. The downside is semantic drift: member UI would call a task-named API. + +#### B. Add `setMemberLogStreamTracking()` mapped to a new tracker consumer + +🎯 8.5 🛡️ 9 🧠 4 +Approx 90-170 LOC. + +Add: + +- feature `MEMBER_LOG_STREAM_SET_TRACKING` channel; +- feature API method or thin compatibility delegate; +- feature preload bridge and browser no-op fallback; +- `TeamLogSourceTrackingConsumer | 'member_log_stream'`; +- IPC handler that calls `TeamLogSourceTracker.enableTracking(teamName, 'member_log_stream')`. + +This keeps UI language correct and lets task/member stream lifecycles have separate consumer counts while sharing the same underlying watcher. + +Important implementation detail from code: + +- `TeamLogSourceTracker` consumer counts are per team and consumer name, not per member. +- That is correct for member stream because the watcher scope is team/session-level. +- Do not add one watcher per member popup. Add one `member_log_stream` consumer and rely on reference counts for multiple popups. +- If the component remounts with a different `teamName`, cleanup must disable the previous team before enabling the next team. +- Disabling `member_log_stream` must not close the watcher while `task_log_stream`, `change_presence`, `tool_activity` or `stall_monitor` still has a positive count. + +#### C. Do not enable tracking from member popup + +🎯 5 🛡️ 4 🧠 1 +Approx 0 LOC. + +This is unreliable. It only works when another part of the UI has already enabled log-source tracking. + +### Tracking Decision + +Use B. + +It adds a little transport work, but it removes a hidden dependency on `TaskLogsPanel` and avoids calling task-named APIs from member logs. + +## 0.8 IPC And Composition Integration + +### Facts + +`src/main/ipc/teams.ts` currently has many positional dependencies and already owns legacy team APIs. That is exactly why member log stream should not be added there as another owned service. + +Current legacy facts still matter: + +- `src/main/ipc/teams.ts` owns existing team handlers; +- task stream still uses `TEAM_GET_TASK_LOG_STREAM`; +- `src/preload/index.ts` and `src/renderer/api/httpClient.ts` already expose legacy team API fallbacks; +- `src/main/index.ts` is the composition point where feature facades can be instantiated. + +### Options + +#### A. Feature-owned IPC and composition + +🎯 8.5 🛡️ 9 🧠 4 +Approx 80-180 LOC for transport wiring. + +This is the correct first PR choice after adopting the canonical feature slice. + +Implementation rule: + +```ts +const memberLogStreamFeature = createMemberLogStreamFeature({ + logsFinder, + logSourceTracker, + runtimeBridge, + logger, +}); + +registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); +``` + +Feature-owned files: + +- `src/features/member-log-stream/contracts/channels.ts`; +- `src/features/member-log-stream/contracts/api.ts`; +- `src/features/member-log-stream/preload/createMemberLogStreamBridge.ts`; +- `src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts`; +- `src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts`. + +Test rule: + +- feature IPC registration test expects feature channel registration/removal; +- app-shell integration test imports only `@features/member-log-stream/main`; +- existing task stream handler tests remain unchanged and prove legacy team IPC was not disturbed. + +#### B. Add member stream into `initializeTeamHandlers()` + +🎯 5 🛡️ 4 🧠 2 +Approx 30-70 LOC. + +This is no longer the recommended path. It is locally cheap, but it violates feature ownership and risks shifting positional dependencies in `initializeTeamHandlers()`. + +Do not choose this. + +#### C. Convert legacy team initializer to a dependency object + +🎯 7 🛡️ 9 🧠 7 +Approx 250-450 LOC. + +This is a useful legacy IPC hygiene refactor, but it is separate from member log stream. It should not be bundled unless the PR explicitly pays that cost. + +### Required Feature Handler Contract + +Add feature-owned channels, for example: + +```ts +export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; +export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; +``` + +Add register/remove symmetry in feature input adapter: + +```ts +ipcMain.handle(MEMBER_LOG_STREAM_GET, handleGetMemberLogStream); +ipcMain.handle(MEMBER_LOG_STREAM_SET_TRACKING, handleSetMemberLogStreamTracking); +ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); +ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); +``` + +Add feature API and preload shape: + +```ts +getMemberLogStream( + teamName: string, + memberName: string, + options?: { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; + } +): Promise +``` + +Handler behavior: + +- validate `teamName` with `validateTeamName`; +- validate `memberName` with `validateMemberName`, but do not require current team membership; +- validate `laneId` with the new runtime-lane validator, not with `validateMemberName`; +- clamp `limitSegments` before passing options to service; +- reject invalid `since`; +- validate `forceRefresh` as boolean when present; +- reject unknown option keys; +- call the feature facade/use case with normalized options. + +Validator placement note: + +- `src/main/ipc/guards.ts` keeps `ValidationResult` local today; +- safest implementation is to export concrete helpers like `validateOptionalRuntimeLaneId()` and `validateOptionalBooleanOption()` from `guards.ts`; +- if validators stay local in the feature input adapter, do not import a non-exported `ValidationResult` type. + +### Browser Fallback + +The renderer API/browser fallback must satisfy the feature API even when Electron IPC is unavailable. If `api.teams` compatibility delegates are kept, they must delegate to the same fallback shape. + +Return a complete empty response: + +```ts +{ + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: new Date().toISOString(), + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, +} +``` + +Do not throw in browser fallback. Throwing would make renderer code need an Electron-only branch even though task stream already uses a safe empty fallback. + +### Recommendation + +Use option A in the implementation PR. + +🛡️ The main bug risk is not the new stream logic here. It is accidentally pulling the feature back into legacy `team` IPC/service ownership and weakening the feature boundary. + +## 1. Claude Transcript Attribution + +### Facts + +`TeamMemberLogsFinder` уже содержит лучший source для member stream: + +```ts +findRecentMemberLogFileRefsByMember( + teamName: string, + memberNames: readonly string[], + mtimeSinceMs?: number | null +): Promise +``` + +Дополнительный compatibility факт: этот method уже используется не только будущим member stream. + +- `TeamMemberRuntimeAdvisoryService` вызывает его с третьим positional numeric arg. +- Existing live tests вызывают его с `null` и numeric `mtimeSinceMs`. +- `discoverProjectSessions(teamName, { forceRefresh })` уже поддерживает cache bypass. +- `getLogSourceWatchContext()` и `getLiveLogSourceWatchContext()` уже передают `forceRefresh` в discovery path. +- Current method calls `discoverProjectSessions(teamName)` without options, so member stream needs object-form options only to add `forceRefresh`. +- Current lead transcript branch pushes the lead ref after `fs.stat()` but before any `mtimeSinceMs` check. +- Current refs have only `memberName`, `sessionId`, `filePath` and `mtimeMs`. + +Значит для member stream нельзя делать breaking object-only замену третьего аргумента. + +Нужен backward-compatible options parser: + +```ts +type FindRecentMemberLogFileRefsOptions = + | number + | null + | { + mtimeSinceMs?: number | null; + forceRefresh?: boolean; + }; +``` + +Implementation rule: + +- numeric third arg остается `mtimeSinceMs`; +- `null` остается "без mtime window"; +- object third arg включает `mtimeSinceMs` и `forceRefresh`; +- only object form can bypass discovery cache; +- existing advisory callers do not need changes. + +Он возвращает: + +```ts +{ + memberName: string; + sessionId: string; + filePath: string; + mtimeMs: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; + sizeBytes?: number; + messageCount?: number; +} +``` + +Metadata rule: + +- `kind` and `sizeBytes` are cheap and should be added by the finder; +- `messageCount` is optional and should be filled only when existing attribution/session metadata already knows it; +- do not parse full JSONL files in `TeamMemberLogsFinder` just to compute `messageCount`, because `ClaudeMemberTranscriptStreamSource` will strict-parse the selected refs afterward. + +Почему это лучше, чем `findMemberLogPaths()`: + +- уже сортирует refs по `mtimeMs desc`; +- дедуплицирует `filePath`; +- возвращает `sessionId`, который нужен для stable segment id; +- умеет lead transcript; +- использует ту же attribution precedence, что и старые member logs; +- принимает `mtimeSinceMs`, что дает cheap performance window. + +### Correct Implementation + +🎯 9 🛡️ 9 🧠 4 +Примерно 120-220 LOC для Claude-only member stream service. + +Service должен: + +1. Вызывать `findRecentMemberLogFileRefsByMember(teamName, [memberName], { mtimeSinceMs: sinceMs, forceRefresh })`. +2. Dedupe cumulative subagent refs before cap, using `kind + memberName + sessionId`, then `messageCount`, `sizeBytes`, `mtimeMs`. +3. Сразу применять cap по ref count после dedupe: default `maxTranscriptFiles = 40`. +4. Парсить только capped refs через отдельный `BoardTaskExactLogStrictParser`. +5. Для каждого ref строить отдельный segment. +6. Ограничивать total chunks глобальным budget, default `maxChunks = 250`. +7. Сортировать response segments по `startTimestamp asc`, чтобы renderer мог reverse как task stream. +8. Возвращать warning `large_log_window_limited`, если refs/chunks больше cap. + +### Risks + +- Если member был переименован, finder ищет только переданное имя. +- Если `knownMembers` не содержит historical removed member, attribution может не найти старые logs. +- Если заменить third arg object-only, можно сломать runtime advisory и live tests, которые уже передают numeric/null. +- В текущем коде lead transcript добавляется до candidate scan и не проходит `mtimeSinceMs` filter. +- Если finder начнет считать `messageCount` через full parse, он удвоит IO и сделает popup тяжелее на больших историях. +- Text mention остается low-confidence fallback. Это уже существующий риск, не новый. + +### Mitigation + +- Не требовать current config membership в IPC handler. +- В plan добавить future alias map, но не делать в v2. +- В coverage явно писать `claude_transcript included/partial`. +- Расширять third arg finder-а через compatibility parser, а не менять его на object-only. +- При object/numeric `mtimeSinceMs` применять time window и к lead transcript, и к member/subagent candidates. +- Если lead transcript старше `mtimeSinceMs`, не возвращать его в recent refs. +- Добавить tests на numeric, `null` и object options формы. +- Добавить tests, что `forceRefresh` передается в `discoverProjectSessions()` только через object form. +- Добавить tests, что optional metadata не ломает existing advisory callers и не требует full parse внутри finder. + +## 2. OpenCode Lane Resolution + +### Facts + +В репо уже есть deterministic lane identity: + +```ts +buildPlannedMemberLaneIdentity({ + leadProviderId, + member, +}) +``` + +Для mixed team с non-OpenCode lead и OpenCode member lane становится: + +```txt +secondary:opencode: +``` + +`TeamMemberResolver` уже кладет в `TeamMemberSnapshot`: + +- `laneId`; +- `laneKind`; +- `laneOwnerProviderId`. + +Renderer получает resolved member через spread snapshot: + +```ts +return { + ...snapshot, + status, + messageCount, + lastActiveAt, +} +``` + +Но `ResolvedTeamMember` type сейчас явно не содержит runtime/lane fields, хотя фактический объект их несет. Это хрупко для новой реализации. + +Дополнительный code research: + +- `src/shared/types/team.ts` объявляет `TeamMemberSnapshot.laneId/laneKind/laneOwnerProviderId`; +- `src/shared/types/team.ts` также объявляет `TeamMemberSnapshot.providerBackendId/selectedFastMode/resolvedFastMode`; +- `src/shared/types/team.ts` не объявляет эти поля на `ResolvedTeamMember`; +- `src/renderer/store/slices/teamSlice.ts` делает `return { ...snapshot, status, messageCount, lastActiveAt }`, поэтому runtime object уже несет lane fields; +- renderer code уже обращается к lane fields в некоторых utility paths, но без полноценного `ResolvedTeamMember` contract это легко превращается в касты и drift. + +### Correct Implementation + +🎯 8.5 🛡️ 9 🧠 4 +Примерно 180-320 LOC. + +Нужно сделать так: + +1. Расширить `ResolvedTeamMember` type полями: + - `providerBackendId?: TeamProviderBackendId`; + - `selectedFastMode?: TeamFastMode`; + - `resolvedFastMode?: boolean`; + - `laneId?: string`; + - `laneKind?: 'primary' | 'secondary'`; + - `laneOwnerProviderId?: TeamProviderId`. + Data mapping almost does not change, because `buildResolvedMember()` already spreads `snapshot`. +2. Расширить `getMemberLogStream` options: + +```ts +{ + limitSegments?: number; + since?: string; + laneId?: string; +} +``` + +3. В `MemberLogStreamSection` передавать `member.laneId`, если: + - `member.providerId === 'opencode'`; + - `member.laneOwnerProviderId === 'opencode'`; + - `member.laneId` непустой. + Не передавать `laneId` для non-OpenCode members, даже если в старом snapshot случайно остался lane-like field. +4. В `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` добавить optional `laneId`. +5. В тот же params добавить optional `timeoutMs`, чтобы member popup не зависал на provider probe path. +6. Bridge должен append: + +```ts +if (params.laneId?.trim()) { + args.push('--lane', params.laneId.trim()); +} +``` + +7. Если `laneId` неизвестен, можно сделать best-effort no-lane call, но только с catch ambiguity error. +8. Ambiguity превращать в warning `opencode_ambiguous_lane`, не в hard failure. +9. Timeout/runtime missing превращать в `opencode_runtime_timeout` или `opencode_runtime_unavailable`, не в hard failure. +10. Add a small member-source TTL cache and in-flight join around OpenCode runtime transcript calls. + +### Why This Is Safer + +Orchestrator уже защищает от unsafe resolution: + +```txt +Multiple OpenCode session records exist ... pass --lane to select one +``` + +Desktop должен уважать это, а не обходить. + +### OpenCode Member Stream Semantics + +Для member stream нельзя использовать task-specific logic из `OpenCodeTaskLogStreamSource`: + +- не применять task marker spans; +- не применять task work intervals; +- не фильтровать по task owner; +- не читать task attribution records как основной source. + +Правильный member OpenCode segment: + +- получить projection messages за выбранный member/lane; +- cap по `limit`, default `openCodeMessageLimit = 400`; +- cap по времени, default `openCodeTimeoutMs = 5_000`, потому что текущий bridge path использует `execCli` timeout and can otherwise delay the popup; +- build chunks через `BoardTaskExactLogChunkBuilder`; +- segment id включает `teamName`, `laneId`, `memberName`, `sessionId`; +- source coverage: `opencode_runtime included`; +- если projection пустой: `opencode_runtime skipped`. + +Implementation detail: + +- не использовать `OpenCodeTaskLogStreamSource` напрямую, потому что он task-specific; +- вынести reusable projection-to-ParsedMessage helpers из `OpenCodeTaskLogStreamSource` в small mapper, если без копипаста не обойтись; +- покрыть старый task source тестами после extraction, чтобы не сломать task stream. + +### Risks + +- `ResolvedTeamMember` type drift может скрыть runtime/lane fields от TS. +- Старые teams могут не иметь lane metadata. +- OpenCode primary-only teams могут использовать `laneId: primary`. +- Runtime transcript может быть stale или пустым. + +### Mitigation + +- API принимает laneId от renderer, но main может recompute fallback lane identity later. +- Для v2 достаточно renderer laneId + safe no-lane fallback. +- Не merge multiple lanes. +- Не показывать OpenCode как complete, если он skipped/partial. + +## 3. Codex Native Member-wide Feasibility + +### Facts + +`CodexNativeTraceReader` хранит traces здесь: + +```txt +.member-work-sync/runtime-hooks/codex-native-traces/ + processed///*.jsonl + incoming///*.jsonl.tmp +``` + +Header содержит: + +- `teamName`; +- `taskId`; +- `ownerName`; +- `runId`; +- `cwd`; +- `startedAt`. + +Значит member filtering по `ownerName` технически возможен. + +Но текущий API: + +```ts +readTaskRuns({ teamName, taskIds, includeIncoming }) +``` + +task-first. Он не умеет: + +- перечислить все task dirs для team; +- выбрать последние N runs по ownerName; +- ограничить scan без taskId; +- возвращать member-wide coverage. + +Current implementation detail: + +- It only lists `processed//` and optional `incoming//`. +- It caps latest candidates after task-dir collection, currently to 10 files. +- It dedupes by `team/task/runId`, preferring non-partial/newer candidates. +- That strategy is safe for a known task id, but not enough for member-wide history because there is no bounded owner index. + +Еще важнее: `CodexNativeTraceProjector` проектирует только native tool events, а не полный разговор Codex. То есть даже если сделать member-wide reader, это будет "Codex native tool trace", а не полный Codex log stream. + +### Options + +#### A. Keep Codex skipped in first implementation + +🎯 9 🛡️ 9 🧠 2 +Примерно 20-40 LOC для coverage warning. + +Плюсы: + +- самый надежный первый релиз; +- нет риска тяжелого scan по trace tree; +- честное product обещание. + +Минусы: + +- Codex участники не получат новый native trace в member popup. + +#### B. Add partial Codex native tool trace as phase 2 + +🎯 7 🛡️ 7 🧠 7 +Примерно 250-450 LOC. + +Новый метод: + +```ts +readMemberRuns({ + teamName, + ownerName, + includeIncoming, + limitRuns, + maxTaskDirs, +}) +``` + +Алгоритм: + +1. Resolve trace root. +2. List `processed/` task dirs and optional `incoming/` task dirs. +3. Collect jsonl/jsonl.tmp candidates with stat. +4. Sort by `mtimeMs desc`. +5. Cap candidates before full parse. +6. Parse headers/runs. +7. Filter normalized `ownerName`. +8. Deduplicate by `team/task/runId`, prefer non-partial and newer mtime. +9. Project via `CodexNativeTraceProjector`. +10. Return segment with source label `codex_native_trace_partial`. + +Additional required caps: + +- `maxTaskDirs` before candidate collection; +- `maxTraceCandidates` before full parse; +- `maxTraceRuns` after owner filtering/dedupe; +- default `includeIncoming: false` unless the member is currently active or the UI explicitly requests live partial files. + +Минусы: + +- scanning all task dirs может быть тяжелым; +- trace может быть native-tool-only и выглядеть неполным; +- incoming partial files требуют аккуратного JSONL handling. +- scanning by owner without an index can become O(number of tasks), so it should not ride inside the first PR. + +#### C. Full Codex member-wide log support + +🎯 5 🛡️ 5 🧠 9 +Примерно 700-1200+ LOC. + +Нужно строить отдельный index по Codex runtime/session events, не только native tool traces. Это уже вариант 3. + +### Recommendation + +Для варианта 2: + +- first PR: Codex skipped with explicit `codex_member_wide_not_supported`; +- optional second PR: partial native tool trace with honest UI label; +- do not present Codex native trace as full logs. + +## 4. Parser Cache Safety + +### Facts + +`BoardTaskExactLogStrictParser.parseFiles()` делает: + +```ts +this.cache.retainOnly(new Set(uniquePaths)); +``` + +`BoardTaskExactLogsParseCache` delegates to `BoardTaskActivityParseCache`, whose `retainOnly()` removes both: + +- parsed cache entries; +- active `inFlight` parse promises outside the retained file set. + +Это значит: если один service instance парсит task files, а другой потом member files на том же parser instance, второй вызов может вычистить не только cache первого, но и его active parse dedupe. В худшем случае это не ломает correctness напрямую, но создает duplicate reads, timing-sensitive cache churn и flaky performance под параллельными reload. + +### Correct Implementation + +🎯 9 🛡️ 9 🧠 3 +Примерно 20-60 LOC. + +Для v2: + +- `ClaudeMemberTranscriptStreamSource` получает собственный `BoardTaskExactLogStrictParser`. +- Не шарить parser instance с `BoardTaskLogStreamService`. +- Не инжектить task-stream parser в тестах как "удобный shared mock", потому что это маскирует ownership rule. +- Если понадобится общий parser, сначала redesign cache API: `retainOnly()` должен стать owner-scoped или LRU-based. + +Future improvement: + +- заменить `retainOnly()` на bounded LRU cache; +- либо добавить namespace/owner key для task/member cache ownership; +- тогда task/member services смогут безопасно шарить parser. + +Но это не нужно для первого релиза. + +## 5. API Shape Refinement + +### Previous Draft + +Изначально было достаточно: + +```ts +getMemberLogStream(teamName, memberName, options?) +``` + +### Updated API + +После research лучше зафиксировать: + +```ts +getMemberLogStream( + teamName: string, + memberName: string, + options?: { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; + } +): Promise +``` + +Почему `laneId` в options: + +- renderer уже знает выбранного member object; +- это самый дешевый и точный path для OpenCode secondary lanes; +- main все равно валидирует strings; +- если поля нет, main может fallback to no-lane best-effort. + +Почему `forceRefresh` в options: + +- `discoverProjectSessions()` кэширует discovery на 30 секунд; +- same-team `log-source-change` означает, что session ids/source context could have changed; +- renderer can pass `forceRefresh: true` only for that event path; +- regular task-log-change reloads can keep the cache. + +Почему не передавать весь member: + +- меньше IPC surface; +- меньше drift между renderer snapshot и main truth; +- no need to trust UI for provider selection. + +## 6. Correct First Implementation Sequence + +Updated safest order: + +1. Create `src/features/member-log-stream` with contracts, core/domain, core/application, main composition, preload bridge and renderer public entrypoints. +2. Add feature-owned `MemberLogStreamResponse`, `MemberLogStreamSegment.source`, response metadata, warnings and `ResolvedTeamMember` runtime/lane fields. Do not extend or broaden `BoardTaskLogStreamResponse.source`. +3. Add feature IPC channels, feature preload methods, browser fallback and optional compatibility methods on `api.teams` for `getMemberLogStream` plus `setMemberLogStreamTracking`. +4. Add IPC validation helpers in the feature input adapter or shared guards, including exact-preserving optional `laneId` validation. Register feature handlers append-only without shifting existing `initializeTeamHandlers()` positional dependencies. +5. Add core source-port interfaces and budget constants. +6. Extend `MemberLogFileRef` with optional `kind`/`sizeBytes`/`messageCount`, add backward-compatible `findRecentMemberLogFileRefsByMember()` third-arg options parsing, apply `mtimeSinceMs` to lead refs, augment requested members into attribution, keep `messageCount` cheap-only, then add ref dedupe tests. +7. Add `ClaudeMemberTranscriptStreamSource` using `findRecentMemberLogFileRefsByMember`, ref dedupe and dedicated parser instance owned by member stream. +8. Extract provider-neutral `ParsedMessage` hygiene helpers and keep board/task JSON cleanup scoped. +9. Add pair-aware message-window trimming and content-size truncation before chunk build. +10. Extract `OpenCodeRuntimeProjectionMapper` from `OpenCodeTaskLogStreamSource` without moving task-window logic. Do not use the narrower stall-monitor mapper as the base. +11. Add `GetMemberLogStreamUseCase` with merge, sort, budget, truncation and identical active request join layer. +12. Add source-local OpenCode member TTL cache and in-flight join. +13. Export/instantiate the feature facade in main composition and register/remove the new IPC handler. +14. Add renderer feature `ExecutionLogStreamView` render-only extraction with shape-preserving normalization and caller-provided segment key strategy. +15. Add `MemberLogStreamSection` with tracking activation, reload coalescing, actor/source identity separation and without importing old `MemberLogsTab`. +16. Wire `MemberDetailDialog` gate-on/gate-off boundary with old `MemberLogsTab` fallback, importing only `@features/member-log-stream/renderer`. +17. Add OpenCode bridge `laneId` and `timeoutMs`. +18. Add `OpenCodeMemberRuntimeStreamSource` using the shared mapper, while leaving stall-monitor mapper migration as a separate optional cleanup. +19. Add coverage/warnings and backend truncation semantics. +20. Keep Codex skipped in first PR. +21. Optional follow-up PR: `CodexNativeTraceReader.readMemberRuns`. + +## 7. Revised Risk Ratings + +| Risk | Before | After research | Notes | +| --- | ---: | ---: | --- | +| Showing another member's Claude logs | 🛡️ 7 | 🛡️ 8 | Existing finder has strong attribution precedence | +| OpenCode wrong lane | 🛡️ 6 | 🛡️ 8 | Pass laneId from snapshot, respect orchestrator ambiguity | +| Codex completeness claim | 🛡️ 5 | 🛡️ 8 | Mark skipped/partial honestly | +| Codex member-wide scan cost | 🛡️ 4 | 🛡️ 9 | First PR does not scan task-keyed trace tree for member-wide owner history | +| Parser cache regression | 🛡️ 6 | 🛡️ 9 | Dedicated parser instance avoids parsed cache eviction | +| Parser in-flight eviction | 🛡️ 5 | 🛡️ 9 | Same ownership rule avoids deleting another stream's active parse dedupe | +| UI copy drift from task stream | 🛡️ 7 | 🛡️ 8 | Generic view extraction avoids duplicated renderer logic | +| Popup freeze on large history | 🛡️ 4 | 🛡️ 8 | Backend budget avoids rendering audit-sized logs | +| Architecture drift | 🛡️ 5 | 🛡️ 9 | Canonical feature slice plus source ports keeps provider logic isolated and follows repo standard | +| Feature boundary deep imports | 🛡️ 5 | 🛡️ 9 | Generic `src/features/*` lint guard rails plus public-entrypoint imports protect Clean Architecture direction | +| IPC lane validation | 🛡️ 6 | 🛡️ 8.5 | Dedicated lane validator handles `secondary:opencode:*` safely without rewriting exact ids | +| Cumulative subagent duplicates | 🛡️ 5 | 🛡️ 8 | Ref metadata plus pre-parse dedupe avoids repeated turns | +| OpenCode mapper drift | 🛡️ 6 | 🛡️ 8.5 | Shared projection mapper avoids copy-pasted conversion | +| Wrong OpenCode mapper base | 🛡️ 5 | 🛡️ 9 | Extract from task source, not from the narrower stall-monitor mapper | +| Finder metadata overreach | 🛡️ 5 | 🛡️ 8 | `kind`/`sizeBytes` stay cheap; no full parse just for `messageCount` | +| Oversized single segment | 🛡️ 4 | 🛡️ 8 | Message budget catches huge chunks that `maxChunks` cannot | +| Live refresh misses | 🛡️ 6 | 🛡️ 8 | Same-team `task-log-change` reload covers task freshness signals | +| IPC dependency shift | 🛡️ 5 | 🛡️ 8 | Append-only service injection avoids breaking positional handler setup | +| Browser fallback compile drift | 🛡️ 6 | 🛡️ 8 | Full empty response keeps feature API and any compatibility delegate satisfied outside Electron | +| Removed member attribution | 🛡️ 5 | 🛡️ 8 | Requested member names are added to finder attribution set | +| Finder options compatibility | 🛡️ 6 | 🛡️ 9 | Backward-compatible third-arg parser keeps advisory and live tests stable | +| Lead mtime window bypass | 🛡️ 6 | 🛡️ 9 | Apply `mtimeSinceMs` to lead transcript before pushing lead ref | +| Mixed source context loss | 🛡️ 6 | 🛡️ 8 | Member segment metadata gives UI safe provider/session labels | +| Segment key collisions | 🛡️ 6 | 🛡️ 8.5 | Generic view supports task default key and member source-aware override | +| OpenCode popup delay | 🛡️ 5 | 🛡️ 8 | Popup-specific timeout and fail-soft source handling avoid blocking Claude transcript logs | +| Renderer duplicate reloads | 🛡️ 5 | 🛡️ 8.5 | Member section coalesces active loads and runs at most one pending reload | +| Live tracking not activated | 🛡️ 4 | 🛡️ 9 | Dedicated `setMemberLogStreamTracking()` activates `TeamLogSourceTracker` while popup stream is mounted | +| Tracking consumer leak | 🛡️ 5 | 🛡️ 8.5 | Team-level `member_log_stream` consumer uses existing reference counting and cleanup tests | +| Stale discovery after launch | 🛡️ 5 | 🛡️ 8 | `forceRefresh` on `log-source-change` bypasses 30s finder discovery cache | +| Huge single tool output | 🛡️ 4 | 🛡️ 8 | Content-char budgets protect renderer beyond message/chunk count | +| Renderer Date shape drift | 🛡️ 6 | 🛡️ 8.5 | HTTP client already revives ISO dates, remaining risk is mostly test fixtures | +| DTO source union drift | 🛡️ 5 | 🛡️ 9 | Standalone `MemberLogStreamResponse` avoids mutating task `BoardTaskLogStreamResponse.source` semantics | +| Member popup fallback regression | 🛡️ 6 | 🛡️ 9 | Gate at `MemberDetailDialog`, leave shared `MemberLogsTab` and task `Execution Sessions` untouched | +| Resolved member runtime/lane type drift | 🛡️ 6 | 🛡️ 9 | Shared type explicitly declares runtime/lane fields already present at runtime | +| OpenCode bridge call churn | 🛡️ 5 | 🛡️ 8 | Short source-local TTL and in-flight join mirror task source behavior | +| Board/task sanitization leakage | 🛡️ 5 | 🛡️ 9 | Extract provider-neutral message hygiene, keep board JSON cleanup scoped to task stream | +| IPC option typo drift | 🛡️ 5 | 🛡️ 9 | Unknown member-stream options are rejected with an allow-list before service dispatch | +| Participant/source identity drift | 🛡️ 5 | 🛡️ 9 | Actor participants stay actor-based; provider/session labels come from `segment.source` | +| Since-only replacement reload | 🛡️ 5 | 🛡️ 9 | Renderer does full bounded reloads until partial-response merge is explicit | +| Generic view side effects | 🛡️ 5 | 🛡️ 9 | View stays render-only; containers own API calls, gates, tracking and fallback | + +## 8. Test Additions From Research + +Add these tests beyond the original plan: + +- Core domain policy tests run without main/preload/renderer imports. +- Core application use-case tests use fake source/cache/clock/logger ports and no concrete provider adapters. +- Feature IPC tests exercise `registerMemberLogStreamIpc()`/remove handler through feature main input adapter. +- Feature preload tests exercise `createMemberLogStreamBridge()` with contracts-only dependencies. +- Renderer UI tests pass props/view models and do not mock `@renderer/api` or `@renderer/store`. +- Targeted lint for `src/features/member-log-stream` passes under generic feature boundary rules. +- App shell integration imports only `@features/member-log-stream/main`, `@features/member-log-stream/preload`, `@features/member-log-stream/renderer` or `@features/member-log-stream/contracts`. +- `ClaudeMemberTranscriptStreamSource` uses `findRecentMemberLogFileRefsByMember`, not `findMemberLogPaths`. +- It dedupes cumulative subagent refs before parsing and caps after dedupe. +- It augments attribution with requested member names so removed/historical members can still resolve. +- It enforces global `maxSegments` and `maxChunks`. +- It enforces `maxSourceMessages` and `maxMessagesPerSegment`. +- It enforces content-char budgets without mutating parser cache arrays. +- It trims oversized message windows without orphaning tool results. +- It truncates oversized content while preserving ids needed for tool linking. +- It preserves ordinary JSON-looking tool outputs unless the content budget requires truncation. +- It does not apply board/task JSON cleanup globally to member stream messages. +- It creates stable segment ids from `sessionId/fileFingerprint/startTimestamp`. +- It returns `MemberLogStreamSegment.source` labels without absolute file paths. +- It hashes file fingerprints and does not expose absolute paths in ids/warnings/metadata. +- It does not share parser cache with task stream. +- It uses a member-owned parser so member `parseFiles().retainOnly()` cannot clear task-stream `inFlight` parse entries. +- Task-stream parse in-flight dedupe still works while member stream parses a different file set. +- First-PR Codex skipped adapter does not call `CodexNativeTraceReader.readTaskRuns()` and does not scan `processed//` directories. +- `MemberLogFileRef` optional `kind`/`sizeBytes`/`messageCount` remains backward compatible with advisory callers. +- `MemberLogFileRef.messageCount` is not computed by full parsing inside `TeamMemberLogsFinder`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` keeps legacy numeric/null third-arg behavior. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` supports object options `{ mtimeSinceMs, forceRefresh }`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` passes `forceRefresh` through to `discoverProjectSessions()`. +- `TeamMemberLogsFinder.findRecentMemberLogFileRefsByMember()` applies `mtimeSinceMs` to lead transcript refs. +- `OpenCodeRuntimeProjectionMapper` preserves tool calls, tool results, content blocks, `sourceToolUseID`, `sourceToolAssistantUUID`, `toolUseResult`, `isMeta`, and sanitized text content. +- `OpenCodeTaskLogStreamSource` and `OpenCodeMemberRuntimeStreamSource` both call `OpenCodeRuntimeProjectionMapper`. +- Mapper fixtures prove non-string content blocks are not dropped like the stall-monitor mapper would drop them. +- `ResolvedTeamMember` exposes runtime/lane fields in TS: `providerBackendId`, `selectedFastMode`, `resolvedFastMode`, `laneId`, `laneKind`, `laneOwnerProviderId`. +- `MemberLogStreamSection` passes `laneId` for OpenCode member only when `laneOwnerProviderId === 'opencode'`. +- `MemberLogStreamSection` does not pass stale lane-like fields for non-OpenCode members. +- `MemberLogStreamSection` enables `setMemberLogStreamTracking(teamName, true)` while mounted and disables it on unmount. +- `MemberLogStreamSection` disables tracking for the previous team when `teamName` changes. +- `MemberLogStreamSection` coalesces duplicate background reloads while one request is active. +- `OpenCodeMemberRuntimeStreamSource` joins duplicate in-flight bridge calls. +- `OpenCodeMemberRuntimeStreamSource` bypasses completed cache on `forceRefresh` but still joins in-flight work. +- `MemberDetailDialog` renders `MemberLogStreamSection` when renderer gate is on. +- `MemberDetailDialog` renders old `MemberLogsTab` when renderer gate is off. +- `MemberDetailDialog` first-load stream error keeps Logs active and shows explicit old logs fallback. +- `ExecutionSessionsSection` remains unchanged and still renders legacy `MemberLogsTab`. +- `MemberLogStreamSection` reloads on same-team `log-source-change` and `task-log-change`, but not `tool-activity`. +- `MemberLogStreamSection` passes `forceRefresh: true` for `log-source-change` reloads only. +- `MemberLogStreamSection` does not pass `since` during renderer background replacement reloads in the first PR. +- `ExecutionLogStreamView` tests cover Date-shaped chunks and document whether JSON-like chunk normalization is supported. +- `ExecutionLogStreamView` preserves task tail-growth expanded state with task default keys. +- `ExecutionLogStreamView` supports a member source-aware `buildSegmentRenderKey` override to avoid provider/session collisions. +- `ExecutionLogStreamView` preserves unknown stream fields and member `segment.source` while normalizing chunks. +- Shared type tests or compile checks keep `MemberLogStreamResponse` standalone and keep `BoardTaskLogStreamResponse.source` task-only. +- `MemberLogStreamSection` renders provider/session/lane labels from `segment.source`, not from participant labels. +- `MemberLogStreamSection` keeps actor participant identity stable for one selected member across multiple providers/sessions. +- A member stream segment with `actor.memberName` and no `Process[]` still renders tool/output items. +- IPC rejects invalid `since`, clamps `limitSegments`, and accepts colon-containing lane ids. +- IPC accepts `primary` as a lane id. +- IPC preserves exact lane id casing/punctuation when passing options to the service. +- IPC rejects lane ids with control characters, NUL, newline, `/`, `\` or length over 256. +- IPC accepts boolean `forceRefresh` and rejects non-boolean values. +- IPC rejects unknown `getMemberLogStream` option keys before dispatching to the service. +- IPC validation helpers do not import non-exported `ValidationResult`. +- IPC registers/removes feature `MEMBER_LOG_STREAM_GET` channel. +- IPC registers/removes feature `MEMBER_LOG_STREAM_SET_TRACKING` channel. +- IPC handler calls the feature facade/use case with normalized options. +- IPC tracking handler validates `teamName` and boolean `enabled`, then maps to `TeamLogSourceTracker` consumer `member_log_stream`. +- `TeamLogSourceTracker` treats `member_log_stream` as a separate consumer from `task_log_stream`. +- Multiple member stream mounts for one team keep the watcher alive until all member consumers disable tracking. +- Disabling `member_log_stream` does not stop tracking while `task_log_stream`, `change_presence`, `tool_activity` or `stall_monitor` is still active. +- Existing `initializeTeamHandlers()` positional setup remains unchanged for task stream and exact-log services. +- Existing task stream IPC handler test remains a wiring smoke test proving the feature did not move legacy handler ownership. +- Browser-mode `httpClient` returns a complete empty member stream response. +- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` appends `--lane` only when provided. +- `ClaudeMultimodelBridgeService.getOpenCodeTranscript()` honors member-popup `timeoutMs`. +- OpenCode timeout/runtime missing becomes a warning and does not fail Claude transcript rendering. +- OpenCode ambiguity error becomes warning, not failed member popup. +- Codex member stream coverage is `skipped` unless explicit partial trace phase is implemented. + +## Final Recommendation + +Грамотная реализация варианта 2: + +🎯 8.5 🛡️ 8.5 🧠 6 +Примерно 1500-2300 LOC вместе с тестами. + +First implementation should be: + +- Claude transcript stream complete enough for selected member; +- OpenCode runtime stream lane-aware and safe; +- Codex native explicitly skipped or partial-only behind separate follow-up; +- old member logs fallback kept; +- fallback decision kept at `MemberDetailDialog`, not inside shared `MemberLogsTab`; +- task `Execution Sessions` kept unchanged; +- feature gated; +- member stream tracking activated while popup Logs stream is mounted; +- backend budget enforced before renderer; +- renderer duplicate reloads coalesced while one member stream request is active; +- provider-neutral message hygiene extracted separately from board/task JSON sanitization; +- pair-aware message trimming used for oversized single segments; +- content-size budgets applied before chunk build; +- cumulative subagent refs deduped before parse; +- requested member names added to finder attribution set; +- finder third arg extended through backward-compatible numeric/null/object parser; +- `mtimeSinceMs` applied to lead transcript refs too; +- `src/features/member-log-stream` follows canonical feature layout with contracts/core/main/preload/renderer public entrypoints; +- app shell imports only public feature entrypoints and does not deep-import source adapters or use case internals; +- member segments include safe provider/session metadata; +- member stream uses source-aware segment render keys; +- OpenCode projection conversion extracted into shared mapper; +- OpenCode runtime transcript calls protected by source-local TTL and in-flight join; +- feature IPC registration is separate from legacy `initializeTeamHandlers()` service injection; +- browser fallback returns a complete empty stream response; +- `log-source-change` reloads use `forceRefresh`; +- source-port architecture used inside the canonical member-log-stream feature slice; +- no orchestrator code changes. + +Это дает максимальный прирост UX с низким риском неправильных логов. diff --git a/src/features/member-log-stream/contracts/api.ts b/src/features/member-log-stream/contracts/api.ts new file mode 100644 index 00000000..01480ada --- /dev/null +++ b/src/features/member-log-stream/contracts/api.ts @@ -0,0 +1,10 @@ +import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from './dto'; + +export interface MemberLogStreamApi { + getMemberLogStream( + teamName: string, + memberName: string, + options?: MemberLogStreamRequestOptions + ): Promise; + setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; +} diff --git a/src/features/member-log-stream/contracts/channels.ts b/src/features/member-log-stream/contracts/channels.ts new file mode 100644 index 00000000..88799ef5 --- /dev/null +++ b/src/features/member-log-stream/contracts/channels.ts @@ -0,0 +1,2 @@ +export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; +export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; diff --git a/src/features/member-log-stream/contracts/dto.ts b/src/features/member-log-stream/contracts/dto.ts new file mode 100644 index 00000000..2d05c0f5 --- /dev/null +++ b/src/features/member-log-stream/contracts/dto.ts @@ -0,0 +1,72 @@ +import type { BoardTaskLogParticipant, BoardTaskLogSegment } from '@shared/types'; + +export type MemberLogStreamProvider = + | 'claude_transcript' + | 'opencode_runtime' + | 'codex_native_trace'; + +export type MemberLogStreamSource = + | 'member_transcript' + | 'member_mixed_runtime' + | 'member_runtime_only' + | 'member_empty'; + +export interface MemberLogStreamRequestOptions { + limitSegments?: number; + since?: string; + laneId?: string; + forceRefresh?: boolean; +} + +export interface MemberLogStreamCoverage { + provider: MemberLogStreamProvider; + status: 'included' | 'partial' | 'skipped'; + reason?: string; +} + +export interface MemberLogStreamWarning { + code: + | 'opencode_ambiguous_lane' + | 'opencode_missing_runtime_session' + | 'opencode_runtime_unavailable' + | 'opencode_runtime_timeout' + | 'codex_member_wide_not_supported' + | 'large_log_window_limited' + | 'segment_message_window_limited' + | 'message_content_limited' + | 'unreadable_transcript_file'; + message: string; +} + +export interface MemberLogStreamMetadata { + scannedTranscriptFileCount: number; + includedTranscriptFileCount: number; + droppedSegmentCount: number; + droppedChunkCount: number; + droppedMessageCount: number; +} + +export interface MemberLogStreamSegmentSource { + provider: MemberLogStreamProvider; + label: string; + sessionId?: string; + laneId?: string; + messageCount?: number; + truncated?: boolean; +} + +export interface MemberLogStreamSegment extends BoardTaskLogSegment { + source: MemberLogStreamSegmentSource; +} + +export interface MemberLogStreamResponse { + participants: BoardTaskLogParticipant[]; + defaultFilter: string; + segments: MemberLogStreamSegment[]; + source: MemberLogStreamSource; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + generatedAt: string; + metadata: MemberLogStreamMetadata; +} diff --git a/src/features/member-log-stream/contracts/index.ts b/src/features/member-log-stream/contracts/index.ts new file mode 100644 index 00000000..41e0bc74 --- /dev/null +++ b/src/features/member-log-stream/contracts/index.ts @@ -0,0 +1,4 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; +export * from './normalize'; diff --git a/src/features/member-log-stream/contracts/normalize.ts b/src/features/member-log-stream/contracts/normalize.ts new file mode 100644 index 00000000..d528d75e --- /dev/null +++ b/src/features/member-log-stream/contracts/normalize.ts @@ -0,0 +1,44 @@ +import type { MemberLogStreamResponse } from './dto'; + +export function createEmptyMemberLogStreamResponse( + generatedAt = new Date().toISOString() +): MemberLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt, + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }; +} + +export function normalizeMemberLogStreamResponse( + response: MemberLogStreamResponse | null | undefined +): MemberLogStreamResponse { + if (!response) { + return createEmptyMemberLogStreamResponse(); + } + + return { + ...createEmptyMemberLogStreamResponse(response.generatedAt), + ...response, + participants: Array.isArray(response.participants) ? response.participants : [], + segments: Array.isArray(response.segments) ? response.segments : [], + coverage: Array.isArray(response.coverage) ? response.coverage : [], + warnings: Array.isArray(response.warnings) ? response.warnings : [], + metadata: { + ...createEmptyMemberLogStreamResponse(response.generatedAt).metadata, + ...(response.metadata ?? {}), + }, + }; +} diff --git a/src/features/member-log-stream/core/application/ports/ClockPort.ts b/src/features/member-log-stream/core/application/ports/ClockPort.ts new file mode 100644 index 00000000..b7b6878b --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/ClockPort.ts @@ -0,0 +1,3 @@ +export interface ClockPort { + now(): number; +} diff --git a/src/features/member-log-stream/core/application/ports/LoggerPort.ts b/src/features/member-log-stream/core/application/ports/LoggerPort.ts new file mode 100644 index 00000000..f25abe6b --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/LoggerPort.ts @@ -0,0 +1,5 @@ +export interface LoggerPort { + debug?(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} diff --git a/src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts b/src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts new file mode 100644 index 00000000..d40ea9f3 --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/MemberLogStreamSource.ts @@ -0,0 +1,40 @@ +import type { + MemberLogStreamCoverage, + MemberLogStreamProvider, + MemberLogStreamSegment, + MemberLogStreamWarning, +} from '../../../contracts'; +import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +export interface MemberLogStreamSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogStreamBudget; + sinceMs?: number | null; + forceRefresh?: boolean; +} + +export interface MemberLogStreamSourceMetadata { + scannedTranscriptFileCount?: number; + includedTranscriptFileCount?: number; + droppedSegmentCount?: number; + droppedChunkCount?: number; + droppedMessageCount?: number; +} + +export interface MemberLogStreamSourceResult { + provider: MemberLogStreamProvider; + status: MemberLogStreamCoverage['status']; + reason?: string; + participants: BoardTaskLogParticipant[]; + segments: MemberLogStreamSegment[]; + warnings: MemberLogStreamWarning[]; + metadata?: MemberLogStreamSourceMetadata; +} + +export interface MemberLogStreamSource { + readonly provider: MemberLogStreamProvider; + load(input: MemberLogStreamSourceInput): Promise; +} diff --git a/src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts b/src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts new file mode 100644 index 00000000..07bc65ff --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/MemberLogStreamTrackingPort.ts @@ -0,0 +1,3 @@ +export interface MemberLogStreamTrackingPort { + setTracking(teamName: string, enabled: boolean): Promise; +} diff --git a/src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts b/src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts new file mode 100644 index 00000000..913ea4cc --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase.ts @@ -0,0 +1,144 @@ +import { createEmptyMemberLogStreamResponse } from '../../../contracts'; +import { + clampMemberLogStreamSegmentLimit, + DEFAULT_MEMBER_LOG_STREAM_BUDGET, +} from '../../domain/models/MemberLogStreamBudget'; +import { buildMemberLogStreamResponse } from '../../domain/policies/memberLogStreamMergePolicy'; + +import type { MemberLogStreamResponse } from '../../../contracts'; +import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget'; +import type { ClockPort } from '../ports/ClockPort'; +import type { LoggerPort } from '../ports/LoggerPort'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceResult, +} from '../ports/MemberLogStreamSource'; + +export interface GetMemberLogStreamInput { + teamName: string; + memberName: string; + limitSegments?: number; + sinceMs?: number | null; + laneId?: string; + forceRefresh?: boolean; +} + +interface GetMemberLogStreamUseCaseDeps { + sources: readonly MemberLogStreamSource[]; + clock: ClockPort; + logger: LoggerPort; + budget?: Partial; +} + +function stableInputKey(input: GetMemberLogStreamInput, limitSegments: number): string { + return JSON.stringify([ + input.teamName, + input.memberName, + limitSegments, + input.sinceMs ?? null, + input.laneId ?? '', + input.forceRefresh === true, + ]); +} + +export class GetMemberLogStreamUseCase { + private readonly budget: MemberLogStreamBudget; + private readonly inFlight = new Map>(); + + constructor(private readonly deps: GetMemberLogStreamUseCaseDeps) { + this.budget = { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...(deps.budget ?? {}) }; + } + + async execute(input: GetMemberLogStreamInput): Promise { + const limitSegments = clampMemberLogStreamSegmentLimit(input.limitSegments, this.budget); + const key = stableInputKey(input, limitSegments); + const existing = this.inFlight.get(key); + if (existing) { + return existing; + } + + const promise = this.buildResponse(input, limitSegments).finally(() => { + this.inFlight.delete(key); + }); + this.inFlight.set(key, promise); + return promise; + } + + private async buildResponse( + input: GetMemberLogStreamInput, + limitSegments: number + ): Promise { + if (this.deps.sources.length === 0) { + return createEmptyMemberLogStreamResponse(new Date(this.deps.clock.now()).toISOString()); + } + + const sourceInput = { + teamName: input.teamName, + memberName: input.memberName, + laneId: input.laneId, + budget: this.budget, + sinceMs: input.sinceMs, + forceRefresh: input.forceRefresh, + }; + + const settled = await Promise.all( + this.deps.sources.map(async (source): Promise => { + try { + return await source.load(sourceInput); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.deps.logger.warn( + `Member log stream source ${source.provider} failed for ${input.teamName}/${input.memberName}: ${message}` + ); + return { + provider: source.provider, + status: 'skipped', + reason: message, + participants: [], + segments: [], + warnings: [ + { + code: + source.provider === 'opencode_runtime' + ? 'opencode_runtime_unavailable' + : 'unreadable_transcript_file', + message, + }, + ], + }; + } + }) + ); + + const metadata = { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }; + + for (const result of settled) { + metadata.scannedTranscriptFileCount += result.metadata?.scannedTranscriptFileCount ?? 0; + metadata.includedTranscriptFileCount += result.metadata?.includedTranscriptFileCount ?? 0; + metadata.droppedSegmentCount += result.metadata?.droppedSegmentCount ?? 0; + metadata.droppedChunkCount += result.metadata?.droppedChunkCount ?? 0; + metadata.droppedMessageCount += result.metadata?.droppedMessageCount ?? 0; + } + + return buildMemberLogStreamResponse({ + participants: settled.flatMap((result) => result.participants), + segments: settled.flatMap((result) => result.segments), + coverage: settled.map((result) => ({ + provider: result.provider, + status: result.status, + ...(result.reason ? { reason: result.reason } : {}), + })), + warnings: settled.flatMap((result) => result.warnings), + generatedAt: new Date(this.deps.clock.now()).toISOString(), + budget: this.budget, + limitSegments, + metadata, + }); + } +} diff --git a/src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts b/src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts new file mode 100644 index 00000000..6590c7dd --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/SetMemberLogStreamTrackingUseCase.ts @@ -0,0 +1,9 @@ +import type { MemberLogStreamTrackingPort } from '../ports/MemberLogStreamTrackingPort'; + +export class SetMemberLogStreamTrackingUseCase { + constructor(private readonly tracking: MemberLogStreamTrackingPort) {} + + async execute(teamName: string, enabled: boolean): Promise { + await this.tracking.setTracking(teamName, enabled); + } +} diff --git a/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts new file mode 100644 index 00000000..358d24d1 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogStreamUseCase.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GetMemberLogStreamUseCase } from '../GetMemberLogStreamUseCase'; + +import type { MemberLogStreamSegment } from '../../../../contracts'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceResult, +} from '../../ports/MemberLogStreamSource'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +const generatedAt = Date.parse('2026-02-01T00:00:00.000Z'); + +const participant: BoardTaskLogParticipant = { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: false, +}; + +function segment(id: string): MemberLogStreamSegment { + return { + id, + participantKey: participant.key, + actor: { + memberName: 'alice', + role: 'member', + sessionId: `session-${id}`, + isSidechain: false, + }, + startTimestamp: '2026-02-01T00:00:00.000Z', + endTimestamp: '2026-02-01T00:00:00.000Z', + chunks: [], + source: { + provider: 'claude_transcript', + label: 'Claude transcript', + sessionId: `session-${id}`, + }, + }; +} + +function includedResult(id: string): MemberLogStreamSourceResult { + return { + provider: 'claude_transcript', + status: 'included', + participants: [participant], + segments: [segment(id)], + warnings: [], + metadata: { + scannedTranscriptFileCount: 1, + includedTranscriptFileCount: 1, + }, + }; +} + +describe('GetMemberLogStreamUseCase', () => { + it('keeps the stream fail-soft when one source throws', async () => { + const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const useCase = new GetMemberLogStreamUseCase({ + sources: [ + { + provider: 'claude_transcript', + load: vi.fn().mockResolvedValue(includedResult('ok')), + }, + { + provider: 'opencode_runtime', + load: vi.fn().mockRejectedValue(new Error('runtime down')), + }, + ], + clock: { now: () => generatedAt }, + logger, + }); + + const response = await useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + }); + + expect(response.segments.map((item) => item.id)).toEqual(['ok']); + expect(response.coverage).toEqual([ + { provider: 'claude_transcript', status: 'included' }, + { provider: 'opencode_runtime', status: 'skipped', reason: 'runtime down' }, + ]); + expect(response.warnings).toEqual([ + { code: 'opencode_runtime_unavailable', message: 'runtime down' }, + ]); + expect(response.generatedAt).toBe('2026-02-01T00:00:00.000Z'); + expect(logger.warn).toHaveBeenCalledWith( + 'Member log stream source opencode_runtime failed for alpha-team/alice: runtime down' + ); + }); + + it('joins identical in-flight requests and releases the key after completion', async () => { + const resolveLoad: ((value: MemberLogStreamSourceResult) => void)[] = []; + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad.push(resolve); + }) + ); + const source: MemberLogStreamSource = { + provider: 'claude_transcript', + load, + }; + const useCase = new GetMemberLogStreamUseCase({ + sources: [source], + clock: { now: () => generatedAt }, + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }); + + const first = useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 5, + forceRefresh: true, + }); + const second = useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 5, + forceRefresh: true, + }); + + expect(load).toHaveBeenCalledTimes(1); + resolveLoad[0]?.(includedResult('joined')); + + const [firstResponse, secondResponse] = await Promise.all([first, second]); + expect(firstResponse.segments.map((item) => item.id)).toEqual(['joined']); + expect(secondResponse.segments.map((item) => item.id)).toEqual(['joined']); + + const third = useCase.execute({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 5, + forceRefresh: true, + }); + + expect(load).toHaveBeenCalledTimes(2); + resolveLoad[1]?.(includedResult('after-release')); + await expect(third).resolves.toMatchObject({ + segments: [{ id: 'after-release' } as MemberLogStreamSegment], + }); + }); +}); diff --git a/src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts b/src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts new file mode 100644 index 00000000..12999854 --- /dev/null +++ b/src/features/member-log-stream/core/domain/models/MemberLogStreamBudget.ts @@ -0,0 +1,35 @@ +export interface MemberLogStreamBudget { + maxTranscriptFiles: number; + maxSegments: number; + maxChunks: number; + maxSourceMessages: number; + maxMessagesPerSegment: number; + maxTotalContentChars: number; + maxMessageContentChars: number; + maxToolResultContentChars: number; + openCodeMessageLimit: number; + openCodeTimeoutMs: number; +} + +export const DEFAULT_MEMBER_LOG_STREAM_BUDGET: MemberLogStreamBudget = { + maxTranscriptFiles: 40, + maxSegments: 30, + maxChunks: 250, + maxSourceMessages: 1200, + maxMessagesPerSegment: 300, + maxTotalContentChars: 800_000, + maxMessageContentChars: 80_000, + maxToolResultContentChars: 120_000, + openCodeMessageLimit: 400, + openCodeTimeoutMs: 5_000, +}; + +export function clampMemberLogStreamSegmentLimit( + requested: number | undefined, + budget: MemberLogStreamBudget = DEFAULT_MEMBER_LOG_STREAM_BUDGET +): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return budget.maxSegments; + } + return Math.max(1, Math.min(80, Math.floor(requested), budget.maxSegments)); +} diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts new file mode 100644 index 00000000..e860fd68 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogStreamMergePolicy.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../models/MemberLogStreamBudget'; +import { buildMemberLogStreamResponse } from '../memberLogStreamMergePolicy'; + +import type { MemberLogStreamSegment } from '../../../../contracts'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +const participant: BoardTaskLogParticipant = { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: false, +}; + +function segment( + id: string, + timestamp: string, + provider: MemberLogStreamSegment['source']['provider'] = 'claude_transcript' +): MemberLogStreamSegment { + return { + id, + participantKey: participant.key, + actor: { + memberName: 'alice', + role: 'member', + sessionId: `session-${id}`, + isSidechain: false, + }, + startTimestamp: timestamp, + endTimestamp: timestamp, + chunks: [], + source: { + provider, + label: provider, + sessionId: `session-${id}`, + }, + }; +} + +describe('buildMemberLogStreamResponse', () => { + it('sorts segments chronologically, keeps the recent limit, and marks bounded windows as truncated', () => { + const response = buildMemberLogStreamResponse({ + participants: [participant, participant], + segments: [ + segment('newest', '2026-01-01T00:03:00.000Z'), + segment('oldest', '2026-01-01T00:01:00.000Z'), + segment('middle', '2026-01-01T00:02:00.000Z'), + ], + coverage: [ + { provider: 'codex_native_trace', status: 'skipped' }, + { provider: 'claude_transcript', status: 'included' }, + ], + warnings: [], + generatedAt: '2026-01-01T00:04:00.000Z', + budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET, + limitSegments: 2, + metadata: { + scannedTranscriptFileCount: 3, + includedTranscriptFileCount: 3, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + + expect(response.segments.map((item) => item.id)).toEqual(['middle', 'newest']); + expect(response.participants).toEqual([participant]); + expect(response.coverage.map((item) => item.provider)).toEqual([ + 'claude_transcript', + 'codex_native_trace', + ]); + expect(response.truncated).toBe(true); + expect(response.metadata.droppedSegmentCount).toBe(1); + expect(response.warnings).toEqual([ + { + code: 'large_log_window_limited', + message: 'Showing a bounded recent member log stream to keep the popup responsive.', + }, + ]); + }); + + it('classifies mixed transcript and runtime streams without relying on coverage-only data', () => { + const mixed = buildMemberLogStreamResponse({ + participants: [participant], + segments: [ + segment('claude', '2026-01-01T00:01:00.000Z', 'claude_transcript'), + segment('opencode', '2026-01-01T00:02:00.000Z', 'opencode_runtime'), + ], + coverage: [ + { provider: 'claude_transcript', status: 'included' }, + { provider: 'opencode_runtime', status: 'included' }, + { provider: 'codex_native_trace', status: 'skipped' }, + ], + warnings: [], + generatedAt: '2026-01-01T00:03:00.000Z', + budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET, + limitSegments: 10, + metadata: { + scannedTranscriptFileCount: 1, + includedTranscriptFileCount: 1, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + + expect(mixed.source).toBe('member_mixed_runtime'); + }); +}); diff --git a/src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts b/src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts new file mode 100644 index 00000000..84def383 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/memberLogStreamMergePolicy.ts @@ -0,0 +1,147 @@ +import type { + MemberLogStreamCoverage, + MemberLogStreamProvider, + MemberLogStreamResponse, + MemberLogStreamSegment, + MemberLogStreamSource, + MemberLogStreamWarning, +} from '../../../contracts'; +import type { MemberLogStreamBudget } from '../models/MemberLogStreamBudget'; +import type { BoardTaskLogParticipant } from '@shared/types'; + +export const MEMBER_LOG_STREAM_PROVIDER_ORDER: readonly MemberLogStreamProvider[] = [ + 'claude_transcript', + 'opencode_runtime', + 'codex_native_trace', +]; + +function getSegmentStartMs(segment: MemberLogStreamSegment): number { + const parsed = Date.parse(segment.startTimestamp); + return Number.isFinite(parsed) ? parsed : 0; +} + +function dedupeParticipants( + participants: readonly BoardTaskLogParticipant[] +): BoardTaskLogParticipant[] { + const deduped = new Map(); + for (const participant of participants) { + if (!deduped.has(participant.key)) { + deduped.set(participant.key, participant); + } + } + return [...deduped.values()]; +} + +export function inferMemberLogStreamSource( + segments: readonly MemberLogStreamSegment[] +): MemberLogStreamSource { + if (segments.length === 0) { + return 'member_empty'; + } + + const hasTranscript = segments.some((segment) => segment.source.provider === 'claude_transcript'); + const hasRuntime = segments.some((segment) => segment.source.provider === 'opencode_runtime'); + + if (hasTranscript && hasRuntime) { + return 'member_mixed_runtime'; + } + if (hasRuntime) { + return 'member_runtime_only'; + } + return 'member_transcript'; +} + +export function buildMemberLogStreamResponse(input: { + participants: readonly BoardTaskLogParticipant[]; + segments: readonly MemberLogStreamSegment[]; + coverage: readonly MemberLogStreamCoverage[]; + warnings: readonly MemberLogStreamWarning[]; + generatedAt: string; + budget: MemberLogStreamBudget; + limitSegments: number; + metadata: { + scannedTranscriptFileCount: number; + includedTranscriptFileCount: number; + droppedSegmentCount: number; + droppedChunkCount: number; + droppedMessageCount: number; + }; +}): MemberLogStreamResponse { + const warnings = [...input.warnings]; + const sorted = [...input.segments].sort((left, right) => { + const byTime = getSegmentStartMs(left) - getSegmentStartMs(right); + return byTime !== 0 ? byTime : left.id.localeCompare(right.id); + }); + + let droppedSegmentCount = input.metadata.droppedSegmentCount; + let droppedChunkCount = input.metadata.droppedChunkCount; + let limitedSegments = sorted; + const maxSegments = Math.min(input.limitSegments, input.budget.maxSegments); + if (limitedSegments.length > maxSegments) { + droppedSegmentCount += limitedSegments.length - maxSegments; + limitedSegments = limitedSegments.slice(-maxSegments); + } + + const totalChunks = limitedSegments.reduce((sum, segment) => sum + segment.chunks.length, 0); + if (totalChunks > input.budget.maxChunks) { + const retained: MemberLogStreamSegment[] = []; + let remaining = input.budget.maxChunks; + for (const segment of [...limitedSegments].reverse()) { + if (remaining <= 0) { + droppedSegmentCount += 1; + continue; + } + if (segment.chunks.length <= remaining) { + retained.push(segment); + remaining -= segment.chunks.length; + continue; + } + const keptChunks = segment.chunks.slice(-remaining); + droppedChunkCount += segment.chunks.length - keptChunks.length; + retained.push({ + ...segment, + chunks: keptChunks, + source: { ...segment.source, truncated: true }, + }); + remaining = 0; + } + const retainedInDisplayOrder = [...retained].reverse(); + limitedSegments = retainedInDisplayOrder; + } + + const truncated = + droppedSegmentCount > input.metadata.droppedSegmentCount || + droppedChunkCount > input.metadata.droppedChunkCount || + input.metadata.droppedMessageCount > 0 || + limitedSegments.some((segment) => segment.source.truncated); + + if (truncated && !warnings.some((warning) => warning.code === 'large_log_window_limited')) { + warnings.push({ + code: 'large_log_window_limited', + message: 'Showing a bounded recent member log stream to keep the popup responsive.', + }); + } + + const participants = dedupeParticipants(input.participants); + return { + participants, + defaultFilter: participants.length === 1 ? (participants[0]?.key ?? 'all') : 'all', + segments: limitedSegments, + source: inferMemberLogStreamSource(limitedSegments), + coverage: [...input.coverage].sort( + (left, right) => + MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(left.provider) - + MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(right.provider) + ), + warnings, + truncated, + generatedAt: input.generatedAt, + metadata: { + scannedTranscriptFileCount: input.metadata.scannedTranscriptFileCount, + includedTranscriptFileCount: input.metadata.includedTranscriptFileCount, + droppedSegmentCount, + droppedChunkCount, + droppedMessageCount: input.metadata.droppedMessageCount, + }, + }; +} diff --git a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts new file mode 100644 index 00000000..322ce182 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_SET_TRACKING } from '../../../../../contracts'; +import { + registerMemberLogStreamIpc, + removeMemberLogStreamIpc, +} from '../registerMemberLogStreamIpc'; + +import type { MemberLogStreamResponse } from '../../../../../contracts'; +import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature'; +import type { IpcMainInvokeEvent } from 'electron'; + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +function emptyResponse(): MemberLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: '2026-03-01T00:00:00.000Z', + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }; +} + +function createFakeIpcMain(): { + handlers: Map unknown>; + ipcMain: { + handle: ReturnType; + removeHandler: ReturnType; + }; +} { + const handlers = new Map unknown>(); + return { + handlers, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel); + }), + }, + }; +} + +describe('registerMemberLogStreamIpc', () => { + it('validates and normalizes getMemberLogStream options before calling the feature facade', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const result = await handlers.get(MEMBER_LOG_STREAM_GET)?.( + {} as IpcMainInvokeEvent, + 'alpha-team', + 'alice', + { + limitSegments: 200, + since: '2026-03-01T12:34:56.000Z', + laneId: ' secondary:opencode:alice ', + forceRefresh: true, + } + ); + + expect(result).toEqual({ success: true, data: emptyResponse() }); + expect(getMemberLogStream).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberName: 'alice', + limitSegments: 80, + sinceMs: Date.parse('2026-03-01T12:34:56.000Z'), + laneId: 'secondary:opencode:alice', + forceRefresh: true, + }); + }); + + it('rejects unknown options and unsafe runtime lane ids', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const get = handlers.get(MEMBER_LOG_STREAM_GET)!; + + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { unknown: true }) + ).resolves.toEqual({ + success: false, + error: 'Unknown getMemberLogStream option: unknown', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: '../bad' }) + ).resolves.toEqual({ + success: false, + error: 'laneId contains invalid characters', + }); + expect(getMemberLogStream).not.toHaveBeenCalled(); + }); + + it('accepts primary lane ids and rejects malformed optional values', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const get = handlers.get(MEMBER_LOG_STREAM_GET)!; + + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'primary' }) + ).resolves.toEqual({ success: true, data: emptyResponse() }); + expect(getMemberLogStream).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberName: 'alice', + laneId: 'primary', + }); + getMemberLogStream.mockClear(); + + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { since: 'not-a-date' }) + ).resolves.toEqual({ + success: false, + error: 'since must be a valid timestamp', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { forceRefresh: 'true' }) + ).resolves.toEqual({ + success: false, + error: 'forceRefresh must be a boolean', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'bad\nlane' }) + ).resolves.toEqual({ + success: false, + error: 'laneId contains invalid characters', + }); + await expect( + get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'x'.repeat(257) }) + ).resolves.toEqual({ + success: false, + error: 'laneId exceeds max length (256)', + }); + expect(getMemberLogStream).not.toHaveBeenCalled(); + }); + + it('validates tracking calls and unregisters both handlers', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + setMemberLogStreamTracking, + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const setTracking = handlers.get(MEMBER_LOG_STREAM_SET_TRACKING)!; + + await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', true)).resolves.toEqual({ + success: true, + }); + await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', 'yes')).resolves.toEqual({ + success: false, + error: 'enabled must be a boolean', + }); + expect(setMemberLogStreamTracking).toHaveBeenCalledWith('alpha-team', true); + + removeMemberLogStreamIpc(ipcMain as never); + + expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false); + expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false); + }); +}); diff --git a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts new file mode 100644 index 00000000..f7a50ce2 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts @@ -0,0 +1,183 @@ +import { validateMemberName, validateTeamName } from '@main/ipc/guards'; +import { createLogger } from '@shared/utils/logger'; + +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogStreamResponse, +} from '../../../../contracts'; + +import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from '../../../../contracts'; +import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature'; +import type { IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('Feature:MemberLogStream:IPC'); +const ALLOWED_OPTION_KEYS = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']); + +interface ValidationResult { + valid: boolean; + value?: T; + error?: string; +} + +function validateOptionalRuntimeLaneId(value: unknown): ValidationResult { + if (value == null) return { valid: true, value: undefined }; + if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' }; + const trimmed = value.trim(); + if (!trimmed) return { valid: true, value: undefined }; + if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' }; + if ( + trimmed.includes('/') || + trimmed.includes('\\') || + [...trimmed].some((char) => { + const code = char.charCodeAt(0); + return code <= 31 || code === 127; + }) + ) { + return { valid: false, error: 'laneId contains invalid characters' }; + } + return { valid: true, value: trimmed }; +} + +function normalizeOptions(options: unknown): ValidationResult<{ + limitSegments?: number; + sinceMs?: number | null; + laneId?: string; + forceRefresh?: boolean; +}> { + if (options == null) { + return { valid: true, value: {} }; + } + if (typeof options !== 'object' || Array.isArray(options)) { + return { valid: false, error: 'options must be an object' }; + } + + const record = options as Record; + for (const key of Object.keys(record)) { + if (!ALLOWED_OPTION_KEYS.has(key)) { + return { valid: false, error: `Unknown getMemberLogStream option: ${key}` }; + } + } + + let limitSegments: number | undefined; + if (record.limitSegments != null) { + if (typeof record.limitSegments !== 'number' || !Number.isFinite(record.limitSegments)) { + return { valid: false, error: 'limitSegments must be a finite number' }; + } + limitSegments = Math.max(1, Math.min(80, Math.floor(record.limitSegments))); + } + + let sinceMs: number | null | undefined; + if (record.since != null) { + if (typeof record.since !== 'string') { + return { valid: false, error: 'since must be an ISO timestamp string' }; + } + const parsed = Date.parse(record.since); + if (!Number.isFinite(parsed)) { + return { valid: false, error: 'since must be a valid timestamp' }; + } + sinceMs = parsed; + } + + const lane = validateOptionalRuntimeLaneId(record.laneId); + if (!lane.valid) { + return { valid: false, error: lane.error }; + } + + let forceRefresh: boolean | undefined; + if (record.forceRefresh != null) { + if (typeof record.forceRefresh !== 'boolean') { + return { valid: false, error: 'forceRefresh must be a boolean' }; + } + forceRefresh = record.forceRefresh; + } + + return { + valid: true, + value: { + ...(limitSegments !== undefined ? { limitSegments } : {}), + ...(sinceMs !== undefined ? { sinceMs } : {}), + ...(lane.value !== undefined ? { laneId: lane.value } : {}), + ...(forceRefresh !== undefined ? { forceRefresh } : {}), + }, + }; +} + +export function registerMemberLogStreamIpc( + ipcMain: IpcMain, + feature: MemberLogStreamFeatureFacade +): void { + ipcMain.handle( + MEMBER_LOG_STREAM_GET, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown, + options?: MemberLogStreamRequestOptions + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vMember = validateMemberName(memberName); + if (!vMember.valid) { + return { success: false, error: vMember.error ?? 'Invalid memberName' }; + } + const vOptions = normalizeOptions(options); + if (!vOptions.valid) { + return { success: false, error: vOptions.error ?? 'Invalid options' }; + } + + try { + const response = await feature.getMemberLogStream({ + teamName: vTeam.value!, + memberName: vMember.value!, + ...vOptions.value!, + }); + return { success: true, data: normalizeMemberLogStreamResponse(response) }; + } catch (error) { + logger.error('Failed to load member log stream', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load member log stream', + }; + } + } + ); + + ipcMain.handle( + MEMBER_LOG_STREAM_SET_TRACKING, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + try { + await feature.setMemberLogStreamTracking(vTeam.value!, enabled); + return { success: true }; + } catch (error) { + logger.error('Failed to update member log stream tracking', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to update member log stream tracking', + }; + } + } + ); +} + +export function removeMemberLogStreamIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); + ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts new file mode 100644 index 00000000..cb255dd6 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts @@ -0,0 +1,214 @@ +import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget'; + +import { + buildMemberActor, + buildMemberParticipant, + buildSegmentId, + normalizeMemberName, + shortHash, + withSegmentSource, +} from './memberLogStreamSourceUtils'; + +import type { MemberLogStreamWarning } from '../../../../contracts'; +import type { LoggerPort } from '../../../../core/application/ports/LoggerPort'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceInput, + MemberLogStreamSourceResult, +} from '../../../../core/application/ports/MemberLogStreamSource'; +import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; +import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import type { + MemberLogFileRef, + TeamMemberLogsFinder, +} from '@main/services/team/TeamMemberLogsFinder'; +import type { ParsedMessage } from '@main/types'; + +function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean { + const candidateMessageCount = candidate.messageCount ?? -1; + const existingMessageCount = existing.messageCount ?? -1; + if (candidateMessageCount !== existingMessageCount) { + return candidateMessageCount > existingMessageCount; + } + + const candidateSize = candidate.sizeBytes ?? -1; + const existingSize = existing.sizeBytes ?? -1; + if (candidateSize !== existingSize) { + return candidateSize > existingSize; + } + + return candidate.mtimeMs > existing.mtimeMs; +} + +function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] { + const byFilePath = new Map(); + const bySession = new Map(); + const passthrough: MemberLogFileRef[] = []; + + for (const ref of refs) { + if (byFilePath.has(ref.filePath)) continue; + byFilePath.set(ref.filePath, ref); + + if (ref.kind === 'lead_session') { + passthrough.push(ref); + continue; + } + + const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`; + const existing = bySession.get(key); + if (!existing || isPreferredRef(ref, existing)) { + bySession.set(key, ref); + } + } + + return [...passthrough, ...bySession.values()].sort((left, right) => { + const byTime = right.mtimeMs - left.mtimeMs; + return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); + }); +} + +function filterSourceMessageBudget( + messages: readonly ParsedMessage[], + remaining: number +): { messages: ParsedMessage[]; dropped: number; limited: boolean } { + if (remaining <= 0) { + return { messages: [], dropped: messages.length, limited: messages.length > 0 }; + } + if (messages.length <= remaining) { + return { messages: [...messages], dropped: 0, limited: false }; + } + return { + messages: messages.slice(-remaining), + dropped: messages.length - remaining, + limited: true, + }; +} + +export class ClaudeMemberTranscriptStreamSource implements MemberLogStreamSource { + readonly provider = 'claude_transcript' as const; + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly parser: BoardTaskExactLogStrictParser, + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder, + private readonly logger: LoggerPort + ) {} + + async load(input: MemberLogStreamSourceInput): Promise { + const warnings: MemberLogStreamWarning[] = []; + const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember( + input.teamName, + [input.memberName], + { + mtimeSinceMs: input.sinceMs ?? null, + forceRefresh: input.forceRefresh === true, + } + ); + const dedupedRefs = dedupeMemberLogRefs(refs); + const cappedRefs = dedupedRefs.slice(0, input.budget.maxTranscriptFiles); + const droppedRefCount = Math.max(0, dedupedRefs.length - cappedRefs.length); + if (droppedRefCount > 0) { + warnings.push({ + code: 'large_log_window_limited', + message: `Showing ${cappedRefs.length} recent transcript files for this member.`, + }); + } + + const parsedByPath = await this.parser.parseFiles(cappedRefs.map((ref) => ref.filePath)); + const participant = buildMemberParticipant(input.memberName); + const segments = []; + let remainingSourceMessages = input.budget.maxSourceMessages; + let includedTranscriptFileCount = 0; + let droppedMessageCount = 0; + let contentLimited = false; + let windowLimited = false; + + for (const ref of cappedRefs) { + const parsedMessages = parsedByPath.get(ref.filePath) ?? []; + if (parsedMessages.length === 0) continue; + + const sourceBudgeted = filterSourceMessageBudget(parsedMessages, remainingSourceMessages); + remainingSourceMessages -= sourceBudgeted.messages.length; + droppedMessageCount += sourceBudgeted.dropped; + windowLimited = windowLimited || sourceBudgeted.limited; + + const budgeted = applyMemberLogMessageBudget(sourceBudgeted.messages, input.budget); + droppedMessageCount += budgeted.droppedMessageCount; + contentLimited = contentLimited || budgeted.contentLimited; + windowLimited = windowLimited || budgeted.segmentWindowLimited; + if (budgeted.messages.length === 0) continue; + + const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages); + if (chunks.length === 0) continue; + + const first = budgeted.messages[0]; + const last = budgeted.messages[budgeted.messages.length - 1]; + if (!first || !last) continue; + + includedTranscriptFileCount += 1; + const role = ref.kind === 'lead_session' ? 'lead' : 'member'; + segments.push( + withSegmentSource( + { + id: buildSegmentId({ + provider: this.provider, + teamName: input.teamName, + memberName: input.memberName, + sessionId: ref.sessionId, + fingerprint: shortHash(`${ref.filePath}:${ref.mtimeMs}:${ref.sizeBytes ?? ''}`), + startTimestamp: first.timestamp.toISOString(), + }), + participantKey: participant.key, + actor: buildMemberActor({ + memberName: input.memberName, + sessionId: ref.sessionId, + role, + }), + startTimestamp: first.timestamp.toISOString(), + endTimestamp: last.timestamp.toISOString(), + chunks, + }, + { + provider: this.provider, + label: role === 'lead' ? 'Claude lead transcript' : 'Claude transcript', + sessionId: ref.sessionId, + messageCount: budgeted.messages.length, + truncated: budgeted.droppedMessageCount > 0 || budgeted.contentLimited, + } + ) + ); + } + + if (windowLimited) { + warnings.push({ + code: 'segment_message_window_limited', + message: 'Some transcript sessions were trimmed to recent messages.', + }); + } + if (contentLimited) { + warnings.push({ + code: 'message_content_limited', + message: 'Some large message content was truncated before rendering.', + }); + } + + this.logger.debug?.( + `Claude member log stream ${input.teamName}/${input.memberName}: refs=${refs.length}, segments=${segments.length}` + ); + + return { + provider: this.provider, + status: segments.length > 0 ? 'included' : 'skipped', + reason: segments.length > 0 ? undefined : 'no_member_transcripts', + participants: segments.length > 0 ? [participant] : [], + segments, + warnings, + metadata: { + scannedTranscriptFileCount: refs.length, + includedTranscriptFileCount, + droppedSegmentCount: droppedRefCount, + droppedMessageCount, + }, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts new file mode 100644 index 00000000..8c9a5301 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTraceStreamSource.ts @@ -0,0 +1,41 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + MemberLogStreamSource, + MemberLogStreamSourceInput, + MemberLogStreamSourceResult, +} from '../../../../core/application/ports/MemberLogStreamSource'; +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; + +export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource { + readonly provider = 'codex_native_trace' as const; + + constructor(private readonly configReader: TeamConfigReader) {} + + async load(input: MemberLogStreamSourceInput): Promise { + const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const member = config?.members?.find( + (item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase() + ); + const isCodexMember = + member?.providerId === 'codex' || + member?.providerBackendId === 'codex-native' || + (member ? false : isLeadMember({ name: input.memberName })); + + return { + provider: this.provider, + status: 'skipped', + reason: 'codex_member_wide_not_supported', + participants: [], + segments: [], + warnings: isCodexMember + ? [ + { + code: 'codex_member_wide_not_supported', + message: 'Codex member-wide native trace is not available in this variant yet.', + }, + ] + : [], + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts new file mode 100644 index 00000000..7c2fa732 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource.ts @@ -0,0 +1,230 @@ +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper'; + +import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget'; + +import { + buildMemberActor, + buildMemberParticipant, + buildSegmentId, + normalizeMemberName, + withSegmentSource, +} from './memberLogStreamSourceUtils'; + +import type { MemberLogStreamWarning } from '../../../../contracts'; +import type { + MemberLogStreamSource, + MemberLogStreamSourceInput, + MemberLogStreamSourceResult, +} from '../../../../core/application/ports/MemberLogStreamSource'; +import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; +import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; + +interface BinaryResolverLike { + resolve(): Promise; +} + +const CACHE_TTL_MS = 1_500; + +function classifyOpenCodeError(error: unknown): MemberLogStreamWarning { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + if (normalized.includes('timed out') || normalized.includes('timeout')) { + return { + code: 'opencode_runtime_timeout', + message: 'OpenCode runtime transcript timed out; showing other member logs only.', + }; + } + if (normalized.includes('--lane') || normalized.includes('multiple') || normalized.includes('ambiguous')) { + return { + code: 'opencode_ambiguous_lane', + message: 'OpenCode runtime session is ambiguous without a safe lane id.', + }; + } + return { + code: 'opencode_runtime_unavailable', + message: `OpenCode runtime transcript is unavailable: ${message}`, + }; +} + +export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource { + readonly provider = 'opencode_runtime' as const; + private readonly cache = new Map(); + private readonly inFlight = new Map>(); + + constructor( + private readonly runtimeBridge: ClaudeMultimodelBridgeService, + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder, + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver + ) {} + + async load(input: MemberLogStreamSourceInput): Promise { + const cacheKey = [ + input.teamName, + normalizeMemberName(input.memberName), + input.laneId ?? '', + input.budget.openCodeMessageLimit, + ].join('::'); + + if (!input.forceRefresh) { + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } + } + + const existing = this.inFlight.get(cacheKey); + if (existing) { + return existing; + } + + const promise = this.buildResult(input) + .then((result) => { + this.cache.set(cacheKey, { expiresAt: Date.now() + CACHE_TTL_MS, result }); + return result; + }) + .finally(() => { + this.inFlight.delete(cacheKey); + }); + this.inFlight.set(cacheKey, promise); + return promise; + } + + private async buildResult(input: MemberLogStreamSourceInput): Promise { + const binaryPath = await this.binaryResolver.resolve(); + if (!binaryPath) { + return this.skipped('opencode_runtime_unavailable', 'OpenCode runtime bridge is unavailable.'); + } + + try { + const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: input.teamName, + memberName: input.memberName, + limit: input.budget.openCodeMessageLimit, + laneId: input.laneId, + timeoutMs: input.budget.openCodeTimeoutMs, + }); + const projectedMessages = transcript?.logProjection?.messages ?? []; + const parsedMessages = mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(projectedMessages) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + if (parsedMessages.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_missing_runtime_session', + participants: [], + segments: [], + warnings: [], + }; + } + + const budgeted = applyMemberLogMessageBudget(parsedMessages, input.budget); + if (budgeted.messages.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_no_renderable_chunks', + participants: [], + segments: [], + warnings: [], + }; + } + + const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages); + if (chunks.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_no_renderable_chunks', + participants: [], + segments: [], + warnings: [], + }; + } + + const first = budgeted.messages[0]; + const last = budgeted.messages[budgeted.messages.length - 1]; + if (!first || !last) { + return this.skipped('opencode_missing_runtime_session', 'OpenCode runtime projection was empty.'); + } + + const participant = buildMemberParticipant(input.memberName); + const sessionId = transcript?.sessionId ?? first.sessionId ?? `opencode:${normalizeMemberName(input.memberName)}`; + const segment = withSegmentSource( + { + id: buildSegmentId({ + provider: this.provider, + teamName: input.teamName, + memberName: input.memberName, + sessionId, + fingerprint: `${sessionId}:${input.laneId ?? ''}:${budgeted.messages.length}`, + startTimestamp: first.timestamp.toISOString(), + }), + participantKey: participant.key, + actor: buildMemberActor({ + memberName: input.memberName, + sessionId, + role: 'member', + }), + startTimestamp: first.timestamp.toISOString(), + endTimestamp: last.timestamp.toISOString(), + chunks, + }, + { + provider: this.provider, + label: 'OpenCode runtime', + sessionId, + ...(input.laneId ? { laneId: input.laneId } : {}), + messageCount: budgeted.messages.length, + truncated: + budgeted.droppedMessageCount > 0 || + budgeted.segmentWindowLimited || + budgeted.contentLimited, + } + ); + + const warnings: MemberLogStreamWarning[] = []; + if (budgeted.segmentWindowLimited) { + warnings.push({ + code: 'segment_message_window_limited', + message: 'OpenCode runtime stream was trimmed to recent messages.', + }); + } + if (budgeted.contentLimited) { + warnings.push({ + code: 'message_content_limited', + message: 'Some large OpenCode runtime content was truncated before rendering.', + }); + } + + return { + provider: this.provider, + status: 'included', + participants: [participant], + segments: [segment], + warnings, + metadata: { + droppedMessageCount: budgeted.droppedMessageCount, + }, + }; + } catch (error) { + const warning = classifyOpenCodeError(error); + return this.skipped(warning.code, warning.message, warning); + } + } + + private skipped( + code: MemberLogStreamWarning['code'], + reason: string, + warning: MemberLogStreamWarning = { code, message: reason } + ): MemberLogStreamSourceResult { + return { + provider: this.provider, + status: 'skipped', + reason, + participants: [], + segments: [], + warnings: [warning], + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts new file mode 100644 index 00000000..179f3706 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget'; +import { ClaudeMemberTranscriptStreamSource } from '../ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource'; + +import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource'; +import type { EnhancedChunk, ParsedMessage } from '@main/types'; + +function parsedMessage(uuid: string, timestamp: string): ParsedMessage { + return { + uuid, + parentUuid: null, + type: 'assistant', + timestamp: new Date(timestamp), + role: 'assistant', + content: `message ${uuid}`, + isSidechain: true, + isMeta: false, + sessionId: 'session-1', + toolCalls: [], + toolResults: [], + }; +} + +function fakeChunk(id: string): EnhancedChunk { + return { + id, + chunkType: 'ai', + startTime: new Date('2026-04-04T00:00:00.000Z'), + endTime: new Date('2026-04-04T00:00:01.000Z'), + durationMs: 1_000, + metrics: { + durationMs: 1_000, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 1, + }, + responses: [], + processes: [], + sidechainMessages: [], + toolExecutions: [], + semanticSteps: [], + rawMessages: [], + }; +} + +function sourceInput(overrides: Partial = {}): MemberLogStreamSourceInput { + return { + teamName: 'alpha-team', + memberName: 'alice', + budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET, + ...overrides, + }; +} + +describe('ClaudeMemberTranscriptStreamSource', () => { + it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => { + const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => { + const parsed = new Map(); + parsed.set('/transcripts/larger.jsonl', [ + parsedMessage('msg-1', '2026-04-04T00:00:00.000Z'), + parsedMessage('msg-2', '2026-04-04T00:01:00.000Z'), + ]); + expect(paths).toEqual(['/transcripts/larger.jsonl']); + return parsed; + }); + const chunkBuilder = { + buildBundleChunks: vi.fn(() => [fakeChunk('chunk-1')]), + }; + const source = new ClaudeMemberTranscriptStreamSource( + { + findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([ + { + memberName: 'alice', + sessionId: 'session-1', + filePath: '/transcripts/smaller.jsonl', + mtimeMs: 10, + sizeBytes: 1_000, + messageCount: 1, + kind: 'subagent', + }, + { + memberName: 'alice', + sessionId: 'session-1', + filePath: '/transcripts/larger.jsonl', + mtimeMs: 20, + sizeBytes: 5_000, + messageCount: 10, + kind: 'subagent', + }, + ]), + } as never, + { parseFiles } as never, + chunkBuilder as never, + { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + ); + + const result = await source.load(sourceInput()); + + expect(result.status).toBe('included'); + expect(parseFiles).toHaveBeenCalledWith(['/transcripts/larger.jsonl']); + expect(result.segments).toHaveLength(1); + expect(result.segments[0]?.id).not.toContain('/transcripts'); + expect(result.segments[0]?.source).toMatchObject({ + provider: 'claude_transcript', + sessionId: 'session-1', + messageCount: 2, + }); + }); +}); + +describe('OpenCodeMemberRuntimeStreamSource', () => { + it('enforces member message and content budgets before building OpenCode chunks', async () => { + const getOpenCodeTranscript = vi.fn().mockResolvedValue({ + sessionId: 'opencode-session', + logProjection: { + messages: [0, 1, 2].map((index) => ({ + uuid: `opencode-${index}`, + parentUuid: index === 0 ? null : `opencode-${index - 1}`, + type: 'assistant', + timestamp: `2026-04-04T00:00:0${index}.000Z`, + role: 'assistant', + content: `long OpenCode runtime message ${index} ${'x'.repeat(80)}`, + toolCalls: [], + toolResults: [], + isMeta: false, + sessionId: 'opencode-session', + })), + }, + }); + const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [ + fakeChunk('opencode-budgeted-chunk'), + ]); + const source = new OpenCodeMemberRuntimeStreamSource( + { getOpenCodeTranscript } as never, + { buildBundleChunks } as never, + { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } + ); + + const result = await source.load( + sourceInput({ + budget: { + ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, + maxMessagesPerSegment: 2, + maxTotalContentChars: 60, + maxMessageContentChars: 40, + }, + }) + ); + + expect(result.status).toBe('included'); + expect(result.metadata?.droppedMessageCount).toBe(1); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining(['segment_message_window_limited', 'message_content_limited']) + ); + expect(result.segments[0]?.source).toMatchObject({ + provider: 'opencode_runtime', + messageCount: 2, + truncated: true, + }); + expect(buildBundleChunks).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ uuid: 'opencode-1' }), + expect.objectContaining({ uuid: 'opencode-2' }), + ]) + ); + expect(JSON.stringify(buildBundleChunks.mock.calls[0]?.[0])).toContain( + '[content truncated by member log stream budget]' + ); + }); + + it('joins active bridge calls, uses TTL cache, and lets forceRefresh bypass completed cache only', async () => { + const getOpenCodeTranscript = vi.fn().mockResolvedValue({ + sessionId: 'opencode-session', + logProjection: { + messages: [ + { + uuid: 'opencode-1', + parentUuid: null, + type: 'assistant', + timestamp: '2026-04-04T00:00:00.000Z', + role: 'assistant', + content: 'hello', + toolCalls: [], + toolResults: [], + isMeta: false, + sessionId: 'opencode-session', + }, + ], + }, + }); + const source = new OpenCodeMemberRuntimeStreamSource( + { getOpenCodeTranscript } as never, + { buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never, + { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } + ); + const input = sourceInput({ laneId: 'secondary:opencode:alice' }); + + const [first, second] = await Promise.all([source.load(input), source.load(input)]); + + expect(first.status).toBe('included'); + expect(second.status).toBe('included'); + expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1); + + await source.load(input); + expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1); + + await source.load({ ...input, forceRefresh: true }); + expect(getOpenCodeTranscript).toHaveBeenCalledTimes(2); + expect(getOpenCodeTranscript).toHaveBeenLastCalledWith( + '/mock/orchestrator', + expect.objectContaining({ + teamId: 'alpha-team', + memberName: 'alice', + laneId: 'secondary:opencode:alice', + timeoutMs: DEFAULT_MEMBER_LOG_STREAM_BUDGET.openCodeTimeoutMs, + }) + ); + }); + + it('reports ambiguous OpenCode lane errors as skipped provider warnings', async () => { + const source = new OpenCodeMemberRuntimeStreamSource( + { + getOpenCodeTranscript: vi.fn().mockRejectedValue(new Error('multiple records, pass --lane')), + } as never, + { buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never, + { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } + ); + + const result = await source.load(sourceInput()); + + expect(result).toMatchObject({ + provider: 'opencode_runtime', + status: 'skipped', + warnings: [ + { + code: 'opencode_ambiguous_lane', + message: 'OpenCode runtime session is ambiguous without a safe lane id.', + }, + ], + }); + }); +}); + +describe('CodexNativeMemberTraceStreamSource', () => { + it('returns an honest skipped warning for Codex members only', async () => { + const codexSource = new CodexNativeMemberTraceStreamSource({ + getConfig: vi.fn().mockResolvedValue({ + members: [{ name: 'alice', providerId: 'codex' }], + }), + } as never); + const nonCodexSource = new CodexNativeMemberTraceStreamSource({ + getConfig: vi.fn().mockResolvedValue({ + members: [{ name: 'alice', providerId: 'opencode' }], + }), + } as never); + + await expect(codexSource.load(sourceInput())).resolves.toMatchObject({ + status: 'skipped', + warnings: [{ code: 'codex_member_wide_not_supported' }], + }); + await expect(nonCodexSource.load(sourceInput())).resolves.toMatchObject({ + status: 'skipped', + warnings: [], + }); + }); +}); diff --git a/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts new file mode 100644 index 00000000..5dcbc046 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts @@ -0,0 +1,75 @@ +import { createHash } from 'crypto'; + +import type { + MemberLogStreamProvider, + MemberLogStreamSegmentSource, +} from '../../../../contracts'; +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, +} from '@shared/types'; + +export function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +export function normalizeTeamName(value: string): string { + return value.trim().toLowerCase(); +} + +export function buildMemberParticipant( + memberName: string, + role: 'member' | 'lead' = 'member' +): BoardTaskLogParticipant { + const isLead = role === 'lead'; + return { + key: `member:${normalizeMemberName(memberName)}`, + label: memberName, + role, + isLead, + isSidechain: !isLead, + }; +} + +export function buildMemberActor(input: { + memberName: string; + sessionId: string; + role?: 'member' | 'lead'; +}): BoardTaskLogActor { + const role = input.role ?? 'member'; + return { + memberName: input.memberName, + role, + sessionId: input.sessionId, + isSidechain: role !== 'lead', + }; +} + +export function shortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +export function buildSegmentId(input: { + provider: MemberLogStreamProvider; + teamName: string; + memberName: string; + sessionId: string; + fingerprint: string; + startTimestamp: string; +}): string { + return [ + input.provider, + normalizeTeamName(input.teamName), + normalizeMemberName(input.memberName), + input.sessionId, + shortHash(`${input.fingerprint}:${input.startTimestamp}`), + ].join(':'); +} + +export function withSegmentSource( + segment: T, + source: MemberLogStreamSegmentSource +): T & { source: MemberLogStreamSegmentSource } { + return { ...segment, source }; +} diff --git a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts new file mode 100644 index 00000000..7cbf3c3f --- /dev/null +++ b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts @@ -0,0 +1,75 @@ +import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; + +import { createEmptyMemberLogStreamResponse } from '../../contracts'; +import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; +import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase'; +import { ClaudeMemberTranscriptStreamSource } from '../adapters/output/sources/ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTraceStreamSource } from '../adapters/output/sources/CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; +import { isMemberLogStreamReadEnabled } from '../featureGates'; + +import type { MemberLogStreamResponse } from '../../contracts'; +import type { LoggerPort } from '../../core/application/ports/LoggerPort'; +import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort'; +import type { GetMemberLogStreamInput } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; +import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; +import type { TeamLogSourceTracker } from '@main/services/team/TeamLogSourceTracker'; +import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; + +export interface MemberLogStreamFeatureFacade { + getMemberLogStream(input: GetMemberLogStreamInput): Promise; + setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; +} + +class TeamLogSourceTrackerMemberStreamPort implements MemberLogStreamTrackingPort { + constructor(private readonly tracker: TeamLogSourceTracker) {} + + async setTracking(teamName: string, enabled: boolean): Promise { + if (enabled) { + await this.tracker.enableTracking(teamName, 'member_log_stream'); + return; + } + await this.tracker.disableTracking(teamName, 'member_log_stream'); + } +} + +export function createMemberLogStreamFeature(deps: { + logsFinder: TeamMemberLogsFinder; + logSourceTracker: TeamLogSourceTracker; + runtimeBridge: ClaudeMultimodelBridgeService; + configReader?: TeamConfigReader; + logger: LoggerPort; +}): MemberLogStreamFeatureFacade { + const chunkBuilder = new BoardTaskExactLogChunkBuilder(); + const sources = [ + new ClaudeMemberTranscriptStreamSource( + deps.logsFinder, + new BoardTaskExactLogStrictParser(), + chunkBuilder, + deps.logger + ), + new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder), + new CodexNativeMemberTraceStreamSource(deps.configReader ?? new TeamConfigReader()), + ]; + const getUseCase = new GetMemberLogStreamUseCase({ + sources, + clock: { now: () => Date.now() }, + logger: deps.logger, + }); + const trackingUseCase = new SetMemberLogStreamTrackingUseCase( + new TeamLogSourceTrackerMemberStreamPort(deps.logSourceTracker) + ); + + return { + getMemberLogStream: async (input) => { + if (!isMemberLogStreamReadEnabled()) { + return createEmptyMemberLogStreamResponse(); + } + return getUseCase.execute(input); + }, + setMemberLogStreamTracking: (teamName, enabled) => + trackingUseCase.execute(teamName, enabled), + }; +} diff --git a/src/features/member-log-stream/main/featureGates.ts b/src/features/member-log-stream/main/featureGates.ts new file mode 100644 index 00000000..29221150 --- /dev/null +++ b/src/features/member-log-stream/main/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isMemberLogStreamReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED, true); +} diff --git a/src/features/member-log-stream/main/index.ts b/src/features/member-log-stream/main/index.ts new file mode 100644 index 00000000..6eb427c3 --- /dev/null +++ b/src/features/member-log-stream/main/index.ts @@ -0,0 +1,8 @@ +export { + registerMemberLogStreamIpc, + removeMemberLogStreamIpc, +} from './adapters/input/ipc/registerMemberLogStreamIpc'; +export { + createMemberLogStreamFeature, + type MemberLogStreamFeatureFacade, +} from './composition/createMemberLogStreamFeature'; diff --git a/src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts b/src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts new file mode 100644 index 00000000..98f8623c --- /dev/null +++ b/src/features/member-log-stream/main/infrastructure/__tests__/memberLogMessageBudget.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../core/domain/models/MemberLogStreamBudget'; +import { applyMemberLogMessageBudget } from '../memberLogMessageBudget'; + +import type { MemberLogStreamBudget } from '../../../core/domain/models/MemberLogStreamBudget'; +import type { ParsedMessage } from '@main/types'; + +function budget(overrides: Partial): MemberLogStreamBudget { + return { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...overrides }; +} + +function message(overrides: Partial): ParsedMessage { + return { + uuid: overrides.uuid ?? 'msg-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-01T00:00:00.000Z'), + content: '', + isSidechain: true, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +describe('applyMemberLogMessageBudget', () => { + it('truncates oversized toolUseResult content, preserves ids, and reports content limiting', () => { + const result = applyMemberLogMessageBudget( + [ + message({ + type: 'user', + role: 'user', + isMeta: true, + sourceToolUseID: 'tool-1', + toolUseResult: { + toolUseId: 'tool-1', + content: 'x'.repeat(200), + stdout: 'y'.repeat(200), + }, + }), + ], + budget({ + maxToolResultContentChars: 80, + maxTotalContentChars: 120, + }) + ); + + const toolUseResult = result.messages[0]?.toolUseResult; + + expect(result.contentLimited).toBe(true); + expect(toolUseResult?.toolUseId).toBe('tool-1'); + expect(String(toolUseResult?.content)).toContain( + '[content truncated by member log stream budget]' + ); + expect(String(toolUseResult?.stdout)).toContain( + '[content truncated by member log stream budget]' + ); + }); + + it('drops orphan tool results after window trimming instead of rendering unpaired results', () => { + const result = applyMemberLogMessageBudget( + [ + message({ + uuid: 'assistant-1', + toolCalls: [{ id: 'tool-1', name: 'Bash', input: {}, isTask: false }], + }), + message({ + uuid: 'result-1', + type: 'user', + role: 'user', + isMeta: true, + sourceToolUseID: 'tool-1', + toolResults: [{ toolUseId: 'tool-1', content: 'done', isError: false }], + }), + ], + budget({ maxMessagesPerSegment: 1 }) + ); + + expect(result.segmentWindowLimited).toBe(true); + expect(result.messages).toEqual([]); + expect(result.droppedMessageCount).toBe(2); + }); + + it('keeps JSON-looking output visible when it does not exceed the content budget', () => { + const result = applyMemberLogMessageBudget( + [message({ content: '{"status":"ok","value":42}' })], + budget({ + maxMessageContentChars: 1_000, + maxTotalContentChars: 1_000, + }) + ); + + expect(result.contentLimited).toBe(false); + expect(result.messages[0]?.content).toBe('{"status":"ok","value":42}'); + }); +}); diff --git a/src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts b/src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts new file mode 100644 index 00000000..e20c6d5b --- /dev/null +++ b/src/features/member-log-stream/main/infrastructure/memberLogMessageBudget.ts @@ -0,0 +1,255 @@ +import type { MemberLogStreamBudget } from '../../core/domain/models/MemberLogStreamBudget'; +import type { ContentBlock, ParsedMessage, ToolResult, ToolUseResultData } from '@main/types'; + +export interface MessageBudgetResult { + messages: ParsedMessage[]; + droppedMessageCount: number; + segmentWindowLimited: boolean; + contentLimited: boolean; +} + +const CONTENT_LIMIT_SUFFIX = '\n\n[content truncated by member log stream budget]'; +const TOOL_RESULT_ID_KEYS = new Set([ + 'id', + 'toolUseId', + 'tool_use_id', + 'sourceToolUseID', + 'sourceToolAssistantUUID', + 'uuid', + 'parentUuid', +]); + +function truncateString(value: string, limit: number): { value: string; truncated: boolean } { + if (value.length <= limit) { + return { value, truncated: false }; + } + const allowed = Math.max(0, limit - CONTENT_LIMIT_SUFFIX.length); + return { value: `${value.slice(0, allowed)}${CONTENT_LIMIT_SUFFIX}`, truncated: true }; +} + +function buildAssistantToolUseIds(messages: readonly ParsedMessage[]): Set { + const ids = new Set(); + for (const message of messages) { + if (message.type !== 'assistant') { + continue; + } + for (const toolCall of message.toolCalls) { + ids.add(toolCall.id); + } + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use') { + ids.add(block.id); + } + } + } + } + return ids; +} + +function dropOrphanToolResults(messages: readonly ParsedMessage[]): ParsedMessage[] { + const assistantToolUseIds = buildAssistantToolUseIds(messages); + return messages.filter((message) => { + if (!message.isMeta && message.toolResults.length === 0 && !message.sourceToolUseID) { + return true; + } + const toolUseIds = [ + message.sourceToolUseID, + ...message.toolResults.map((toolResult) => toolResult.toolUseId), + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + if (toolUseIds.length === 0) { + return true; + } + return toolUseIds.some((toolUseId) => assistantToolUseIds.has(toolUseId)); + }); +} + +function trimMessageWindow( + messages: readonly ParsedMessage[], + maxMessages: number +): { messages: ParsedMessage[]; droppedMessageCount: number; limited: boolean } { + if (messages.length <= maxMessages) { + return { messages: [...messages], droppedMessageCount: 0, limited: false }; + } + const sliced = messages.slice(-maxMessages); + const paired = dropOrphanToolResults(sliced); + return { + messages: paired, + droppedMessageCount: messages.length - paired.length, + limited: true, + }; +} + +function truncateContentBlock( + block: ContentBlock, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { block: ContentBlock; truncated: boolean } { + if (total.remaining <= 0) { + if (block.type === 'text') { + return { block: { ...block, text: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true }; + } + if (block.type === 'thinking') { + return { block: { ...block, thinking: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true }; + } + if (block.type === 'tool_result') { + return { block: { ...block, content: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true }; + } + return { block, truncated: false }; + } + + if (block.type === 'text') { + const limit = Math.min(budget.maxMessageContentChars, total.remaining); + const truncated = truncateString(block.text, limit); + total.remaining -= truncated.value.length; + return { block: { ...block, text: truncated.value }, truncated: truncated.truncated }; + } + + if (block.type === 'thinking') { + const limit = Math.min(budget.maxMessageContentChars, total.remaining); + const truncated = truncateString(block.thinking, limit); + total.remaining -= truncated.value.length; + return { block: { ...block, thinking: truncated.value }, truncated: truncated.truncated }; + } + + if (block.type === 'tool_result') { + if (typeof block.content === 'string') { + const limit = Math.min(budget.maxToolResultContentChars, total.remaining); + const truncated = truncateString(block.content, limit); + total.remaining -= truncated.value.length; + return { block: { ...block, content: truncated.value }, truncated: truncated.truncated }; + } + const nested = block.content.map((item) => truncateContentBlock(item, budget, total)); + return { + block: { ...block, content: nested.map((item) => item.block) }, + truncated: nested.some((item) => item.truncated), + }; + } + + return { block, truncated: false }; +} + +function truncateToolResult( + toolResult: ToolResult, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { toolResult: ToolResult; truncated: boolean } { + if (typeof toolResult.content !== 'string') { + return { toolResult, truncated: false }; + } + const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining)); + const truncated = truncateString(toolResult.content, limit); + total.remaining -= truncated.value.length; + return { + toolResult: { ...toolResult, content: truncated.value }, + truncated: truncated.truncated, + }; +} + +function truncateUnknownToolResultValue( + value: unknown, + budget: MemberLogStreamBudget, + total: { remaining: number }, + key?: string +): { value: unknown; truncated: boolean } { + if (typeof value === 'string') { + if (key && TOOL_RESULT_ID_KEYS.has(key)) { + return { value, truncated: false }; + } + const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining)); + const truncated = truncateString(value, limit); + total.remaining = Math.max(0, total.remaining - truncated.value.length); + return { value: truncated.value, truncated: truncated.truncated }; + } + + if (Array.isArray(value)) { + let truncated = false; + const mapped = value.map((item) => { + const result = truncateUnknownToolResultValue(item, budget, total); + truncated = truncated || result.truncated; + return result.value; + }); + return { value: mapped, truncated }; + } + + if (value && typeof value === 'object') { + let truncated = false; + const mapped: Record = {}; + for (const [childKey, childValue] of Object.entries(value)) { + const result = truncateUnknownToolResultValue(childValue, budget, total, childKey); + truncated = truncated || result.truncated; + mapped[childKey] = result.value; + } + return { value: mapped, truncated }; + } + + return { value, truncated: false }; +} + +function truncateToolUseResult( + toolUseResult: ToolUseResultData | undefined, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { toolUseResult: ToolUseResultData | undefined; truncated: boolean } { + if (!toolUseResult) { + return { toolUseResult, truncated: false }; + } + const result = truncateUnknownToolResultValue(toolUseResult, budget, total); + return { + toolUseResult: result.value as ToolUseResultData, + truncated: result.truncated, + }; +} + +function truncateMessageContent( + message: ParsedMessage, + budget: MemberLogStreamBudget, + total: { remaining: number } +): { message: ParsedMessage; truncated: boolean } { + let truncated = false; + let content: ParsedMessage['content']; + if (typeof message.content === 'string') { + const limit = Math.min(budget.maxMessageContentChars, Math.max(0, total.remaining)); + const result = truncateString(message.content, limit); + total.remaining -= result.value.length; + truncated = result.truncated; + content = result.value; + } else { + const mapped = message.content.map((block) => truncateContentBlock(block, budget, total)); + truncated = mapped.some((item) => item.truncated); + content = mapped.map((item) => item.block); + } + + const toolResults = message.toolResults.map((toolResult) => + truncateToolResult(toolResult, budget, total) + ); + const toolUseResult = truncateToolUseResult(message.toolUseResult, budget, total); + + return { + message: { + ...message, + content, + toolResults: toolResults.map((item) => item.toolResult), + ...(toolUseResult.toolUseResult ? { toolUseResult: toolUseResult.toolUseResult } : {}), + }, + truncated: + truncated || + toolResults.some((item) => item.truncated) || + toolUseResult.truncated, + }; +} + +export function applyMemberLogMessageBudget( + messages: readonly ParsedMessage[], + budget: MemberLogStreamBudget +): MessageBudgetResult { + const windowed = trimMessageWindow(messages, budget.maxMessagesPerSegment); + const total = { remaining: budget.maxTotalContentChars }; + const truncated = windowed.messages.map((message) => truncateMessageContent(message, budget, total)); + return { + messages: truncated.map((item) => item.message), + droppedMessageCount: windowed.droppedMessageCount, + segmentWindowLimited: windowed.limited, + contentLimited: truncated.some((item) => item.truncated), + }; +} diff --git a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts new file mode 100644 index 00000000..d6d955f1 --- /dev/null +++ b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_SET_TRACKING, +} from '../../contracts'; +import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge'; + +const mocks = vi.hoisted(() => ({ + ipcRenderer: { + invoke: vi.fn(), + }, +})); + +vi.mock('electron', () => ({ + ipcRenderer: mocks.ipcRenderer, +})); + +describe('createMemberLogStreamBridge', () => { + beforeEach(() => { + mocks.ipcRenderer.invoke.mockReset(); + }); + + it('forwards member log stream IPC requests and normalizes response payloads', async () => { + mocks.ipcRenderer.invoke.mockResolvedValueOnce({ + success: true, + data: { + participants: [], + segments: [], + generatedAt: '2026-04-02T00:00:00.000Z', + }, + }); + const bridge = createMemberLogStreamBridge(); + + const response = await bridge.getMemberLogStream('alpha-team', 'alice', { + limitSegments: 30, + laneId: 'secondary:opencode:alice', + forceRefresh: true, + }); + + expect(response).toMatchObject({ + participants: [], + segments: [], + source: 'member_empty', + generatedAt: '2026-04-02T00:00:00.000Z', + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith( + MEMBER_LOG_STREAM_GET, + 'alpha-team', + 'alice', + { + limitSegments: 30, + laneId: 'secondary:opencode:alice', + forceRefresh: true, + } + ); + }); + + it('forwards tracking calls and throws IPC errors', async () => { + mocks.ipcRenderer.invoke + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'bad lane' }); + const bridge = createMemberLogStreamBridge(); + + await expect(bridge.setMemberLogStreamTracking('alpha-team', true)).resolves.toBeUndefined(); + await expect(bridge.getMemberLogStream('alpha-team', 'alice')).rejects.toThrow('bad lane'); + + expect(mocks.ipcRenderer.invoke).toHaveBeenNthCalledWith( + 1, + MEMBER_LOG_STREAM_SET_TRACKING, + 'alpha-team', + true + ); + }); +}); diff --git a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts new file mode 100644 index 00000000..fa971599 --- /dev/null +++ b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts @@ -0,0 +1,42 @@ +import { ipcRenderer } from 'electron'; + +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogStreamResponse, +} from '../contracts'; + +import type { + MemberLogStreamApi, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from '../contracts'; +import type { IpcResult } from '@shared/types'; + +async function invokeIpcWithResult(channel: string, ...args: unknown[]): Promise { + const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult; + if (!result.success) { + throw new Error(result.error ?? 'Unknown error'); + } + return result.data as T; +} + +export function createMemberLogStreamBridge(): MemberLogStreamApi { + return { + getMemberLogStream: async ( + teamName: string, + memberName: string, + options?: MemberLogStreamRequestOptions + ): Promise => + normalizeMemberLogStreamResponse( + await invokeIpcWithResult( + MEMBER_LOG_STREAM_GET, + teamName, + memberName, + options + ) + ), + setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise => + invokeIpcWithResult(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled), + }; +} diff --git a/src/features/member-log-stream/preload/index.ts b/src/features/member-log-stream/preload/index.ts new file mode 100644 index 00000000..e5c78985 --- /dev/null +++ b/src/features/member-log-stream/preload/index.ts @@ -0,0 +1 @@ +export { createMemberLogStreamBridge } from './createMemberLogStreamBridge'; diff --git a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx new file mode 100644 index 00000000..fe3aed25 --- /dev/null +++ b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx @@ -0,0 +1,78 @@ +import { useEffect, useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; + +import { useMemberLogStream } from '../hooks/useMemberLogStream'; +import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView'; + +import type { MemberLogStreamSegment } from '../../contracts'; +import type { ResolvedTeamMember } from '@shared/types'; + +interface MemberLogStreamSectionProps { + teamName: string; + member: ResolvedTeamMember; + enabled?: boolean; + onInitialLoadErrorChange?: (hasError: boolean) => void; +} + +function describeMemberStream(): string { + return 'Member-scoped transcript and runtime logs rendered with the same execution-log components used in Task Log Stream.'; +} + +function getSegmentMetaLabel(segment: MemberLogStreamSegment): string { + const details = [segment.source.label]; + if (segment.source.laneId) { + details.push(`lane ${segment.source.laneId}`); + } else if (segment.source.sessionId) { + details.push(`session ${segment.source.sessionId.slice(0, 8)}`); + } + return details.join(' · '); +} + +function buildMemberSegmentRenderKey(segment: MemberLogStreamSegment): string { + const firstChunkId = segment.chunks[0]?.id; + return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`; +} + +export function MemberLogStreamSection({ + teamName, + member, + enabled = true, + onInitialLoadErrorChange, +}: Readonly): React.JSX.Element { + const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); + const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled }); + const hasInitialLoadError = Boolean(error && !stream && !loading); + const boundedHistoryNote = useMemo(() => { + if (!stream) return null; + const isBounded = + stream.truncated || + stream.warnings.some((warning) => warning.code === 'large_log_window_limited'); + return isBounded ? 'Showing a bounded recent member log stream.' : null; + }, [stream]); + + useEffect(() => { + onInitialLoadErrorChange?.(hasInitialLoadError); + }, [hasInitialLoadError, onInitialLoadErrorChange]); + + return ( + + ); +} diff --git a/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx b/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx new file mode 100644 index 00000000..3d9fb6a1 --- /dev/null +++ b/src/features/member-log-stream/renderer/hooks/__tests__/useMemberLogStream.test.tsx @@ -0,0 +1,326 @@ +import React, { act, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMemberLogStream } from '../useMemberLogStream'; + +import type { MemberLogStreamResponse } from '../../../contracts'; +import type { ResolvedTeamMember } from '@shared/types'; + +const apiMock = vi.hoisted(() => ({ + memberLogStream: { + getMemberLogStream: vi.fn(), + setMemberLogStreamTracking: vi.fn(), + }, + teams: { + onTeamChange: vi.fn(), + }, +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function member(name: string): ResolvedTeamMember { + return { + name, + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; +} + +function response(generatedAt: string): MemberLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt, + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }; +} + +const HookProbe = ({ + teamName, + selectedMember, + enabled = true, + onState, +}: { + teamName: string; + selectedMember: ResolvedTeamMember; + enabled?: boolean; + onState: (state: ReturnType) => void; +}): React.JSX.Element | null => { + const state = useMemberLogStream({ teamName, member: selectedMember, enabled }); + useEffect(() => { + onState(state); + }, [onState, state]); + return null; +}; + +describe('useMemberLogStream', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiMock.memberLogStream.getMemberLogStream.mockReset(); + apiMock.memberLogStream.setMemberLogStreamTracking.mockReset(); + apiMock.memberLogStream.setMemberLogStreamTracking.mockResolvedValue(undefined); + apiMock.teams.onTeamChange.mockReset(); + apiMock.teams.onTeamChange.mockReturnValue(() => undefined); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('does not let an older in-flight member request drive a pending reload after member key changes', async () => { + const aliceLoad = createDeferred(); + const bobLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogStream + .mockReturnValueOnce(aliceLoad.promise) + .mockReturnValueOnce(bobLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onState = vi.fn((_: ReturnType) => undefined); + const latestState = (): ReturnType | undefined => + onState.mock.calls.at(-1)?.[0]; + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + const requestedMembers = apiMock.memberLogStream.getMemberLogStream.mock.calls.map( + (call: unknown[]) => String(call[1]) + ); + expect(requestedMembers).toEqual(['alice', 'bob']); + + await act(async () => { + aliceLoad.resolve(response('2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + + expect(latestState()?.stream).toBeNull(); + + await act(async () => { + bobLoad.resolve(response('2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + + expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:01:00.000Z'); + + act(() => { + root.unmount(); + }); + }); + + it('reloads on same-team log events with forceRefresh only for source changes', async () => { + vi.useFakeTimers(); + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogStream + .mockResolvedValueOnce(response('2026-04-03T00:00:00.000Z')) + .mockResolvedValueOnce(response('2026-04-03T00:01:00.000Z')) + .mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'other-team', type: 'log-source-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith( + 'alpha-team', + 'alice', + expect.objectContaining({ forceRefresh: true }) + ); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(3); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith( + 'alpha-team', + 'alice', + expect.not.objectContaining({ forceRefresh: true }) + ); + + act(() => { + root.unmount(); + }); + }); + + it('releases stale in-flight state when the section is disabled before a request finishes', async () => { + const firstLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogStream + .mockReturnValueOnce(firstLoad.promise) + .mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onState = vi.fn((_: ReturnType) => undefined); + const latestState = (): ReturnType | undefined => + onState.mock.calls.at(-1)?.[0]; + const selectedMember = member('alice'); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + await act(async () => { + firstLoad.resolve(response('2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.stream).toBeNull(); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2); + expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:02:00.000Z'); + + act(() => { + root.unmount(); + }); + }); + + it('passes an OpenCode lane only for OpenCode-owned members', async () => { + apiMock.memberLogStream.getMemberLogStream.mockResolvedValue( + response('2026-04-03T00:00:00.000Z') + ); + const staleLaneMember: ResolvedTeamMember = { + ...member('alice'), + providerId: 'anthropic', + laneId: 'secondary:opencode:alice', + laneOwnerProviderId: 'opencode', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + + const request = apiMock.memberLogStream.getMemberLogStream.mock.calls[0] as + | [string, string, { laneId?: unknown }] + | undefined; + expect(request?.[0]).toBe('alpha-team'); + expect(request?.[1]).toBe('alice'); + expect(request?.[2].laneId).toBeUndefined(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts b/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts new file mode 100644 index 00000000..96adf5e6 --- /dev/null +++ b/src/features/member-log-stream/renderer/hooks/useMemberLogStream.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; + +import { + type MemberLogStreamRequestOptions, + type MemberLogStreamResponse, + normalizeMemberLogStreamResponse, +} from '../../contracts'; +import { normalizeExecutionLogStream } from '../ui/ExecutionLogStreamView'; + +import type { ResolvedTeamMember } from '@shared/types'; + +const LIVE_RELOAD_DEBOUNCE_MS = 650; + +function getSafeOpenCodeLaneId(member: ResolvedTeamMember): string | undefined { + if (member.providerId !== 'opencode') return undefined; + if (member.laneOwnerProviderId !== 'opencode') return undefined; + const laneId = member.laneId?.trim(); + return laneId ? laneId : undefined; +} + +export function useMemberLogStream(input: { + teamName: string; + member: ResolvedTeamMember; + enabled?: boolean; +}): { + stream: MemberLogStreamResponse | null; + loading: boolean; + error: string | null; + reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise; +} { + const enabled = input.enabled ?? true; + const [stream, setStream] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const streamRef = useRef(null); + const activeLoadKeyRef = useRef(null); + const pendingReloadRef = useRef<{ key: string; forceRefresh?: boolean } | null>(null); + const reloadTimerRef = useRef | null>(null); + const requestSeqRef = useRef(0); + const memberName = input.member.name; + const openCodeLaneId = getSafeOpenCodeLaneId(input.member); + const streamKey = `${input.teamName}:${memberName}:${openCodeLaneId ?? ''}`; + + useEffect(() => { + streamRef.current = stream; + }, [stream]); + + const loadStream = useCallback( + async (options?: { forceRefresh?: boolean; background?: boolean }): Promise => { + if (!enabled) return; + + if (activeLoadKeyRef.current === streamKey) { + const existingPending = pendingReloadRef.current; + pendingReloadRef.current = { + key: streamKey, + forceRefresh: + (existingPending?.key === streamKey && existingPending.forceRefresh) || + options?.forceRefresh, + }; + return; + } + + activeLoadKeyRef.current = streamKey; + const background = options?.background ?? false; + const hadExistingStream = streamRef.current != null; + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + + if (!background) setLoading(true); + setError((prev) => (background ? prev : null)); + + try { + const requestOptions: MemberLogStreamRequestOptions = { + limitSegments: 30, + ...(options?.forceRefresh ? { forceRefresh: true } : {}), + }; + if (openCodeLaneId) { + requestOptions.laneId = openCodeLaneId; + } + + const response = normalizeExecutionLogStream( + normalizeMemberLogStreamResponse( + await api.memberLogStream.getMemberLogStream(input.teamName, memberName, requestOptions) + ) + ); + if (requestSeqRef.current !== requestSeq) return; + + setStream(response); + setError(null); + } catch (loadError) { + if (requestSeqRef.current !== requestSeq) return; + if (!background || streamRef.current == null) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load member log stream' + ); + setStream(null); + } + } finally { + const isCurrentRequest = + requestSeqRef.current === requestSeq && activeLoadKeyRef.current === streamKey; + if (isCurrentRequest && (!background || !hadExistingStream)) { + setLoading(false); + } + if (isCurrentRequest) { + activeLoadKeyRef.current = null; + } + const pending = pendingReloadRef.current; + if (pending?.key === streamKey) { + pendingReloadRef.current = null; + } + if (isCurrentRequest && pending?.key === streamKey && enabled) { + void loadStream({ background: true, forceRefresh: pending.forceRefresh }); + } + } + }, + [enabled, input.teamName, memberName, openCodeLaneId, streamKey] + ); + + useEffect(() => { + requestSeqRef.current += 1; + setStream(null); + streamRef.current = null; + setError(null); + setLoading(enabled); + pendingReloadRef.current = null; + activeLoadKeyRef.current = null; + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + if (enabled) { + void loadStream(); + } + }, [enabled, streamKey, loadStream]); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void api.memberLogStream + .setMemberLogStreamTracking(input.teamName, true) + .catch(() => undefined); + return () => { + if (cancelled) return; + cancelled = true; + void api.memberLogStream + .setMemberLogStreamTracking(input.teamName, false) + .catch(() => undefined); + }; + }, [enabled, input.teamName]); + + useEffect(() => { + if (!enabled) return; + + const scheduleReload = (forceRefresh: boolean): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + void loadStream({ background: true, forceRefresh }); + }, LIVE_RELOAD_DEBOUNCE_MS); + }; + + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + if (event.teamName !== input.teamName) return; + if (event.type === 'log-source-change') { + scheduleReload(true); + return; + } + if (event.type === 'task-log-change') { + scheduleReload(false); + } + }); + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') scheduleReload(false); + }; + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + return () => { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + if (typeof unsubscribe === 'function') unsubscribe(); + }; + }, [enabled, input.teamName, loadStream]); + + return { stream, loading, error, reload: loadStream }; +} diff --git a/src/features/member-log-stream/renderer/index.ts b/src/features/member-log-stream/renderer/index.ts new file mode 100644 index 00000000..c9cc9831 --- /dev/null +++ b/src/features/member-log-stream/renderer/index.ts @@ -0,0 +1,7 @@ +export { MemberLogStreamSection } from './adapters/MemberLogStreamSection'; +export { + buildDefaultExecutionSegmentRenderKey, + ExecutionLogStreamView, + normalizeExecutionLogStream, +} from './ui/ExecutionLogStreamView'; +export { isMemberLogStreamUiEnabled } from './utils/featureGates'; diff --git a/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx new file mode 100644 index 00000000..217156a1 --- /dev/null +++ b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx @@ -0,0 +1,369 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { + getTeamColorSet, + getThemedBadge, + getThemedBorder, + getThemedText, +} from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { isLeadMember } from '@shared/utils/leadDetection'; +import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; + +import type { + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + ResolvedTeamMember, +} from '@shared/types'; + +interface ExecutionLogStreamLike { + participants: BoardTaskLogParticipant[]; + defaultFilter: string; + segments: BoardTaskLogSegment[]; +} + +interface ParticipantVisual { + name: string; + color?: string; +} + +export interface ExecutionLogStreamViewProps { + title: string; + description: string; + stream: TStream | null; + loading: boolean; + error: string | null; + teamName: string; + teamMembers: readonly ResolvedTeamMember[]; + loadingText: string; + emptyTitle: string; + emptyDescription: string; + selectionResetKey: string; + boundedHistoryNote?: string | null; + forceSegmentHeaders?: boolean; + buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string; + getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null; +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHours / 24); + + if (!Number.isFinite(diffMs)) return '--'; + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function actorLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) return actor.memberName; + if (actor.role === 'lead' || actor.isSidechain === false) return 'lead session'; + if (actor.agentId) return `member ${actor.agentId.slice(0, 8)}`; + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +export function normalizeExecutionLogStream( + response: TStream +): TStream { + return { + ...response, + segments: response.segments.map((segment) => ({ + ...segment, + chunks: asEnhancedChunkArray(segment.chunks) ?? [], + })), + }; +} + +export function buildDefaultExecutionSegmentRenderKey(segment: BoardTaskLogSegment): string { + const firstChunkId = segment.chunks[0]?.id; + if (firstChunkId) { + return `${segment.participantKey}:${firstChunkId}`; + } + return `${segment.participantKey}:${segment.startTimestamp}`; +} + +function buildParticipantVisualMap( + stream: ExecutionLogStreamLike | null, + members: readonly ResolvedTeamMember[], + memberColorMap: ReadonlyMap +): Map { + const visuals = new Map(); + const leadMember = members.find((member) => isLeadMember(member)); + + for (const participant of stream?.participants ?? []) { + const matchingSegment = stream?.segments.find( + (segment) => segment.participantKey === participant.key + ); + const name = + matchingSegment?.actor.memberName ?? + (participant.isLead ? leadMember?.name : undefined) ?? + participant.label; + + visuals.set(participant.key, { + name, + color: memberColorMap.get(name) ?? memberColorMap.get(participant.label), + }); + } + + for (const segment of stream?.segments ?? []) { + if (visuals.has(segment.participantKey)) continue; + const name = segment.actor.memberName ?? actorLabel(segment.actor); + visuals.set(segment.participantKey, { name, color: memberColorMap.get(name) }); + } + + return visuals; +} + +const SegmentMarker = ({ + segment, + visual, + teamName, + metaLabel, +}: { + segment: TSegment; + visual?: ParticipantVisual; + teamName: string; + metaLabel?: string | null; +}): React.JSX.Element => ( +
+ {visual ? ( + + ) : null} + {metaLabel ? {metaLabel} : null} + + + {formatRelativeTime(segment.endTimestamp)} + +
+); + +const SegmentBlock = ({ + segment, + showHeader, + teamName, + visual, + metaLabel, +}: { + segment: TSegment; + showHeader: boolean; + teamName: string; + visual?: ParticipantVisual; + metaLabel?: string | null; +}): React.JSX.Element => ( +
+ {showHeader ? ( + + ) : null} + +
+); + +const ParticipantFilterChip = ({ + label, + selected, + visual, + teamName, + onClick, +}: { + label: string; + selected: boolean; + visual?: ParticipantVisual; + teamName: string; + onClick: () => void; +}): React.JSX.Element => { + const { isLight } = useTheme(); + const colors = getTeamColorSet(visual?.color ?? ''); + const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)'; + const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent'; + const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)'; + + return ( + + ); +}; + +export function ExecutionLogStreamView({ + title, + description, + stream, + loading, + error, + teamName, + teamMembers, + loadingText, + emptyTitle, + emptyDescription, + selectionResetKey, + boundedHistoryNote, + forceSegmentHeaders = false, + buildSegmentRenderKey, + getSegmentMetaLabel, +}: Readonly>): React.JSX.Element { + const [selectedParticipantKey, setSelectedParticipantKey] = useState('all'); + const participants = stream?.participants ?? []; + const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]); + const participantVisuals = useMemo( + () => buildParticipantVisualMap(stream, teamMembers, memberColorMap), + [memberColorMap, stream, teamMembers] + ); + + useEffect(() => { + if (!stream) { + setSelectedParticipantKey('all'); + return; + } + setSelectedParticipantKey(stream.defaultFilter); + }, [selectionResetKey, stream]); + + useEffect(() => { + if (!stream) return; + const availableParticipantKeys = new Set([ + 'all', + ...stream.participants.map((participant) => participant.key), + ]); + setSelectedParticipantKey((prev) => + availableParticipantKeys.has(prev) ? prev : stream.defaultFilter + ); + }, [stream]); + + const showChips = participants.length > 1; + const visibleSegments = useMemo(() => { + const source = stream?.segments ?? []; + const filtered = + selectedParticipantKey === 'all' + ? source + : source.filter((segment) => segment.participantKey === selectedParticipantKey); + return [...filtered].reverse(); + }, [selectedParticipantKey, stream?.segments]); + + const showSegmentHeaders = + forceSegmentHeaders || + participants.length > 1 || + (selectedParticipantKey !== 'all' && visibleSegments.length > 1); + const renderKey = buildSegmentRenderKey ?? buildDefaultExecutionSegmentRenderKey; + + if (loading) { + return ( +
+

+ {title} +

+
+ + {loadingText} +
+
+ ); + } + + if (error) { + return ( +
+

+ {title} +

+
+ + {error} +
+
+ ); + } + + return ( +
+

+ {title} +

+

{description}

+ {boundedHistoryNote ? ( +

{boundedHistoryNote}

+ ) : null} + + {showChips ? ( +
+ + {participants.map((participant) => ( + setSelectedParticipantKey(participant.key)} + /> + ))} +
+ ) : null} + + {visibleSegments.length === 0 ? ( +
+ + {emptyTitle} +

{emptyDescription}

+
+ ) : ( +
+ {visibleSegments.map((segment) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/member-log-stream/renderer/utils/featureGates.ts b/src/features/member-log-stream/renderer/utils/featureGates.ts new file mode 100644 index 00000000..d584844f --- /dev/null +++ b/src/features/member-log-stream/renderer/utils/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: unknown, defaultValue: boolean): boolean { + if (typeof value !== 'string') { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isMemberLogStreamUiEnabled(): boolean { + return readEnabledFlag(import.meta.env.VITE_MEMBER_LOG_STREAM_UI_ENABLED, true); +} diff --git a/src/main/index.ts b/src/main/index.ts index d6722651..37e66234 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,11 @@ import { type CodexModelCatalogFeatureFacade, createCodexModelCatalogFeature, } from '@features/codex-model-catalog/main'; +import { + createMemberLogStreamFeature, + registerMemberLogStreamIpc, + removeMemberLogStreamIpc, +} from '@features/member-log-stream/main'; import { buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, @@ -49,6 +54,7 @@ import { removeRuntimeProviderManagementIpc, type RuntimeProviderManagementFeatureFacade, } from '@features/runtime-provider-management/main'; +import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; @@ -1138,6 +1144,13 @@ async function initializeServices(): Promise { undefined, teamTranscriptSourceLocator ); + const memberLogStreamFeature = createMemberLogStreamFeature({ + logsFinder: teamMemberLogsFinder, + logSourceTracker: teamLogSourceTracker, + runtimeBridge: new ClaudeMultimodelBridgeService(), + configReader: taskLogConfigReader, + logger: createLogger('Feature:MemberLogStream'), + }); const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( teamMemberLogsFinder ); @@ -1483,6 +1496,7 @@ async function initializeServices(): Promise { registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature); + registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { @@ -1672,6 +1686,7 @@ async function shutdownServices(): Promise { removeRecentProjectsIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain); removeMemberWorkSyncIpc(ipcMain); + removeMemberLogStreamIpc(ipcMain); }); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 44b7ca31..ceef49d8 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -925,6 +925,8 @@ export class ClaudeMultimodelBridgeService { teamId: string; memberName: string; limit?: number; + laneId?: string; + timeoutMs?: number; } ): Promise { const { env } = await this.buildCliEnv(binaryPath); @@ -943,12 +945,15 @@ export class ClaudeMultimodelBridgeService { if (typeof params.limit === 'number') { args.push('--limit', String(params.limit)); } + if (typeof params.laneId === 'string' && params.laneId.trim().length > 0) { + args.push('--lane', params.laneId.trim()); + } const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-')); const outputPath = path.join(outputDir, 'transcript.json'); try { await execCli(binaryPath, [...args, '--output', outputPath], { - timeout: PROVIDER_STATUS_TIMEOUT_MS, + timeout: params.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS, env, }); const parsed = extractJsonObject( diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 6435907f..48de3c74 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -39,6 +39,7 @@ export type TeamLogSourceTrackingConsumer = | 'change_presence' | 'tool_activity' | 'task_log_stream' + | 'member_log_stream' | 'stall_monitor'; interface TrackingState { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 3e1828b9..ccb6eb16 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -111,8 +111,19 @@ export interface MemberLogFileRef { sessionId: string; filePath: string; mtimeMs: number; + sizeBytes?: number; + messageCount?: number; + kind?: 'lead_session' | 'member_session' | 'subagent'; } +type FindRecentMemberLogFileRefsOptions = + | number + | null + | { + mtimeSinceMs?: number | null; + forceRefresh?: boolean; + }; + export interface TeamLogSourceLiveContext { projectDir: string; projectPath?: string; @@ -935,8 +946,15 @@ export class TeamMemberLogsFinder { async findRecentMemberLogFileRefsByMember( teamName: string, memberNames: readonly string[], - mtimeSinceMs?: number | null + options?: FindRecentMemberLogFileRefsOptions ): Promise { + const parsedOptions = + typeof options === 'number' || options === null + ? { mtimeSinceMs: options ?? null, forceRefresh: false } + : { + mtimeSinceMs: options?.mtimeSinceMs ?? null, + forceRefresh: options?.forceRefresh === true, + }; const requestedMembersByKey = new Map(); for (const memberName of memberNames) { const trimmed = memberName.trim(); @@ -952,12 +970,18 @@ export class TeamMemberLogsFinder { return []; } - const discovery = await this.discoverProjectSessions(teamName); + const discovery = await this.discoverProjectSessions(teamName, { + forceRefresh: parsedOptions.forceRefresh, + }); if (!discovery) { return []; } const { projectDir, sessionIds, knownMembers, config } = discovery; + const scopedKnownMembers = new Set(knownMembers); + for (const memberKey of requestedMembersByKey.keys()) { + scopedKnownMembers.add(memberKey); + } const refs: MemberLogFileRef[] = []; const seenFilePaths = new Set(); const pushRef = (ref: MemberLogFileRef): void => { @@ -975,12 +999,17 @@ export class TeamMemberLogsFinder { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { const stat = await fs.stat(leadJsonl); - if (stat.isFile()) { + if ( + stat.isFile() && + (parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs) + ) { pushRef({ memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName, sessionId: config.leadSessionId, filePath: leadJsonl, mtimeMs: stat.mtimeMs, + sizeBytes: stat.size, + kind: 'lead_session', }); } } catch { @@ -995,20 +1024,20 @@ export class TeamMemberLogsFinder { if (!stat.isFile()) { return null; } - if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) { + if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) { return null; } const attribution = candidate.kind === 'subagent' ? await this.getCachedSubagentAttribution( candidate.filePath, - knownMembers, + scopedKnownMembers, stat.mtimeMs ) : await this.getCachedMemberSessionAttribution( candidate.filePath, teamName, - knownMembers, + scopedKnownMembers, stat.mtimeMs ); if (!attribution) { @@ -1024,6 +1053,8 @@ export class TeamMemberLogsFinder { sessionId: candidate.sessionId, filePath: candidate.filePath, mtimeMs: stat.mtimeMs, + sizeBytes: stat.size, + kind: candidate.kind, } satisfies MemberLogFileRef; } catch { return null; diff --git a/src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts b/src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts new file mode 100644 index 00000000..0bf0f8f8 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper.ts @@ -0,0 +1,126 @@ +import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; + +import type { + OpenCodeRuntimeTranscriptLogContentBlock, + OpenCodeRuntimeTranscriptLogMessage, +} from '../../../runtime/ClaudeMultimodelBridgeService'; +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; + +function mapOpenCodeContentBlock( + block: OpenCodeRuntimeTranscriptLogContentBlock +): ContentBlock | null { + switch (block.type) { + case 'text': { + const text = sanitizeDisplayContent(block.text); + return text.length > 0 ? { type: 'text', text } : null; + } + case 'thinking': + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + }; + case 'tool_use': + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }; + case 'tool_result': + return { + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: Array.isArray(block.content) + ? block.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null) + : block.content, + ...(block.is_error ? { is_error: true } : {}), + }; + default: + return null; + } +} + +function buildToolUseResultData( + message: OpenCodeRuntimeTranscriptLogMessage +): ToolUseResultData | undefined { + if (!message.sourceToolUseID || message.toolResults.length !== 1) { + return undefined; + } + + const toolResult = message.toolResults[0]; + if (!toolResult) { + return undefined; + } + + return { + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + }; +} + +export function mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage( + message: OpenCodeRuntimeTranscriptLogMessage +): ParsedMessage | null { + const timestamp = new Date(message.timestamp); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + const normalizedContent: ContentBlock[] | string = + typeof message.content === 'string' + ? sanitizeDisplayContent(message.content) + : message.content + .map(mapOpenCodeContentBlock) + .filter((item): item is ContentBlock => item !== null); + + const toolCalls = message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + input: toolCall.input, + isTask: toolCall.isTask, + ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), + ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), + })); + + const toolResults = message.toolResults.map((toolResult) => ({ + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + })); + const toolUseResult = buildToolUseResultData(message); + + return { + uuid: message.uuid, + parentUuid: message.parentUuid, + type: message.type, + timestamp, + role: message.role, + content: normalizedContent, + model: message.model, + agentName: message.agentName, + isSidechain: true, + isMeta: message.isMeta, + sessionId: message.sessionId, + toolCalls, + toolResults, + ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), + ...(message.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + ...(message.subtype ? { subtype: message.subtype } : {}), + ...(message.level ? { level: message.level } : {}), + }; +} + +export function mapOpenCodeRuntimeTranscriptMessagesToParsedMessages( + messages: readonly OpenCodeRuntimeTranscriptLogMessage[] +): ParsedMessage[] { + return messages + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) + .filter((message): message is ParsedMessage => message !== null); +} diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index bea26f9b..093f9f2e 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -1,4 +1,3 @@ -import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; @@ -7,17 +6,15 @@ import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver'; import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper'; import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; -import type { - OpenCodeRuntimeTranscriptLogContentBlock, - OpenCodeRuntimeTranscriptLogMessage, -} from '../../../runtime/ClaudeMultimodelBridgeService'; +import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService'; import type { OpenCodeTaskLogAttributionReader, OpenCodeTaskLogAttributionRecord, } from './OpenCodeTaskLogAttributionStore'; -import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; +import type { ParsedMessage } from '@main/types'; import type { BoardTaskLogActor, BoardTaskLogParticipant, @@ -431,7 +428,7 @@ function hasForeignTeamTaskMarker( } return projectedMessages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null) .some((message) => message.toolCalls.some((toolCall) => { @@ -758,7 +755,7 @@ function buildTaskMarkerProjection( ): TaskMarkerProjection | null { const parsedMessages = sortParsedMessagesByTime( projectedMessages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null) ); const taskRefs = buildTaskRefSet(task); @@ -919,7 +916,7 @@ function filterMessagesForAttribution( record: OpenCodeTaskLogAttributionRecord ): ParsedMessage[] { const parsedMessages = messages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null); const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid); @@ -936,115 +933,6 @@ function filterMessagesForAttribution( .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); } -function mapOpenCodeContentBlock( - block: OpenCodeRuntimeTranscriptLogContentBlock -): ContentBlock | null { - switch (block.type) { - case 'text': { - const text = sanitizeDisplayContent(block.text); - return text.length > 0 ? { type: 'text', text } : null; - } - case 'thinking': - return { - type: 'thinking', - thinking: block.thinking, - signature: block.signature, - }; - case 'tool_use': - return { - type: 'tool_use', - id: block.id, - name: block.name, - input: block.input, - }; - case 'tool_result': - return { - type: 'tool_result', - tool_use_id: block.tool_use_id, - content: Array.isArray(block.content) - ? block.content - .map(mapOpenCodeContentBlock) - .filter((item): item is ContentBlock => item !== null) - : block.content, - ...(block.is_error ? { is_error: true } : {}), - }; - default: - return null; - } -} - -function buildToolUseResultData( - message: OpenCodeRuntimeTranscriptLogMessage -): ToolUseResultData | undefined { - if (!message.sourceToolUseID || message.toolResults.length !== 1) { - return undefined; - } - - const toolResult = message.toolResults[0]; - if (!toolResult) { - return undefined; - } - - return { - toolUseId: toolResult.toolUseId, - content: toolResult.content, - isError: toolResult.isError, - }; -} - -function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null { - const timestamp = new Date(message.timestamp); - if (Number.isNaN(timestamp.getTime())) { - return null; - } - - const normalizedContent: ContentBlock[] | string = - typeof message.content === 'string' - ? sanitizeDisplayContent(message.content) - : message.content - .map(mapOpenCodeContentBlock) - .filter((item): item is ContentBlock => item !== null); - - const toolCalls = message.toolCalls.map((toolCall) => ({ - id: toolCall.id, - name: toolCall.name, - input: toolCall.input, - isTask: toolCall.isTask, - ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), - ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), - })); - - const toolResults = message.toolResults.map((toolResult) => ({ - toolUseId: toolResult.toolUseId, - content: toolResult.content, - isError: toolResult.isError, - })); - const toolUseResult = buildToolUseResultData(message); - - return { - uuid: message.uuid, - parentUuid: message.parentUuid, - type: message.type, - timestamp, - role: message.role, - content: normalizedContent, - model: message.model, - agentName: message.agentName, - isSidechain: true, - isMeta: message.isMeta, - sessionId: message.sessionId, - toolCalls, - toolResults, - ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), - ...(message.sourceToolAssistantUUID - ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } - : {}), - ...(toolUseResult ? { toolUseResult } : {}), - ...(message.subtype ? { subtype: message.subtype } : {}), - ...(message.level ? { level: message.level } : {}), - }; -} - export class OpenCodeTaskLogStreamSource { private readonly cache = new Map< string, @@ -1187,7 +1075,7 @@ export class OpenCodeTaskLogStreamSource { const filteredMessages = markerProjection?.messages ?? projectedMessages - .map(toParsedMessage) + .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage) .filter((message): message is ParsedMessage => message !== null) .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); diff --git a/src/preload/index.ts b/src/preload/index.ts index 162a9dd4..3880e5d6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { createCodexAccountBridge } from '@features/codex-account/preload'; +import { createMemberLogStreamBridge } from '@features/member-log-stream/preload'; import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload'; @@ -478,6 +479,7 @@ const electronAPI: ElectronAPI = { ...createRecentProjectsBridge(), runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), + memberLogStream: createMemberLogStreamBridge(), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 52722276..f219c606 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,10 @@ * to run in a regular browser connected to an HTTP server. */ +import { createEmptyMemberLogStreamResponse } from '@features/member-log-stream/contracts'; + import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; +import type { MemberLogStreamApi } from '@features/member-log-stream/contracts'; import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { @@ -251,6 +254,16 @@ export class HttpAPIClient implements ElectronAPI { getDashboardRecentProjects = (): Promise => this.get('/api/dashboard/recent-projects'); + memberLogStream: MemberLogStreamApi = { + getMemberLogStream: async () => { + console.warn('[HttpAPIClient] getMemberLogStream is not available in browser mode'); + return createEmptyMemberLogStreamResponse(); + }, + setMemberLogStreamTracking: async () => { + // Not available in browser mode - no-op. + }, + }; + getProjects = (): Promise => this.get('/api/projects'); getSessions = (projectId: string): Promise => diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 92458c37..511f0774 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,5 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; +import { + isMemberLogStreamUiEnabled, + MemberLogStreamSection, +} from '@features/member-log-stream/renderer'; // import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; @@ -167,6 +171,7 @@ export const MemberDetailDialog = ({ const [activeTab, setActiveTab] = useState(initialTab); const [restarting, setRestarting] = useState(false); const [restartError, setRestartError] = useState(null); + const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false); const runtimeSummary = useMemo( () => @@ -237,6 +242,7 @@ export const MemberDetailDialog = ({ setActiveTab(initialTab); setRestartError(null); setRestarting(false); + setShowLegacyLogsFallback(false); }, [initialTab, member, open]); const { @@ -246,6 +252,7 @@ export const MemberDetailDialog = ({ } = useMemberStats(teamName, member?.name ?? null); const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null; + const memberLogStreamEnabled = isMemberLogStreamUiEnabled(); if (!member) return null; @@ -352,7 +359,26 @@ export const MemberDetailDialog = ({ /> - + {memberLogStreamEnabled ? ( +
+ + {showLegacyLogsFallback ? ( +
+
+ Legacy Logs Fallback +
+ +
+ ) : null} +
+ ) : ( + + )}
diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index aa4d1c02..0975d3e7 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -1,28 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { api } from '@renderer/api'; -import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { - getTeamColorSet, - getThemedBadge, - getThemedBorder, - getThemedText, -} from '@renderer/constants/teamColors'; -import { useTheme } from '@renderer/hooks/useTheme'; + ExecutionLogStreamView, + normalizeExecutionLogStream, +} from '@features/member-log-stream/renderer'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; -import { asEnhancedChunkArray } from '@renderer/types/data'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { isLeadMember } from '@shared/utils/leadDetection'; -import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; -import type { - BoardTaskLogActor, - BoardTaskLogSegment, - BoardTaskLogStreamResponse, - ResolvedTeamMember, -} from '@shared/types'; +import type { BoardTaskLogStreamResponse } from '@shared/types'; interface TaskLogStreamSectionProps { teamName: string; @@ -33,54 +19,6 @@ interface TaskLogStreamSectionProps { const LIVE_RELOAD_DEBOUNCE_MS = 350; -function formatRelativeTime(isoString: string): string { - const date = new Date(isoString); - const diffMs = Date.now() - date.getTime(); - const diffMin = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMin / 60); - const diffDays = Math.floor(diffHours / 24); - - if (!Number.isFinite(diffMs)) return '--'; - if (diffMin < 1) return 'just now'; - if (diffMin < 60) return `${diffMin}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; -} - -function actorLabel(actor: BoardTaskLogActor): string { - if (actor.memberName) { - return actor.memberName; - } - if (actor.role === 'lead' || actor.isSidechain === false) { - return 'lead session'; - } - if (actor.agentId) { - return `member ${actor.agentId.slice(0, 8)}`; - } - return `member session ${actor.sessionId.slice(0, 8)}`; -} - -function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse { - return { - participants: response.participants, - defaultFilter: response.defaultFilter, - source: response.source, - runtimeProjection: response.runtimeProjection, - segments: response.segments.map((segment) => ({ - ...segment, - chunks: asEnhancedChunkArray(segment.chunks) ?? [], - })), - }; -} - -function buildStableSegmentRenderKey(segment: BoardTaskLogSegment): string { - const firstChunkId = segment.chunks[0]?.id; - if (firstChunkId) { - return `${segment.participantKey}:${firstChunkId}`; - } - return `${segment.participantKey}:${segment.startTimestamp}`; -} - function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string { if (stream?.source === 'opencode_runtime_attribution') { return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.'; @@ -109,142 +47,6 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.'; } -interface ParticipantVisual { - name: string; - color?: string; -} - -function buildParticipantVisualMap( - stream: BoardTaskLogStreamResponse | null, - members: readonly ResolvedTeamMember[], - memberColorMap: ReadonlyMap -): Map { - const visuals = new Map(); - const leadMember = members.find((member) => isLeadMember(member)); - - for (const participant of stream?.participants ?? []) { - const matchingSegment = stream?.segments.find( - (segment) => segment.participantKey === participant.key - ); - const name = - matchingSegment?.actor.memberName ?? - (participant.isLead ? leadMember?.name : undefined) ?? - participant.label; - - visuals.set(participant.key, { - name, - color: memberColorMap.get(name) ?? memberColorMap.get(participant.label), - }); - } - - for (const segment of stream?.segments ?? []) { - if (visuals.has(segment.participantKey)) { - continue; - } - const name = segment.actor.memberName ?? actorLabel(segment.actor); - visuals.set(segment.participantKey, { - name, - color: memberColorMap.get(name), - }); - } - - return visuals; -} - -const SegmentMarker = ({ - segment, - visual, - teamName, -}: { - segment: BoardTaskLogSegment; - visual?: ParticipantVisual; - teamName: string; -}): React.JSX.Element => { - return ( -
- {visual ? ( - - ) : null} - - - {formatRelativeTime(segment.endTimestamp)} - -
- ); -}; - -const SegmentBlock = ({ - segment, - showHeader, - teamName, - visual, -}: { - segment: BoardTaskLogSegment; - showHeader: boolean; - teamName: string; - visual?: ParticipantVisual; -}): React.JSX.Element => { - return ( -
- {showHeader ? : null} - -
- ); -}; - -const ParticipantFilterChip = ({ - label, - selected, - visual, - teamName, - onClick, -}: { - label: string; - selected: boolean; - visual?: ParticipantVisual; - teamName: string; - onClick: () => void; -}): React.JSX.Element => { - const { isLight } = useTheme(); - const colors = getTeamColorSet(visual?.color ?? ''); - const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)'; - const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent'; - const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)'; - - return ( - - ); -}; - export const TaskLogStreamSection = ({ teamName, taskId, @@ -254,7 +56,6 @@ export const TaskLogStreamSection = ({ const [stream, setStream] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); const requestSeqRef = useRef(0); const streamRef = useRef(null); @@ -265,41 +66,25 @@ export const TaskLogStreamSection = ({ }, [stream]); const loadStream = useCallback( - async (options?: { resetSelection?: boolean; background?: boolean }): Promise => { - const resetSelection = options?.resetSelection ?? false; + async (options?: { background?: boolean }): Promise => { const background = options?.background ?? false; const hadExistingStream = streamRef.current != null; const requestSeq = requestSeqRef.current + 1; requestSeqRef.current = requestSeq; - if (!background) { - setLoading(true); - } + if (!background) setLoading(true); setError((prev) => (background ? prev : null)); try { - const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); - if (requestSeqRef.current !== requestSeq) { - return; - } + const response = normalizeExecutionLogStream( + await api.teams.getTaskLogStream(teamName, taskId) + ); + if (requestSeqRef.current !== requestSeq) return; setStream(response); - setSelectedParticipantKey((prev) => { - if (resetSelection) { - return response.defaultFilter; - } - const availableParticipantKeys = new Set([ - 'all', - ...response.participants.map((participant) => participant.key), - ]); - return availableParticipantKeys.has(prev) ? prev : response.defaultFilter; - }); setError(null); } catch (loadError) { - if (requestSeqRef.current !== requestSeq) { - return; - } - + if (requestSeqRef.current !== requestSeq) return; if (!background || streamRef.current == null) { setError( loadError instanceof Error ? loadError.message : 'Failed to load task log stream' @@ -319,13 +104,12 @@ export const TaskLogStreamSection = ({ setStream(null); streamRef.current = null; setError(null); - setSelectedParticipantKey('all'); requestSeqRef.current += 1; if (reloadTimerRef.current) { clearTimeout(reloadTimerRef.current); reloadTimerRef.current = null; } - void loadStream({ resetSelection: true }); + void loadStream(); }, [loadStream]); const previousTaskMetaRef = useRef({ taskId, taskStatus }); @@ -334,10 +118,7 @@ export const TaskLogStreamSection = ({ const previousTaskMeta = previousTaskMetaRef.current; previousTaskMetaRef.current = { taskId, taskStatus }; - if (previousTaskMeta.taskId !== taskId) { - return; - } - + if (previousTaskMeta.taskId !== taskId) return; if ( previousTaskMeta.taskStatus === 'in_progress' && taskStatus && @@ -357,12 +138,8 @@ export const TaskLogStreamSection = ({ } const scheduleReload = (): void => { - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { - return; - } - if (reloadTimerRef.current) { - clearTimeout(reloadTimerRef.current); - } + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); reloadTimerRef.current = setTimeout(() => { reloadTimerRef.current = null; void loadStream({ background: true }); @@ -370,22 +147,15 @@ export const TaskLogStreamSection = ({ }; const unsubscribe = api.teams.onTeamChange?.((_event, event) => { - if (event.teamName !== teamName) { - return; - } + if (event.teamName !== teamName) return; const shouldReload = event.type === 'log-source-change' || (event.type === 'task-log-change' && event.taskId === taskId); - if (!shouldReload) { - return; - } - scheduleReload(); + if (shouldReload) scheduleReload(); }); const handleVisibilityChange = (): void => { - if (document.visibilityState === 'visible') { - scheduleReload(); - } + if (document.visibilityState === 'visible') scheduleReload(); }; if (typeof document !== 'undefined') { @@ -400,115 +170,25 @@ export const TaskLogStreamSection = ({ if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', handleVisibilityChange); } - if (typeof unsubscribe === 'function') { - unsubscribe(); - } + if (typeof unsubscribe === 'function') unsubscribe(); }; }, [liveEnabled, loadStream, taskId, teamName]); - const participants = stream?.participants ?? []; - const memberColorMap = useMemo(() => buildMemberColorMap(teamMembers), [teamMembers]); - const participantVisuals = useMemo( - () => buildParticipantVisualMap(stream, teamMembers, memberColorMap), - [memberColorMap, stream, teamMembers] - ); - const showChips = participants.length > 1; const streamDescription = useMemo(() => describeStreamSource(stream), [stream]); - const visibleSegments = useMemo(() => { - const source = stream?.segments ?? []; - const filtered = - selectedParticipantKey === 'all' - ? source - : source.filter((segment) => segment.participantKey === selectedParticipantKey); - return [...filtered].reverse(); - }, [selectedParticipantKey, stream?.segments]); - - const showSegmentHeaders = - participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1); - - if (loading) { - return ( -
-

- Task Log Stream -

-
- - Loading task log stream... -
-
- ); - } - - if (error) { - return ( -
-

- Task Log Stream -

-
- - {error} -
-
- ); - } return ( -
-

- Task Log Stream -

-

{streamDescription}

- - {showChips ? ( -
- - {participants.map((participant) => ( - setSelectedParticipantKey(participant.key)} - /> - ))} -
- ) : null} - - {visibleSegments.length === 0 ? ( -
- - No task log stream yet -

- Task-linked logs will appear here when transcript metadata or runtime projection is - available. -

-
- ) : ( -
- {visibleSegments.map((segment) => ( - - ))} -
- )} -
+ ); }; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b52d8fc2..fe4f6d45 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -98,6 +98,7 @@ import type { TerminalAPI } from './terminal'; import type { TmuxAPI } from './tmux'; import type { WaterfallData } from './visualization'; import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; +import type { MemberLogStreamApi } from '@features/member-log-stream/contracts'; import type { MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, @@ -904,6 +905,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec // Member actionable-work sync diagnostics API memberWorkSync: MemberWorkSyncElectronApi; + // Member log stream API + memberLogStream: MemberLogStreamApi; + // tmux runtime diagnostics API tmux: TmuxAPI; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e42bcf6e..66aa5758 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -807,8 +807,14 @@ export interface ResolvedTeamMember { workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + selectedFastMode?: TeamFastMode; + resolvedFastMode?: boolean; + laneId?: string; + laneKind?: 'primary' | 'secondary'; + laneOwnerProviderId?: TeamProviderId; cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; @@ -908,7 +914,13 @@ export interface TeamViewSnapshot { export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; -export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; +export type TeamProviderBackendId = + | 'auto' + | 'adapter' + | 'api' + | 'cli-sdk' + | 'codex-native' + | 'opencode-cli'; export type TeamFastMode = 'inherit' | 'on' | 'off'; export interface ProviderModelLaunchIdentity { diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 5c38cb3b..be5796f3 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -954,8 +954,8 @@ describe('ClaudeMultimodelBridgeService', () => { messageCount: 2, toolCallCount: 1, errorCount: 0, - latestAssistantText: '/tmp/project', - latestAssistantPreview: '/tmp/project', + latestAssistantText: '/Users/tester/project', + latestAssistantPreview: '/Users/tester/project', messages: [], diagnostics: [], logProjection: { @@ -1027,6 +1027,65 @@ describe('ClaudeMultimodelBridgeService', () => { }); }); + it('passes OpenCode lane and popup timeout to the runtime transcript command', async () => { + execCliMock.mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + + if ( + normalizedArgs.startsWith( + 'runtime transcript --json --provider opencode --team team-a --member alice --projection-only --limit 20 --lane secondary:opencode:alice --output ' + ) + ) { + const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1; + const outputPath = + outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : ''; + await writeFile( + outputPath, + JSON.stringify({ + schemaVersion: 1, + providerId: 'opencode', + transcript: { + sessionId: 'session-lane', + durableState: 'idle', + messages: [], + diagnostics: [], + logProjection: { + messages: [], + }, + }, + }), + 'utf8' + ); + return Promise.resolve({ + stdout: '', + stderr: '', + exitCode: 0, + }); + } + + return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', { + teamId: 'team-a', + memberName: 'alice', + limit: 20, + laneId: ' secondary:opencode:alice ', + timeoutMs: 1_234, + }); + + expect(transcript?.sessionId).toBe('session-lane'); + expect(execCliMock).toHaveBeenCalledWith( + '/mock/agent_teams_orchestrator', + expect.arrayContaining(['--lane', 'secondary:opencode:alice']), + expect.objectContaining({ timeout: 1_234 }) + ); + }); + it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => { const fixturePath = path.resolve( process.cwd(), diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 19ea0cff..2486c21f 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -35,38 +35,42 @@ describe('TeamMemberLogsFinder', () => { await fs.mkdir(projectRoot, { recursive: true }); const projectResolver = { - getLiveBaseContext: vi.fn(async () => ({ - projectDir: projectRoot, - projectId: '-Users-test-live-context', - config, - })), - getContext: vi.fn(async () => { - throw new Error('broad context must not be used for live tracking'); - }), + getLiveBaseContext: vi.fn(() => + Promise.resolve({ + projectDir: projectRoot, + projectId: '-Users-test-live-context', + config, + }) + ), + getContext: vi.fn(() => + Promise.reject(new Error('broad context must not be used for live tracking')) + ), }; const launchStateStore = { - read: vi.fn(async () => ({ - version: 2, - teamName, - updatedAt: '2026-05-03T12:00:00.000Z', - leadSessionId: 'lead-session', - launchPhase: 'active', - expectedMembers: ['bob'], - members: { - bob: { - name: 'bob', - launchState: 'runtime_pending_bootstrap', - agentToolAccepted: true, - runtimeAlive: false, - bootstrapConfirmed: false, - hardFailure: false, - runtimeSessionId: 'runtime-bob', - updatedAt: '2026-05-03T12:00:00.000Z', + read: vi.fn(() => + Promise.resolve({ + version: 2, + teamName, + updatedAt: '2026-05-03T12:00:00.000Z', + leadSessionId: 'lead-session', + launchPhase: 'active', + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + runtimeSessionId: 'runtime-bob', + updatedAt: '2026-05-03T12:00:00.000Z', + }, }, - }, - summary: {}, - teamLaunchState: 'partial_pending', - })), + summary: {}, + teamLaunchState: 'partial_pending', + }) + ), }; const finder = new TeamMemberLogsFinder( @@ -418,6 +422,97 @@ describe('TeamMemberLogsFinder', () => { expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false); }); + it('applies recent-ref object options to discovery, lead refs, metadata, and requested-member attribution', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'member-stream-ref-options'; + const projectPath = '/Users/test/member-stream-ref-options'; + const projectId = '-Users-test-member-stream-ref-options'; + const leadSessionId = 'lead-session'; + const recentSince = Date.now() - 10 * 60_000; + const old = new Date(Date.now() - 30 * 60_000); + const now = new Date(); + const projectRoot = path.join(tmpDir, 'projects', projectId); + const subagentsDir = path.join(projectRoot, leadSessionId, 'subagents'); + await fs.mkdir(subagentsDir, { recursive: true }); + + const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`); + await fs.writeFile( + leadPath, + JSON.stringify({ + timestamp: old.toISOString(), + type: 'user', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + await fs.utimes(leadPath, old, old); + + const zoePath = path.join(subagentsDir, 'agent-zoe.jsonl'); + await fs.writeFile( + zoePath, + [ + JSON.stringify({ + timestamp: now.toISOString(), + type: 'user', + message: { + role: 'user', + content: `You are Zoe, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: now.toISOString(), + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Ready' }] }, + }), + ].join('\n') + '\n', + 'utf8' + ); + await fs.utimes(zoePath, now, now); + + const projectResolver = { + getContext: vi.fn(() => + Promise.resolve({ + projectDir: projectRoot, + projectId, + sessionIds: [leadSessionId], + config: { + name: teamName, + projectPath, + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) + ), + }; + const finder = new TeamMemberLogsFinder( + undefined, + undefined, + undefined, + projectResolver as never + ); + + const refs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['team-lead', 'Zoe'], { + mtimeSinceMs: recentSince, + forceRefresh: true, + }); + + expect(projectResolver.getContext).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ forceRefresh: true }) + ); + expect(refs).toEqual([ + expect.objectContaining({ + memberName: 'Zoe', + filePath: zoePath, + kind: 'subagent', + sizeBytes: expect.any(Number), + }), + ]); + expect(refs.some((ref) => ref.filePath === leadPath)).toBe(false); + }); + it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); @@ -452,18 +547,22 @@ describe('TeamMemberLogsFinder', () => { await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true }); await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true }); - const attributedLog = [ - JSON.stringify({ - timestamp: '2026-01-01T00:00:01.000Z', - type: 'user', - message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` }, - }), - JSON.stringify({ - timestamp: '2026-01-01T00:00:02.000Z', - type: 'assistant', - message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, - }), - ].join('\n') + '\n'; + const attributedLog = + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: `You are alice, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, + }), + ].join('\n') + '\n'; await fs.writeFile( path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'), @@ -1259,7 +1358,11 @@ describe('TeamMemberLogsFinder', () => { message: { role: 'assistant', content: [ - { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '5', status: 'in_progress' } }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: '5', status: 'in_progress' }, + }, ], }, }), @@ -1413,7 +1516,11 @@ describe('TeamMemberLogsFinder', () => { message: { role: 'assistant', content: [ - { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } }, + { + type: 'tool_use', + name: 'TaskUpdate', + input: { taskId: '3', status: 'in_progress' }, + }, ], }, }), diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index 78f905f3..2b8dc1ca 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -6,6 +6,10 @@ import { useStore } from '@renderer/store'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +const memberLogStreamMockState = vi.hoisted(() => ({ + uiEnabled: true, +})); + vi.mock('@renderer/hooks/useMemberStats', () => ({ useMemberStats: () => ({ stats: null, @@ -110,11 +114,32 @@ vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({ MemberLogsTab: () => React.createElement('div', null, 'logs-tab'), })); +vi.mock('@features/member-log-stream/renderer', async () => { + const ReactModule = await import('react'); + return { + isMemberLogStreamUiEnabled: () => memberLogStreamMockState.uiEnabled, + MemberLogStreamSection: ({ + onInitialLoadErrorChange, + }: { + onInitialLoadErrorChange?: (hasError: boolean) => void; + }) => + ReactModule.createElement( + 'button', + { + type: 'button', + onClick: () => onInitialLoadErrorChange?.(true), + }, + 'member-log-stream' + ), + }; +}); + import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog'; describe('MemberDetailDialog activity count', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + memberLogStreamMockState.uiEnabled = true; useStore.setState({ teamMessagesByName: { 'demo-team': { @@ -139,6 +164,98 @@ describe('MemberDetailDialog activity count', () => { vi.unstubAllGlobals(); }); + it('renders legacy member logs directly when the member log stream UI gate is off', async () => { + memberLogStreamMockState.uiEnabled = false; + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members: [member], + tasks: [], + initialTab: 'logs', + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('logs-tab'); + expect(host.textContent).not.toContain('member-log-stream'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the stream visible and renders legacy fallback after an initial stream error', async () => { + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members: [member], + tasks: [], + initialTab: 'logs', + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const streamButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('member-log-stream') + ); + expect(streamButton).not.toBeUndefined(); + + await act(async () => { + streamButton?.click(); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('member-log-stream'); + expect(host.textContent).toContain('Legacy Logs Fallback'); + expect(host.textContent).toContain('logs-tab'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('counts task comments in the Activity badge even when messageCount is zero', async () => { const member: ResolvedTeamMember = { name: 'jack', @@ -348,7 +465,7 @@ describe('MemberDetailDialog activity count', () => { messageCount: 0, providerId: 'opencode', }; - const onRestartMember = vi.fn(async () => undefined); + const onRestartMember = vi.fn(() => Promise.resolve(undefined)); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -424,7 +541,7 @@ describe('MemberDetailDialog activity count', () => { messageCount: 0, providerId: 'opencode', }; - const onRestartMember = vi.fn(async () => undefined); + const onRestartMember = vi.fn(() => Promise.resolve(undefined)); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); diff --git a/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx b/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx new file mode 100644 index 00000000..30bc6aba --- /dev/null +++ b/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx @@ -0,0 +1,378 @@ +/* eslint-disable security/detect-non-literal-fs-filename -- Fixture E2E reads a repo fixture and writes temp JSONL. */ +import { readFile, rm, stat, writeFile, mkdtemp } from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase'; +import { + type MemberLogStreamRequestOptions, + type MemberLogStreamResponse, +} from '../../../../../src/features/member-log-stream/contracts'; +import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource'; +import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; +import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogStrictParser } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip'; + +import type { OpenCodeRuntimeTranscriptResponse } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService'; +import type { MemberLogFileRef } from '../../../../../src/main/services/team/TeamMemberLogsFinder'; +import type { ResolvedTeamMember } from '../../../../../src/shared/types'; + +const TEAM_NAME = 'relay-works-10'; +const MEMBER_NAME = 'jack'; +const LANE_ID = 'secondary:opencode:jack'; +const GENERATED_AT = '2026-04-24T20:40:00.000Z'; +const FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json' +); + +const tempDirs: string[] = []; + +const apiState = { + getMemberLogStream: + vi.fn< + ( + teamName: string, + memberName: string, + options?: MemberLogStreamRequestOptions + ) => Promise + >(), + setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise>(), + onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + memberLogStream: { + getMemberLogStream: (...args: Parameters) => + apiState.getMemberLogStream(...args), + setMemberLogStreamTracking: ( + ...args: Parameters + ) => apiState.setMemberLogStreamTracking(...args), + }, + teams: { + onTeamChange: (...args: Parameters) => + apiState.onTeamChange(...args), + }, + }, +})); + +import { MemberLogStreamSection } from '../../../../../src/features/member-log-stream/renderer'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function flushAsyncWork(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function waitForText( + host: HTMLElement, + predicate: (text: string) => boolean +): Promise { + let text = ''; + for (let attempt = 0; attempt < 25; attempt += 1) { + await act(async () => { + await flushAsyncWork(); + }); + text = host.textContent ?? ''; + if (predicate(text)) { + return text; + } + } + return text; +} + +async function loadOpenCodeFixtureTranscript(): Promise< + NonNullable +> { + const parsed = JSON.parse( + await readFile(FIXTURE_PATH, 'utf8') + ) as OpenCodeRuntimeTranscriptResponse; + if (parsed.providerId !== 'opencode' || !parsed.transcript) { + throw new Error('Invalid OpenCode transcript fixture'); + } + return parsed.transcript; +} + +async function createClaudeTranscriptRef(): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'member-log-stream-e2e-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'jack-claude-session.jsonl'); + const rows = [ + { + parentUuid: null, + isSidechain: true, + userType: 'external', + cwd: '/Users/tester/project', + sessionId: 'claude-session-jack', + version: '1.0.0', + gitBranch: 'main', + agentName: MEMBER_NAME, + type: 'system', + uuid: 'claude-init', + timestamp: '2026-04-24T20:25:00.000Z', + subtype: 'init', + level: 'info', + isMeta: false, + content: 'member session started', + }, + { + parentUuid: 'claude-init', + isSidechain: true, + userType: 'external', + cwd: '/Users/tester/project', + sessionId: 'claude-session-jack', + version: '1.0.0', + gitBranch: 'main', + agentName: MEMBER_NAME, + type: 'user', + uuid: 'claude-user-1', + timestamp: '2026-04-24T20:25:01.000Z', + isMeta: false, + message: { + role: 'user', + content: 'Collect member-wide evidence for calculator behavior.', + }, + }, + { + parentUuid: 'claude-user-1', + isSidechain: true, + userType: 'external', + cwd: '/Users/tester/project', + sessionId: 'claude-session-jack', + version: '1.0.0', + gitBranch: 'main', + agentName: MEMBER_NAME, + type: 'assistant', + uuid: 'claude-assistant-1', + requestId: 'req-claude-1', + timestamp: '2026-04-24T20:25:03.000Z', + message: { + role: 'assistant', + id: 'msg-claude-1', + type: 'message', + model: 'claude-sonnet-4-5-20250929', + content: [ + { + type: 'text', + text: 'Member-wide Claude transcript final note for Jack.', + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 12, output_tokens: 16 }, + }, + }, + ]; + + await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8'); + const fileStat = await stat(filePath); + + return { + memberName: MEMBER_NAME, + sessionId: 'claude-session-jack', + filePath, + mtimeMs: fileStat.mtimeMs, + sizeBytes: fileStat.size, + messageCount: rows.length, + kind: 'subagent', + }; +} + +async function createFixtureUseCase(): Promise<{ + useCase: GetMemberLogStreamUseCase; + getOpenCodeTranscript: ReturnType; + findRecentMemberLogFileRefsByMember: ReturnType; +}> { + const claudeRef = await createClaudeTranscriptRef(); + const openCodeTranscript = await loadOpenCodeFixtureTranscript(); + const findRecentMemberLogFileRefsByMember = vi.fn(() => Promise.resolve([claudeRef])); + const getOpenCodeTranscript = vi.fn(() => Promise.resolve(openCodeTranscript)); + + const chunkBuilder = new BoardTaskExactLogChunkBuilder(); + const sources = [ + new ClaudeMemberTranscriptStreamSource( + { findRecentMemberLogFileRefsByMember } as never, + new BoardTaskExactLogStrictParser(), + chunkBuilder, + { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + ), + new OpenCodeMemberRuntimeStreamSource( + { getOpenCodeTranscript } as never, + chunkBuilder, + { resolve: vi.fn(() => Promise.resolve('/Users/tester/agent_teams_orchestrator')) } + ), + ]; + + return { + useCase: new GetMemberLogStreamUseCase({ + sources, + clock: { now: () => Date.parse(GENERATED_AT) }, + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }), + getOpenCodeTranscript, + findRecentMemberLogFileRefsByMember, + }; +} + +function createMember(): ResolvedTeamMember { + return { + name: MEMBER_NAME, + status: 'idle', + currentTaskId: null, + taskCount: 2, + lastActiveAt: '2026-04-24T20:34:00.000Z', + messageCount: 12, + color: 'blue', + providerId: 'opencode', + laneId: LANE_ID, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }; +} + +function stubMatchMedia(): void { + const matchMedia = vi.fn((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal('matchMedia', matchMedia); +} + +function expectCapturedResponse( + value: MemberLogStreamResponse | null +): MemberLogStreamResponse { + expect(value).not.toBeNull(); + return value!; +} + +describe('MemberLogStreamSection real fixture e2e', () => { + afterEach(async () => { + document.body.innerHTML = ''; + apiState.getMemberLogStream.mockReset(); + apiState.setMemberLogStreamTracking.mockReset(); + apiState.onTeamChange.mockReset(); + vi.unstubAllGlobals(); + await Promise.all( + tempDirs.splice(0, tempDirs.length).map((dirPath) => + rm(dirPath, { recursive: true, force: true }) + ) + ); + }); + + it('renders member-wide Claude transcript and OpenCode runtime logs through the member Logs UI', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + stubMatchMedia(); + apiState.onTeamChange.mockImplementation(() => () => undefined); + apiState.setMemberLogStreamTracking.mockResolvedValue(undefined); + + const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } = + await createFixtureUseCase(); + const capturedResponseRef: { current: MemberLogStreamResponse | null } = { current: null }; + apiState.getMemberLogStream.mockImplementation(async (teamName, memberName, options) => { + const response = await useCase.execute({ + teamName, + memberName, + limitSegments: options?.limitSegments, + laneId: options?.laneId, + forceRefresh: options?.forceRefresh, + }); + capturedResponseRef.current = response; + return response; + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(MemberLogStreamSection, { + teamName: TEAM_NAME, + member: createMember(), + }) + ) + ); + await flushMicrotasks(); + }); + + const text = await waitForText(host, (content) => + content.includes('Member-wide Claude transcript final note for Jack.') + ); + + expect(text).toContain('Logs'); + expect(text).toContain('Member-scoped transcript and runtime logs'); + expect(text).toContain('Claude transcript'); + expect(text).toContain('OpenCode runtime'); + expect(text).toContain('Calculator behavior'); + expect(text).toContain('Logic smoke check'); + expect(text).toContain('Collect member-wide evidence for calculator behavior.'); + + const capturedResponse = expectCapturedResponse(capturedResponseRef.current); + expect(capturedResponse).toMatchObject({ + source: 'member_mixed_runtime', + defaultFilter: 'member:jack', + generatedAt: GENERATED_AT, + metadata: { + scannedTranscriptFileCount: 1, + includedTranscriptFileCount: 1, + }, + }); + expect(capturedResponse.coverage).toEqual( + expect.arrayContaining([ + { provider: 'claude_transcript', status: 'included' }, + { provider: 'opencode_runtime', status: 'included' }, + ]) + ); + expect(JSON.stringify(capturedResponse.segments)).toContain('Keyboard handlers added'); + expect(apiState.getMemberLogStream).toHaveBeenCalledWith( + TEAM_NAME, + MEMBER_NAME, + expect.objectContaining({ + limitSegments: 30, + laneId: LANE_ID, + }) + ); + expect(findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith( + TEAM_NAME, + [MEMBER_NAME], + expect.objectContaining({ forceRefresh: false }) + ); + expect(getOpenCodeTranscript).toHaveBeenCalledWith( + '/Users/tester/agent_teams_orchestrator', + expect.objectContaining({ + teamId: TEAM_NAME, + memberName: MEMBER_NAME, + laneId: LANE_ID, + limit: 400, + timeoutMs: 5_000, + }) + ); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + + expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true); + expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false); + }); +}); From 9505ef8485cf565b4a6a8033baa7d2d6241fe224 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 15:18:21 +0300 Subject: [PATCH 02/83] feat: add graph member log previews --- .../src/hooks/useGraphSimulation.ts | 15 + .../agent-graph/src/layout/stableSlots.ts | 76 +- packages/agent-graph/src/ports/types.ts | 1 + packages/agent-graph/src/ui/GraphView.tsx | 7 + .../hooks/useGraphMemberLogPreviews.ts | 362 ++++++ .../renderer/ui/GraphMemberLogPreviewHud.tsx | 382 ++++++ .../renderer/ui/TeamGraphOverlay.tsx | 20 + .../agent-graph/renderer/ui/TeamGraphTab.tsx | 20 + .../member-log-stream/contracts/api.ts | 12 +- .../member-log-stream/contracts/channels.ts | 1 + .../member-log-stream/contracts/dto.ts | 40 + .../member-log-stream/contracts/normalize.ts | 51 +- .../ports/MemberLogPreviewSource.ts | 32 + .../use-cases/GetMemberLogPreviewsUseCase.ts | 210 ++++ .../GetMemberLogPreviewsUseCase.test.ts | 98 ++ .../domain/models/MemberLogPreviewBudget.ts | 41 + .../memberLogPreviewExtractor.test.ts | 361 ++++++ .../memberLogPreviewMergePolicy.test.ts | 51 + .../policies/memberLogPreviewExtractor.ts | 1098 +++++++++++++++++ .../policies/memberLogPreviewMergePolicy.ts | 83 ++ .../registerMemberLogStreamIpc.test.ts | 83 +- .../input/ipc/registerMemberLogStreamIpc.ts | 158 ++- .../ClaudeMemberTranscriptPreviewSource.ts | 106 ++ .../ClaudeMemberTranscriptStreamSource.ts | 50 +- .../CodexNativeMemberTracePreviewSource.ts | 42 + .../OpenCodeMemberRuntimePreviewSource.ts | 188 +++ .../__tests__/memberLogSources.test.ts | 179 ++- .../sources/memberLogStreamSourceUtils.ts | 49 +- .../createMemberLogStreamFeature.ts | 38 +- .../createMemberLogStreamBridge.test.ts | 43 + .../preload/createMemberLogStreamBridge.ts | 17 + src/renderer/api/httpClient.ts | 9 +- .../GraphMemberLogPreviewHud.test.tsx | 221 ++++ .../useGraphMemberLogPreviews.test.tsx | 297 +++++ .../agent-graph/useGraphSimulation.test.ts | 15 +- 35 files changed, 4377 insertions(+), 79 deletions(-) create mode 100644 src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts create mode 100644 src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx create mode 100644 src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts create mode 100644 src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts create mode 100644 src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts create mode 100644 src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts create mode 100644 src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts create mode 100644 src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts create mode 100644 src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts create mode 100644 src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts create mode 100644 src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts create mode 100644 test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx create mode 100644 test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index b4fc758e..419c9158 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -68,6 +68,7 @@ export interface UseGraphSimulationResult { } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityWorldRect: (nodeId: string) => StableRect | null; + getLogWorldRect: (nodeId: string) => StableRect | null; getExtraWorldBounds: () => WorldBounds[]; } @@ -86,6 +87,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { const dragOwnerPositionsRef = useRef(new Map()); const launchAnchorPositionsRef = useRef(new Map()); const activityRectByNodeIdRef = useRef(new Map()); + const logRectByNodeIdRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); const prevNodeIdsRef = useRef(new Set()); @@ -112,6 +114,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { dragOwnerPositionsRef, launchAnchorPositionsRef, activityRectByNodeIdRef, + logRectByNodeIdRef, extraWorldBoundsRef, }); return; @@ -132,6 +135,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { dragOwnerPositionsRef, launchAnchorPositionsRef, activityRectByNodeIdRef, + logRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions: true, }); @@ -144,6 +148,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { layoutSnapshotRef, launchAnchorPositionsRef, activityRectByNodeIdRef, + logRectByNodeIdRef, extraWorldBoundsRef, }); }, []); @@ -264,6 +269,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { dragOwnerPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear(); activityRectByNodeIdRef.current.clear(); + logRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; layoutSnapshotRef.current = null; lastValidSnapshotByTeamRef.current.clear(); @@ -283,6 +289,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, + getLogWorldRect: (nodeId: string) => logRectByNodeIdRef.current.get(nodeId) ?? null, getExtraWorldBounds: () => extraWorldBoundsRef.current, }), [ @@ -352,6 +359,7 @@ function commitSnapshotGeometry(args: { dragOwnerPositionsRef: { current: ReadonlyMap }; launchAnchorPositionsRef: { current: Map }; activityRectByNodeIdRef: { current: Map }; + logRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; fillMissingFallbackPositions?: boolean; }): void { @@ -364,6 +372,7 @@ function commitSnapshotGeometry(args: { dragOwnerPositionsRef, launchAnchorPositionsRef, activityRectByNodeIdRef, + logRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions = false, } = args; @@ -377,10 +386,12 @@ function commitSnapshotGeometry(args: { launchAnchorPositionsRef.current.clear(); activityRectByNodeIdRef.current.clear(); + logRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); + logRectByNodeIdRef.current.set(frame.ownerId, frame.logColumnRect); } if (snapshot.leadNodeId) { @@ -388,6 +399,7 @@ function commitSnapshotGeometry(args: { snapshot.leadNodeId, snapshot.leadSlotFrame.activityColumnRect ); + logRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadSlotFrame.logColumnRect); } } @@ -396,6 +408,7 @@ function resetToFallbackLayout(args: { layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; launchAnchorPositionsRef: { current: Map }; activityRectByNodeIdRef: { current: Map }; + logRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; }): void { const { @@ -403,12 +416,14 @@ function resetToFallbackLayout(args: { layoutSnapshotRef, launchAnchorPositionsRef, activityRectByNodeIdRef, + logRectByNodeIdRef, extraWorldBoundsRef, } = args; layoutSnapshotRef.current = null; launchAnchorPositionsRef.current.clear(); activityRectByNodeIdRef.current.clear(); + logRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; fallbackPositionNodes(nodes); KanbanLayoutEngine.layout(nodes); diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 0e7aa03f..5f8672c2 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -23,6 +23,8 @@ export interface OwnerFootprint { radialDepth: number; activityColumnWidth: number; activityColumnHeight: number; + logColumnWidth: number; + logColumnHeight: number; processBandWidth: number; kanbanBandWidth: number; kanbanBandHeight: number; @@ -42,6 +44,7 @@ export interface SlotFrame { ownerY: number; boardBandRect: StableRect; activityColumnRect: StableRect; + logColumnRect: StableRect; processBandRect: StableRect; kanbanBandRect: StableRect; taskColumnCount: number; @@ -108,6 +111,11 @@ const SLOT_GEOMETRY = { ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight, activityColumnWidth: ACTIVITY_LANE.width, + logColumnHeight: + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight, + logColumnWidth: 260, ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, boardColumnGap: 24, @@ -231,6 +239,7 @@ function buildCentralCollisionRects(args: { args.leadCoreRect, args.leadSlotFrame.processBandRect, args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.logColumnRect, args.leadSlotFrame.kanbanBandRect, ]; if (args.unassignedTaskRect) { @@ -247,6 +256,7 @@ function buildLeadCentralReservedBlock(args: { args.leadCoreRect, args.leadSlotFrame.processBandRect, args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.logColumnRect, args.leadSlotFrame.kanbanBandRect, ]); } @@ -270,6 +280,7 @@ export function computeOwnerFootprints( ): OwnerFootprint[] { const ownerNodes = nodes.filter((node) => node.kind === 'member'); const showActivity = layout?.showActivity ?? true; + const showLogs = layout?.showLogs ?? showActivity; const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const)); const taskColumnsByOwnerId = new Map>(); const processCountByOwnerId = new Map(); @@ -304,6 +315,7 @@ export function computeOwnerFootprints( taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0, processCount: processCountByOwnerId.get(ownerId) ?? 0, showActivity, + showLogs, }), ]; }); @@ -331,6 +343,7 @@ function computeOwnerFootprintForOwnerId( taskColumnCount: taskColumns.size, processCount, showActivity: layout?.showActivity ?? true, + showLogs: layout?.showLogs ?? layout?.showActivity ?? true, }); } @@ -339,17 +352,28 @@ function buildOwnerFootprint(args: { taskColumnCount: number; processCount: number; showActivity: boolean; + showLogs: boolean; }): OwnerFootprint { const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0; const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 0; - const activityToKanbanGap = args.showActivity ? SLOT_GEOMETRY.boardColumnGap : 0; + const logColumnWidth = args.showLogs ? SLOT_GEOMETRY.logColumnWidth : 0; + const logColumnHeight = args.showLogs ? SLOT_GEOMETRY.logColumnHeight : 0; + const activityToLogGap = + activityColumnWidth > 0 && logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0; + const feedToKanbanGap = + activityColumnWidth > 0 || logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0; const kanbanBandWidth = args.taskColumnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth; const processBandWidth = computeProcessBandWidth(args.processCount); - const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth; - const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight); + const boardBandWidth = + activityColumnWidth + activityToLogGap + logColumnWidth + feedToKanbanGap + kanbanBandWidth; + const boardBandHeight = Math.max( + activityColumnHeight, + logColumnHeight, + SLOT_GEOMETRY.kanbanBandHeight + ); const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth); const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; const slotHeight = @@ -377,6 +401,8 @@ function buildOwnerFootprint(args: { radialDepth, activityColumnWidth, activityColumnHeight, + logColumnWidth, + logColumnHeight, processBandWidth, kanbanBandWidth, kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, @@ -651,6 +677,7 @@ function validateStaticSnapshotRects( ['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds], ['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect], ['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect], + ['leadSlotFrame.logColumnRect', snapshot.leadSlotFrame.logColumnRect], ['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect], ['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect], ['leadActivityRect', snapshot.leadActivityRect], @@ -697,6 +724,9 @@ function validateLeadSnapshotRects( if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.logColumnRect)) { + return { valid: false, reason: 'lead logColumnRect must fit inside leadCentralReservedBlock' }; + } if ( !rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect) ) { @@ -795,6 +825,9 @@ function validateSlotFrameGeometry( if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { return { valid: false, reason: `activityColumnRect escapes ${label}` }; } + if (!rectContainsRect(frame.bounds, frame.logColumnRect)) { + return { valid: false, reason: `logColumnRect escapes ${label}` }; + } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { return { valid: false, reason: `processBandRect escapes ${label}` }; } @@ -807,6 +840,12 @@ function validateSlotFrameGeometry( reason: `activityColumnRect escapes boardBandRect in ${label}`, }; } + if (!rectContainsRect(frame.boardBandRect, frame.logColumnRect)) { + return { + valid: false, + reason: `logColumnRect escapes boardBandRect in ${label}`, + }; + } if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { return { valid: false, @@ -819,6 +858,18 @@ function validateSlotFrameGeometry( reason: `activityColumnRect overlaps kanbanBandRect in ${label}`, }; } + if (rectsOverlap(frame.activityColumnRect, frame.logColumnRect)) { + return { + valid: false, + reason: `activityColumnRect overlaps logColumnRect in ${label}`, + }; + } + if (rectsOverlap(frame.logColumnRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `logColumnRect overlaps kanbanBandRect in ${label}`, + }; + } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { return { valid: false, reason: `owner anchor escapes ${label}` }; } @@ -853,6 +904,7 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl ownerY: frame.ownerY + dy, boardBandRect: translateRect(frame.boardBandRect, dx, dy), activityColumnRect: translateRect(frame.activityColumnRect, dx, dy), + logColumnRect: translateRect(frame.logColumnRect, dx, dy), processBandRect: translateRect(frame.processBandRect, dx, dy), kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy), }; @@ -1296,9 +1348,22 @@ function buildSlotFrameAtOwnerAnchor( footprint.activityColumnWidth, footprint.activityColumnHeight ); - const activityToKanbanGap = footprint.activityColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0; + const activityToLogGap = + footprint.activityColumnWidth > 0 && footprint.logColumnWidth > 0 + ? SLOT_GEOMETRY.boardColumnGap + : 0; + const logColumnRect = createRect( + activityColumnRect.right + activityToLogGap, + boardBandRect.top, + footprint.logColumnWidth, + footprint.logColumnHeight + ); + const feedToKanbanGap = + footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0 + ? SLOT_GEOMETRY.boardColumnGap + : 0; const kanbanBandRect = createRect( - activityColumnRect.right + activityToKanbanGap, + logColumnRect.right + feedToKanbanGap, boardBandRect.top, footprint.kanbanBandWidth, footprint.kanbanBandHeight @@ -1314,6 +1379,7 @@ function buildSlotFrameAtOwnerAnchor( ownerY, boardBandRect, activityColumnRect, + logColumnRect, processBandRect, kanbanBandRect, taskColumnCount: footprint.taskColumnCount, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8a990dbb..7fc62370 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -67,6 +67,7 @@ export interface GraphLayoutPort { version: GraphLayoutVersion; mode?: GraphLayoutMode; showActivity?: boolean; + showLogs?: boolean; ownerOrder: string[]; slotAssignments: Record; } diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index ba143518..5486d139 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -82,6 +82,7 @@ export interface GraphViewProps { leadNodeId: string ) => { x: number; y: number; scale: number; visible: boolean } | null; getActivityWorldRect: (ownerNodeId: string) => StableRect | null; + getLogWorldRect: (ownerNodeId: string) => StableRect | null; getTransientHandoffSnapshot: (options?: { focusNodeIds?: ReadonlySet | null; focusEdgeIds?: ReadonlySet | null; @@ -137,6 +138,7 @@ export function GraphView({ ? { ...data.layout, showActivity: filters.showActivity, + showLogs: filters.showActivity, } : data.layout, [data.layout, filters.showActivity] @@ -295,6 +297,10 @@ export function GraphView({ (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), [] ); + const getLogWorldRect = useCallback( + (ownerNodeId: string) => simulationRef.current.getLogWorldRect(ownerNodeId), + [] + ); const getTransientHandoffSnapshot = useCallback( (options?: { focusNodeIds?: ReadonlySet | null; @@ -1092,6 +1098,7 @@ export function GraphView({ filters, getLaunchAnchorScreenPlacement, getActivityWorldRect, + getLogWorldRect, getTransientHandoffSnapshot, getCameraZoom, worldToScreen: camera.worldToScreen, diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts new file mode 100644 index 00000000..39049efc --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -0,0 +1,362 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + type MemberLogPreviewMember, + type MemberLogPreviewRequestOptions, + normalizeMemberLogPreviewResponse, +} from '@features/member-log-stream/contracts'; +import { api } from '@renderer/api'; + +import type { ResolvedTeamMember, TeamChangeEvent } from '@shared/types/team'; + +const LIVE_RELOAD_DEBOUNCE_MS = 650; +const PREVIEW_CACHE_TTL_MS = 3_500; +const DEFAULT_MAX_ITEMS = 3; +const DEFAULT_TEXT_LIMIT = 200; + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function buildRequestKey(input: { + teamName: string; + memberNames: readonly string[]; + laneIdsByMember: Readonly>; + maxItemsPerMember: number; + textLimit: number; + forceRefresh?: boolean; +}): string { + const laneEntries = Object.entries(input.laneIdsByMember) + .map(([memberName, laneId]) => [normalizeMemberName(memberName), laneId.trim()] as const) + .filter(([, laneId]) => laneId.length > 0) + .sort((left, right) => left[0].localeCompare(right[0])); + return JSON.stringify([ + input.teamName, + input.memberNames.map(normalizeMemberName), + laneEntries, + input.maxItemsPerMember, + input.textLimit, + input.forceRefresh === true, + ]); +} + +function memberMapFromResponse( + members: readonly MemberLogPreviewMember[] +): Map { + return new Map(members.map((member) => [normalizeMemberName(member.memberName), member])); +} + +function mergeMemberPreviews( + base: Map, + members: Iterable +): Map { + const next = new Map(base); + for (const member of members) { + next.set(normalizeMemberName(member.memberName), member); + } + return next; +} + +function laneIdForMember( + memberName: string, + laneIdsByMember: Readonly> +): string { + return ( + laneIdsByMember[memberName]?.trim() ?? + laneIdsByMember[normalizeMemberName(memberName)]?.trim() ?? + '' + ); +} + +function buildMemberCacheKey(input: { + teamName: string; + memberName: string; + laneIdsByMember: Readonly>; + maxItemsPerMember: number; + textLimit: number; +}): string { + return JSON.stringify([ + input.teamName, + normalizeMemberName(input.memberName), + laneIdForMember(input.memberName, input.laneIdsByMember), + input.maxItemsPerMember, + input.textLimit, + ]); +} + +export function getSafeGraphLogPreviewLaneId( + member: ResolvedTeamMember | undefined +): string | undefined { + if (!member) return undefined; + if (member.providerId !== 'opencode') return undefined; + if (member.laneOwnerProviderId !== 'opencode') return undefined; + const laneId = member.laneId?.trim(); + return laneId ? laneId : undefined; +} + +export function buildGraphLogPreviewLaneIdsByMember( + members: readonly ResolvedTeamMember[] +): Record { + const laneIdsByMember: Record = {}; + for (const member of members) { + const laneId = getSafeGraphLogPreviewLaneId(member); + if (!laneId) continue; + laneIdsByMember[member.name] = laneId; + laneIdsByMember[normalizeMemberName(member.name)] = laneId; + } + return laneIdsByMember; +} + +export function useGraphMemberLogPreviews(input: { + teamName: string; + memberNames: readonly string[]; + laneIdsByMember?: Readonly>; + enabled?: boolean; + maxItemsPerMember?: number; + textLimit?: number; +}): { + previewsByMember: Map; + loading: boolean; + error: string | null; + reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise; +} { + const enabled = input.enabled ?? true; + const maxItemsPerMember = Math.max( + 1, + Math.min(3, Math.floor(input.maxItemsPerMember ?? DEFAULT_MAX_ITEMS)) + ); + const textLimit = Math.max(80, Math.min(240, Math.floor(input.textLimit ?? DEFAULT_TEXT_LIMIT))); + const laneIdsByMember = useMemo( + () => ({ ...(input.laneIdsByMember ?? {}) }), + [input.laneIdsByMember] + ); + const memberNames = useMemo(() => { + const seen = new Set(); + const result: string[] = []; + for (const memberName of input.memberNames) { + const trimmed = memberName.trim(); + if (!trimmed) continue; + const key = normalizeMemberName(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; + }, [input.memberNames]); + const memberKey = useMemo(() => memberNames.map(normalizeMemberName).join('|'), [memberNames]); + const [previewsByMember, setPreviewsByMember] = useState( + new Map() + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const cacheRef = useRef(new Map()); + const previewsByMemberRef = useRef(previewsByMember); + const inFlightRef = useRef(new Map>>()); + const reloadTimerRef = useRef | null>(null); + const teamNameRef = useRef(input.teamName); + + useEffect(() => { + previewsByMemberRef.current = previewsByMember; + }, [previewsByMember]); + + useEffect(() => { + if (teamNameRef.current !== input.teamName) { + teamNameRef.current = input.teamName; + cacheRef.current.clear(); + inFlightRef.current.clear(); + setPreviewsByMember(new Map()); + } + if (!enabled || memberNames.length === 0) { + setLoading(false); + } + setError(null); + }, [enabled, input.teamName, memberKey, memberNames.length]); + + const loadPreviews = useCallback( + async (options?: { forceRefresh?: boolean; background?: boolean }): Promise => { + if (!enabled || memberNames.length === 0) { + setLoading(false); + setError(null); + return; + } + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return; + } + + const now = Date.now(); + const membersToRequest: string[] = []; + const cachedMembers: MemberLogPreviewMember[] = []; + let hasMissingPreview = false; + + for (const memberName of memberNames) { + const cacheKey = buildMemberCacheKey({ + teamName: input.teamName, + memberName, + laneIdsByMember, + maxItemsPerMember, + textLimit, + }); + const cached = cacheRef.current.get(cacheKey); + if (cached) { + cachedMembers.push(cached.member); + } + if (options?.forceRefresh || !cached || cached.expiresAt <= now) { + membersToRequest.push(memberName); + } + const normalizedMemberName = normalizeMemberName(memberName); + if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) { + hasMissingPreview = true; + } + } + + if (cachedMembers.length > 0) { + setPreviewsByMember((current) => mergeMemberPreviews(current, cachedMembers)); + } + + if (membersToRequest.length === 0) { + setLoading(false); + setError(null); + return; + } + + const requestKey = buildRequestKey({ + teamName: input.teamName, + memberNames: membersToRequest, + laneIdsByMember, + maxItemsPerMember, + textLimit, + forceRefresh: options?.forceRefresh, + }); + const requestTeamName = input.teamName; + + if (!options?.background && hasMissingPreview) { + setLoading(true); + setError(null); + } + + try { + let request = inFlightRef.current.get(requestKey); + if (!request) { + const requestOptions: MemberLogPreviewRequestOptions = { + maxItemsPerMember, + textLimit, + ...(Object.keys(laneIdsByMember).length > 0 ? { laneIdsByMember } : {}), + ...(options?.forceRefresh ? { forceRefresh: true } : {}), + }; + request = api.memberLogStream + .getMemberLogPreviews(input.teamName, membersToRequest, requestOptions) + .then((response) => { + const normalized = normalizeMemberLogPreviewResponse(response); + const members = memberMapFromResponse(normalized.members); + for (const member of members.values()) { + cacheRef.current.set( + buildMemberCacheKey({ + teamName: input.teamName, + memberName: member.memberName, + laneIdsByMember, + maxItemsPerMember, + textLimit, + }), + { + expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS, + member, + } + ); + } + return members; + }) + .finally(() => { + inFlightRef.current.delete(requestKey); + }); + inFlightRef.current.set(requestKey, request); + } + + const members = await request; + if (teamNameRef.current !== requestTeamName) { + return; + } + setPreviewsByMember((current) => mergeMemberPreviews(current, members.values())); + setError(null); + } catch (loadError) { + if (teamNameRef.current !== requestTeamName) { + return; + } + setError( + loadError instanceof Error ? loadError.message : 'Failed to load graph log previews' + ); + } finally { + if (teamNameRef.current === requestTeamName) { + setLoading(false); + } + } + }, + [enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit] + ); + + useEffect(() => { + if (!enabled || memberNames.length === 0) { + setLoading(false); + setError(null); + return; + } + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + } + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + void loadPreviews(); + }, LIVE_RELOAD_DEBOUNCE_MS); + return () => { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + }; + }, [enabled, loadPreviews, memberKey, memberNames.length]); + + useEffect(() => { + if (!enabled) return; + + const scheduleReload = (forceRefresh: boolean): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (memberNames.length === 0) return; + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + void loadPreviews({ background: true, forceRefresh }); + }, LIVE_RELOAD_DEBOUNCE_MS); + }; + + const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => { + if (event.teamName !== input.teamName) return; + if (event.type === 'log-source-change') { + scheduleReload(true); + return; + } + if (event.type === 'task-log-change') { + scheduleReload(false); + } + }); + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') scheduleReload(false); + }; + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + return () => { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + if (typeof unsubscribe === 'function') unsubscribe(); + }; + }, [enabled, input.teamName, loadPreviews, memberNames.length]); + + return { previewsByMember, loading, error, reload: loadPreviews }; +} diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx new file mode 100644 index 00000000..26f0e196 --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -0,0 +1,382 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { + AlertCircle, + Brain, + CheckCircle2, + MessageSquareText, + Terminal, + Wrench, +} from 'lucide-react'; + +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; +import { + buildGraphLogPreviewLaneIdsByMember, + useGraphMemberLogPreviews, +} from '../hooks/useGraphMemberLogPreviews'; + +import type { GraphNode } from '@claude-teams/agent-graph'; +import type { + MemberLogPreviewItem, + MemberLogPreviewMember, +} from '@features/member-log-stream/contracts'; +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; + +const LOG_PREVIEW_FALLBACK_WIDTH = 260; +const LOG_PREVIEW_FALLBACK_HEIGHT = 292; + +interface StableRectLike { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +interface GraphMemberLogPreviewHudProps { + teamName: string; + nodes: GraphNode[]; + getLogWorldRect?: (ownerNodeId: string) => StableRectLike | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getViewportSize?: () => { width: number; height: number }; + focusNodeIds: ReadonlySet | null; + enabled?: boolean; + onOpenMemberProfile?: ( + memberName: string, + options?: { + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } + ) => void; +} + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function formatRelativeTime(timestamp: string): string { + const parsed = Date.parse(timestamp); + if (!Number.isFinite(parsed)) return ''; + const diffMs = Date.now() - parsed; + if (diffMs < 60_000) return 'now'; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; +} + +function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { + const className = 'size-3.5 shrink-0'; + const title = item.title.trim().toLowerCase(); + if ( + title === 'send message' || + title === 'message sent' || + title === 'add comment' || + title === 'comment added' + ) { + return ; + } + if (item.tone === 'error') { + return ; + } + if (item.kind === 'tool_result') { + return ; + } + if (item.kind === 'tool_use') { + return ; + } + if (item.kind === 'thinking') { + return ; + } + return ; +} + +function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: boolean): string { + if (loading && !preview) return 'Loading logs'; + if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) { + return 'Unsupported provider'; + } + return 'No recent logs'; +} + +function setShellHidden(shell: HTMLDivElement): void { + shell.style.opacity = '0'; + shell.style.pointerEvents = 'none'; +} + +export const GraphMemberLogPreviewHud = ({ + teamName, + nodes, + getLogWorldRect = () => null, + getCameraZoom = () => 1, + worldToScreen, + getViewportSize, + focusNodeIds, + enabled = true, + onOpenMemberProfile, +}: GraphMemberLogPreviewHudProps): React.JSX.Element | null => { + const worldLayerRef = useRef(null); + const shellRefs = useRef(new Map()); + const visibleKeyRef = useRef(''); + const [visibleMemberNames, setVisibleMemberNames] = useState([]); + const { teamData } = useGraphActivityContext(teamName); + const members = teamData?.members ?? []; + const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]); + const ownerNodes = useMemo( + () => + nodes.filter((node): node is GraphNode & { kind: 'lead' | 'member' } => { + return ( + (node.kind === 'lead' || node.kind === 'member') && + (node.domainRef.kind === 'lead' || node.domainRef.kind === 'member') + ); + }), + [nodes] + ); + const { previewsByMember, loading } = useGraphMemberLogPreviews({ + teamName, + memberNames: visibleMemberNames, + laneIdsByMember, + enabled: enabled && visibleMemberNames.length > 0, + maxItemsPerMember: 3, + textLimit: 200, + }); + + const openLogs = useCallback( + (memberName: string) => { + onOpenMemberProfile?.(memberName, { initialTab: 'logs' }); + }, + [onOpenMemberProfile] + ); + + useLayoutEffect(() => { + if (!enabled || ownerNodes.length === 0) { + for (const shell of shellRefs.current.values()) { + if (shell) setShellHidden(shell); + } + setVisibleMemberNames([]); + visibleKeyRef.current = ''; + return; + } + + let frameId = 0; + const updatePositions = (): void => { + const worldLayer = worldLayerRef.current; + if (worldLayer && worldToScreen) { + const origin = worldToScreen(0, 0); + const zoom = Math.max(getCameraZoom(), 0.001); + worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; + } + + const visibleNames: string[] = []; + for (const node of ownerNodes) { + const shell = shellRefs.current.get(node.id); + if (!shell) continue; + + const laneRect = getLogWorldRect(node.id); + if (!laneRect || !worldToScreen || laneRect.width <= 0 || laneRect.height <= 0) { + setShellHidden(shell); + continue; + } + + const zoom = Math.max(getCameraZoom(), 0.001); + const screenTopLeft = worldToScreen(laneRect.left, laneRect.top); + const widthScreen = Math.max(1, laneRect.width * zoom); + const heightScreen = Math.max(1, laneRect.height * zoom); + const viewport = getViewportSize?.(); + const laneVisible = + !viewport || + (screenTopLeft.x + widthScreen > -80 && + screenTopLeft.x < viewport.width + 80 && + screenTopLeft.y + heightScreen > -80 && + screenTopLeft.y < viewport.height + 80); + if (!laneVisible) { + setShellHidden(shell); + continue; + } + + const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1; + shell.style.opacity = String(baseOpacity); + shell.style.pointerEvents = 'auto'; + shell.style.left = `${Math.round(laneRect.left)}px`; + shell.style.top = `${Math.round(laneRect.top)}px`; + shell.style.width = `${Math.round(laneRect.width)}px`; + shell.style.height = `${Math.round(laneRect.height)}px`; + if (node.domainRef.kind === 'lead' || node.domainRef.kind === 'member') { + visibleNames.push(node.domainRef.memberName); + } + } + + const nextVisibleKey = visibleNames + .map(normalizeMemberName) + .sort((left, right) => left.localeCompare(right)) + .join('|'); + if (nextVisibleKey !== visibleKeyRef.current) { + visibleKeyRef.current = nextVisibleKey; + setVisibleMemberNames(visibleNames); + } + + frameId = window.requestAnimationFrame(updatePositions); + }; + + updatePositions(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [ + enabled, + focusNodeIds, + getCameraZoom, + getLogWorldRect, + getViewportSize, + ownerNodes, + worldToScreen, + ]); + + const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => { + const graphRoot = shell.closest('.team-graph-view'); + const canvas = graphRoot?.querySelector('canvas'); + if (!(canvas instanceof HTMLCanvasElement)) { + return; + } + if (event.cancelable) { + event.preventDefault(); + } + canvas.dispatchEvent( + new WheelEvent('wheel', { + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + bubbles: true, + cancelable: true, + }) + ); + }, []); + + useEffect(() => { + if (!enabled) { + return; + } + const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = []; + for (const node of ownerNodes) { + const shell = shellRefs.current.get(node.id); + if (!shell) continue; + const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell); + shell.addEventListener('wheel', handler, { passive: false }); + listeners.push({ shell, handler }); + } + return () => { + for (const { shell, handler } of listeners) { + shell.removeEventListener('wheel', handler); + } + }; + }, [enabled, forwardWheelToGraph, ownerNodes]); + + const renderItem = useCallback( + (memberName: string, item: MemberLogPreviewItem) => ( + + ), + [openLogs] + ); + + if (!enabled || ownerNodes.length === 0) { + return null; + } + + return ( +
+ {ownerNodes.map((node) => { + const laneRect = getLogWorldRect(node.id); + const laneWidth = laneRect?.width ?? LOG_PREVIEW_FALLBACK_WIDTH; + const laneHeight = laneRect?.height ?? LOG_PREVIEW_FALLBACK_HEIGHT; + const memberName = + node.domainRef.kind === 'lead' || node.domainRef.kind === 'member' + ? node.domainRef.memberName + : node.label; + const preview = previewsByMember.get(normalizeMemberName(memberName)); + const items = preview?.items ?? []; + + return ( +
{ + shellRefs.current.set(node.id, element); + }} + className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0" + style={{ + width: `${laneWidth}px`, + maxWidth: `${laneWidth}px`, + height: `${laneHeight}px`, + }} + onDragStart={(event) => { + event.preventDefault(); + }} + > +
+
+ + Logs +
+
+ {items.length > 0 ? ( + items.slice(0, 3).map((item) => renderItem(memberName, item)) + ) : ( + + )} + {preview && preview.overflowCount > 0 ? ( + + ) : null} +
+
+
+ ); + })} +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 3de4ab0a..b9f549d7 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -15,6 +15,7 @@ import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions' import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; +import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; @@ -148,6 +149,14 @@ export const TeamGraphOverlay = ({ width: number; height: number; } | null; + getLogWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; getTransientHandoffSnapshot?: (options?: { focusNodeIds?: ReadonlySet | null; @@ -186,6 +195,17 @@ export const TeamGraphOverlay = ({ onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} /> + ); }} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 14e21ec7..43ba87fb 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -15,6 +15,7 @@ import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions' import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; +import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; @@ -168,6 +169,14 @@ export const TeamGraphTab = ({ width: number; height: number; } | null; + getLogWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; getTransientHandoffSnapshot?: (options?: { focusNodeIds?: ReadonlySet | null; @@ -207,6 +216,17 @@ export const TeamGraphTab = ({ onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} /> + ); }} diff --git a/src/features/member-log-stream/contracts/api.ts b/src/features/member-log-stream/contracts/api.ts index 01480ada..69283538 100644 --- a/src/features/member-log-stream/contracts/api.ts +++ b/src/features/member-log-stream/contracts/api.ts @@ -1,4 +1,9 @@ -import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from './dto'; +import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from './dto'; export interface MemberLogStreamApi { getMemberLogStream( @@ -6,5 +11,10 @@ export interface MemberLogStreamApi { memberName: string, options?: MemberLogStreamRequestOptions ): Promise; + getMemberLogPreviews( + teamName: string, + memberNames: string[], + options?: MemberLogPreviewRequestOptions + ): Promise; setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; } diff --git a/src/features/member-log-stream/contracts/channels.ts b/src/features/member-log-stream/contracts/channels.ts index 88799ef5..a0dc0115 100644 --- a/src/features/member-log-stream/contracts/channels.ts +++ b/src/features/member-log-stream/contracts/channels.ts @@ -1,2 +1,3 @@ export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; +export const MEMBER_LOG_STREAM_GET_PREVIEWS = 'member-log-stream:getMemberLogPreviews'; export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; diff --git a/src/features/member-log-stream/contracts/dto.ts b/src/features/member-log-stream/contracts/dto.ts index 2d05c0f5..d0c7cfda 100644 --- a/src/features/member-log-stream/contracts/dto.ts +++ b/src/features/member-log-stream/contracts/dto.ts @@ -18,6 +18,13 @@ export interface MemberLogStreamRequestOptions { forceRefresh?: boolean; } +export interface MemberLogPreviewRequestOptions { + maxItemsPerMember?: number; + textLimit?: number; + laneIdsByMember?: Record; + forceRefresh?: boolean; +} + export interface MemberLogStreamCoverage { provider: MemberLogStreamProvider; status: 'included' | 'partial' | 'skipped'; @@ -70,3 +77,36 @@ export interface MemberLogStreamResponse { generatedAt: string; metadata: MemberLogStreamMetadata; } + +export type MemberLogPreviewItemKind = 'text' | 'tool_use' | 'tool_result' | 'thinking'; + +export type MemberLogPreviewItemTone = 'neutral' | 'success' | 'warning' | 'error'; + +export interface MemberLogPreviewItem { + id: string; + kind: MemberLogPreviewItemKind; + provider: MemberLogStreamProvider; + timestamp: string; + title: string; + preview?: string; + tone: MemberLogPreviewItemTone; + toolName?: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; +} + +export interface MemberLogPreviewMember { + memberName: string; + items: MemberLogPreviewItem[]; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + overflowCount: number; + generatedAt: string; +} + +export interface MemberLogPreviewResponse { + members: MemberLogPreviewMember[]; + generatedAt: string; +} diff --git a/src/features/member-log-stream/contracts/normalize.ts b/src/features/member-log-stream/contracts/normalize.ts index d528d75e..7de2556b 100644 --- a/src/features/member-log-stream/contracts/normalize.ts +++ b/src/features/member-log-stream/contracts/normalize.ts @@ -1,4 +1,8 @@ -import type { MemberLogStreamResponse } from './dto'; +import type { + MemberLogPreviewMember, + MemberLogPreviewResponse, + MemberLogStreamResponse, +} from './dto'; export function createEmptyMemberLogStreamResponse( generatedAt = new Date().toISOString() @@ -42,3 +46,48 @@ export function normalizeMemberLogStreamResponse( }, }; } + +export function createEmptyMemberLogPreviewResponse( + generatedAt = new Date().toISOString() +): MemberLogPreviewResponse { + return { + members: [], + generatedAt, + }; +} + +function normalizeMemberLogPreviewMember(member: MemberLogPreviewMember): MemberLogPreviewMember { + return { + memberName: typeof member.memberName === 'string' ? member.memberName : '', + items: Array.isArray(member.items) ? member.items : [], + coverage: Array.isArray(member.coverage) ? member.coverage : [], + warnings: Array.isArray(member.warnings) ? member.warnings : [], + truncated: member.truncated === true, + overflowCount: + typeof member.overflowCount === 'number' && Number.isFinite(member.overflowCount) + ? Math.max(0, Math.floor(member.overflowCount)) + : 0, + generatedAt: + typeof member.generatedAt === 'string' && member.generatedAt.length > 0 + ? member.generatedAt + : new Date().toISOString(), + }; +} + +export function normalizeMemberLogPreviewResponse( + response: MemberLogPreviewResponse | null | undefined +): MemberLogPreviewResponse { + if (!response) { + return createEmptyMemberLogPreviewResponse(); + } + + return { + members: Array.isArray(response.members) + ? response.members.map(normalizeMemberLogPreviewMember) + : [], + generatedAt: + typeof response.generatedAt === 'string' && response.generatedAt.length > 0 + ? response.generatedAt + : new Date().toISOString(), + }; +} diff --git a/src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts b/src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts new file mode 100644 index 00000000..f90de451 --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts @@ -0,0 +1,32 @@ +import type { + MemberLogPreviewItem, + MemberLogStreamCoverage, + MemberLogStreamProvider, + MemberLogStreamWarning, +} from '../../../contracts'; +import type { MemberLogPreviewBudget } from '../../domain/models/MemberLogPreviewBudget'; + +export interface MemberLogPreviewSourceInput { + teamName: string; + memberName: string; + laneId?: string; + budget: MemberLogPreviewBudget; + maxItems: number; + textLimit: number; + forceRefresh?: boolean; +} + +export interface MemberLogPreviewSourceResult { + provider: MemberLogStreamProvider; + status: MemberLogStreamCoverage['status']; + reason?: string; + items: MemberLogPreviewItem[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + overflowCount: number; +} + +export interface MemberLogPreviewSource { + readonly provider: MemberLogStreamProvider; + loadPreview(input: MemberLogPreviewSourceInput): Promise; +} diff --git a/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts b/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts new file mode 100644 index 00000000..92d8fea2 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts @@ -0,0 +1,210 @@ +import { createEmptyMemberLogPreviewResponse } from '../../../contracts'; +import { + clampMemberLogPreviewItemLimit, + clampMemberLogPreviewTextLimit, + DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, +} from '../../domain/models/MemberLogPreviewBudget'; +import { buildMemberLogPreviewMember } from '../../domain/policies/memberLogPreviewMergePolicy'; + +import type { + MemberLogPreviewResponse, + MemberLogStreamProvider, + MemberLogStreamWarning, +} from '../../../contracts'; +import type { MemberLogPreviewBudget } from '../../domain/models/MemberLogPreviewBudget'; +import type { ClockPort } from '../ports/ClockPort'; +import type { LoggerPort } from '../ports/LoggerPort'; +import type { + MemberLogPreviewSource, + MemberLogPreviewSourceResult, +} from '../ports/MemberLogPreviewSource'; + +export interface GetMemberLogPreviewsInput { + teamName: string; + memberNames: string[]; + maxItemsPerMember?: number; + textLimit?: number; + laneIdsByMember?: Record; + forceRefresh?: boolean; +} + +interface GetMemberLogPreviewsUseCaseDeps { + sources: readonly MemberLogPreviewSource[]; + clock: ClockPort; + logger: LoggerPort; + budget?: Partial; +} + +interface NormalizedMemberRequest { + memberName: string; + laneId?: string; +} + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeMembers( + memberNames: readonly string[], + laneIdsByMember: Record | undefined, + maxMembers: number +): NormalizedMemberRequest[] { + const result: NormalizedMemberRequest[] = []; + const seen = new Set(); + for (const memberName of memberNames) { + const trimmed = memberName.trim(); + if (!trimmed) continue; + const key = normalizeMemberName(trimmed); + if (seen.has(key)) continue; + seen.add(key); + const laneId = laneIdsByMember?.[trimmed] ?? laneIdsByMember?.[key]; + result.push({ + memberName: trimmed, + ...(laneId ? { laneId } : {}), + }); + if (result.length >= maxMembers) break; + } + return result; +} + +function stableInputKey(input: { + teamName: string; + members: readonly NormalizedMemberRequest[]; + maxItems: number; + textLimit: number; + forceRefresh?: boolean; +}): string { + return JSON.stringify([ + input.teamName, + input.members.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? '']), + input.maxItems, + input.textLimit, + input.forceRefresh === true, + ]); +} + +function warningForSourceFailure( + provider: MemberLogStreamProvider, + message: string +): MemberLogStreamWarning { + return { + code: + provider === 'opencode_runtime' + ? 'opencode_runtime_unavailable' + : 'unreadable_transcript_file', + message, + }; +} + +export class GetMemberLogPreviewsUseCase { + private readonly budget: MemberLogPreviewBudget; + private readonly inFlight = new Map>(); + + constructor(private readonly deps: GetMemberLogPreviewsUseCaseDeps) { + this.budget = { ...DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, ...(deps.budget ?? {}) }; + } + + async execute(input: GetMemberLogPreviewsInput): Promise { + const maxItems = clampMemberLogPreviewItemLimit(input.maxItemsPerMember, this.budget); + const textLimit = clampMemberLogPreviewTextLimit(input.textLimit, this.budget); + const members = normalizeMembers( + input.memberNames, + input.laneIdsByMember, + this.budget.maxMembers + ); + if (members.length === 0) { + return createEmptyMemberLogPreviewResponse(new Date(this.deps.clock.now()).toISOString()); + } + + const key = stableInputKey({ + teamName: input.teamName, + members, + maxItems, + textLimit, + forceRefresh: input.forceRefresh, + }); + const existing = this.inFlight.get(key); + if (existing) { + return existing; + } + + const promise = this.buildResponse({ + input, + members, + maxItems, + textLimit, + }).finally(() => { + this.inFlight.delete(key); + }); + this.inFlight.set(key, promise); + return promise; + } + + private async buildResponse(args: { + input: GetMemberLogPreviewsInput; + members: readonly NormalizedMemberRequest[]; + maxItems: number; + textLimit: number; + }): Promise { + const generatedAt = new Date(this.deps.clock.now()).toISOString(); + if (this.deps.sources.length === 0) { + return createEmptyMemberLogPreviewResponse(generatedAt); + } + + const members = await Promise.all( + args.members.map(async (member) => { + const sourceResults = await Promise.all( + this.deps.sources.map((source): Promise => { + return source + .loadPreview({ + teamName: args.input.teamName, + memberName: member.memberName, + laneId: member.laneId, + budget: this.budget, + maxItems: args.maxItems, + textLimit: args.textLimit, + forceRefresh: args.input.forceRefresh, + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.deps.logger.warn( + `Member log preview source ${source.provider} failed for ${args.input.teamName}/${member.memberName}: ${message}` + ); + return { + provider: source.provider, + status: 'skipped', + reason: message, + items: [], + warnings: [warningForSourceFailure(source.provider, message)], + truncated: false, + overflowCount: 0, + }; + }); + }) + ); + + return buildMemberLogPreviewMember({ + memberName: member.memberName, + sourceResults: sourceResults.map((result) => ({ + coverage: { + provider: result.provider, + status: result.status, + ...(result.reason ? { reason: result.reason } : {}), + }, + items: result.items, + warnings: result.warnings, + truncated: result.truncated, + overflowCount: result.overflowCount, + })), + generatedAt, + maxItems: args.maxItems, + }); + }) + ); + + return { + members, + generatedAt, + }; + } +} diff --git a/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts new file mode 100644 index 00000000..6d70f931 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GetMemberLogPreviewsUseCase } from '../GetMemberLogPreviewsUseCase'; + +import type { + MemberLogPreviewSource, + MemberLogPreviewSourceInput, +} from '../../ports/MemberLogPreviewSource'; + +function source( + provider: MemberLogPreviewSource['provider'], + loadPreview: ( + input: MemberLogPreviewSourceInput + ) => ReturnType +): MemberLogPreviewSource { + return { provider, loadPreview }; +} + +describe('GetMemberLogPreviewsUseCase', () => { + it('dedupes members, clamps options, and merges source coverage per member', async () => { + const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({ + provider: 'claude_transcript' as const, + status: 'included' as const, + items: [ + { + id: `item:${input.memberName}`, + kind: 'text' as const, + provider: 'claude_transcript' as const, + timestamp: '2026-04-01T12:00:00.000Z', + title: 'Assistant', + preview: input.memberName, + tone: 'neutral' as const, + }, + ], + warnings: [], + truncated: false, + overflowCount: 0, + })); + const useCase = new GetMemberLogPreviewsUseCase({ + sources: [source('claude_transcript', loadPreview)], + clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') }, + logger: { warn: vi.fn(), error: vi.fn() }, + }); + + const response = await useCase.execute({ + teamName: 'alpha-team', + memberNames: ['alice', 'Alice', 'bob'], + maxItemsPerMember: 99, + textLimit: 999, + laneIdsByMember: { alice: 'secondary:opencode:alice' }, + }); + + expect(response.members.map((member) => member.memberName)).toEqual(['alice', 'bob']); + expect(loadPreview).toHaveBeenCalledTimes(2); + expect(loadPreview).toHaveBeenCalledWith( + expect.objectContaining({ + memberName: 'alice', + maxItems: 3, + textLimit: 200, + laneId: 'secondary:opencode:alice', + }) + ); + expect(response.members[0]?.coverage).toEqual([ + { provider: 'claude_transcript', status: 'included' }, + ]); + }); + + it('dedupes in-flight identical batch requests', async () => { + const loadPreview = vi.fn(async (_input: MemberLogPreviewSourceInput) => ({ + provider: 'codex_native_trace' as const, + status: 'skipped' as const, + reason: 'codex_member_wide_not_supported', + items: [], + warnings: [ + { + code: 'codex_member_wide_not_supported' as const, + message: 'Codex member-wide native trace is not available in this variant yet.', + }, + ], + truncated: false, + overflowCount: 0, + })); + const useCase = new GetMemberLogPreviewsUseCase({ + sources: [source('codex_native_trace', loadPreview)], + clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') }, + logger: { warn: vi.fn(), error: vi.fn() }, + }); + + const [first, second] = await Promise.all([ + useCase.execute({ teamName: 'alpha-team', memberNames: ['codex'] }), + useCase.execute({ teamName: 'alpha-team', memberNames: ['codex'] }), + ]); + + expect(first).toEqual(second); + expect(loadPreview).toHaveBeenCalledTimes(1); + expect(first.members[0]?.warnings[0]?.code).toBe('codex_member_wide_not_supported'); + }); +}); diff --git a/src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts b/src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts new file mode 100644 index 00000000..503ca4f2 --- /dev/null +++ b/src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts @@ -0,0 +1,41 @@ +export interface MemberLogPreviewBudget { + maxMembers: number; + maxItemsPerMember: number; + maxTextChars: number; + maxTranscriptFiles: number; + maxSourceMessagesPerProvider: number; + openCodeMessageLimit: number; + openCodeTimeoutMs: number; + cacheTtlMs: number; +} + +export const DEFAULT_MEMBER_LOG_PREVIEW_BUDGET: MemberLogPreviewBudget = { + maxMembers: 40, + maxItemsPerMember: 3, + maxTextChars: 200, + maxTranscriptFiles: 8, + maxSourceMessagesPerProvider: 120, + openCodeMessageLimit: 80, + openCodeTimeoutMs: 2_500, + cacheTtlMs: 3_000, +}; + +export function clampMemberLogPreviewItemLimit( + requested: number | undefined, + budget: MemberLogPreviewBudget = DEFAULT_MEMBER_LOG_PREVIEW_BUDGET +): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return budget.maxItemsPerMember; + } + return Math.max(1, Math.min(3, budget.maxItemsPerMember, Math.floor(requested))); +} + +export function clampMemberLogPreviewTextLimit( + requested: number | undefined, + budget: MemberLogPreviewBudget = DEFAULT_MEMBER_LOG_PREVIEW_BUDGET +): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return budget.maxTextChars; + } + return Math.max(80, Math.min(240, budget.maxTextChars, Math.floor(requested))); +} diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts new file mode 100644 index 00000000..da45a508 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; + +import { extractMemberLogPreviewItems } from '../memberLogPreviewExtractor'; + +import type { MemberLogPreviewParsedMessage } from '../memberLogPreviewExtractor'; + +function message( + overrides: Partial & { + uuid: string; + timestamp: string; + } +): MemberLogPreviewParsedMessage { + const { uuid, timestamp, ...rest } = overrides; + return { + uuid, + parentUuid: null, + type: 'assistant', + role: 'assistant', + timestamp: new Date(timestamp), + content: '', + toolCalls: [], + toolResults: [], + ...rest, + } as MemberLogPreviewParsedMessage; +} + +describe('memberLogPreviewExtractor', () => { + it('extracts bounded assistant text previews newest first', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 120, + messages: [ + message({ + uuid: 'old', + timestamp: '2026-04-01T10:00:00.000Z', + content: [{ type: 'text', text: 'older answer' }], + }), + message({ + uuid: 'new', + timestamp: '2026-04-01T10:01:00.000Z', + content: [{ type: 'text', text: 'latest answer' }], + }), + ], + }); + + expect(result.items).toHaveLength(2); + expect(result.items[0]).toMatchObject({ + kind: 'text', + title: 'Assistant', + preview: 'latest answer', + }); + expect(result.items[1]?.preview).toBe('older answer'); + }); + + it('extracts tool_use input and tool_result output without rendering huge payloads', () => { + const hugeOutput = 'x'.repeat(10_000); + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + sourceId: 'session-1', + sourceLabel: 'OpenCode runtime', + laneId: 'secondary:opencode:alice', + messages: [ + message({ + uuid: 'tool-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'toolu-1', + name: 'Bash', + input: { command: 'pnpm test -- --runInBand', ignored: hugeOutput }, + }, + ], + }), + message({ + uuid: 'tool-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu-1', + content: hugeOutput, + is_error: true, + }, + ], + toolResults: [], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool error', + tone: 'error', + laneId: 'secondary:opencode:alice', + }); + expect(result.items[0]?.preview?.length).toBeLessThanOrEqual(160); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Bash', + preview: 'pnpm test -- --runInBand', + }); + expect(result.truncated).toBe(true); + }); + + it('formats SendMessage and message_send payloads without raw JSON noise', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'send-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-send', + name: 'mcp__agent-teams__message_send', + input: { + to: 'team-lead', + from: 'tom', + summary: '#abc done', + text: 'Detailed body should stay secondary', + }, + }, + ], + }), + message({ + uuid: 'send-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: '', + toolResults: [ + { + toolUseId: 'tool-send', + content: [ + { + type: 'text', + text: JSON.stringify({ + deliveredToInbox: true, + message: { + from: 'tom', + to: 'team-lead', + text: 'Detailed body', + summary: '#abc done', + }, + }), + }, + ], + isError: false, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Message sent', + preview: 'Message sent to team-lead - #abc done', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Send message', + preview: 'to team-lead: #abc done', + }); + expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox'); + }); + + it('formats task comment result payloads without raw JSON noise', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: [ + { + type: 'text', + text: JSON.stringify({ + taskId: 'task-799', + comment: { + id: 'comment-1', + author: 'tom', + text: 'Done with UI review', + }, + }), + }, + ], + }, + ], + }), + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment added', + preview: 'Comment by tom on #task-799: Done with UI review', + }); + expect(JSON.stringify(result.items)).not.toContain('"comment"'); + }); + + it('formats plain board tool results through the paired tool_use context', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'complete-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-complete', + name: 'mcp__agent-teams__task_complete', + input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' }, + }, + ], + }), + message({ + uuid: 'complete-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-complete', + content: 'ok', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Task completed', + preview: 'Completed #abc12345', + toolName: 'mcp__agent-teams__task_complete', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Complete task', + preview: '#abc12345', + }); + }); + + it('formats wrapped Agent Teams task responses', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'task-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-task-get', + content: JSON.stringify({ + agent_teams_task_get_response: { + task: { + id: 'abc12345-0000-0000-0000-000000000000', + displayId: 'abc12345', + title: 'Fix preview alignment', + status: 'in_progress', + owner: 'tom', + }, + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Task loaded', + preview: '#abc12345: Fix preview alignment, status in_progress, owner tom', + }); + expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response'); + }); + + it('keeps orphan tool results visible because graph preview is diagnostic', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 120, + messages: [ + message({ + uuid: 'orphan', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: '', + toolResults: [ + { + toolUseId: 'missing-call', + content: 'orphan result still matters', + isError: false, + }, + ], + }), + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool result', + preview: 'orphan result still matters', + tone: 'success', + }); + }); + + it('caps preview items at three and reports overflow', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 120, + messages: [1, 2, 3, 4].map((index) => + message({ + uuid: `m-${index}`, + timestamp: `2026-04-01T10:0${index}:00.000Z`, + content: [{ type: 'text', text: `message ${index}` }], + }) + ), + }); + + expect(result.items.map((item) => item.preview)).toEqual([ + 'message 4', + 'message 3', + 'message 2', + ]); + expect(result.overflowCount).toBe(1); + expect(result.truncated).toBe(true); + }); +}); diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts new file mode 100644 index 00000000..d9bf6661 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMemberLogPreviewMember } from '../memberLogPreviewMergePolicy'; + +import type { MemberLogPreviewItem } from '../../../../contracts'; + +function item(id: string, timestamp: string): MemberLogPreviewItem { + return { + id, + kind: 'text', + provider: 'claude_transcript', + timestamp, + title: 'Assistant', + preview: id, + tone: 'neutral', + }; +} + +describe('memberLogPreviewMergePolicy', () => { + it('merges sources newest first with stable tie break and max three items', () => { + const member = buildMemberLogPreviewMember({ + memberName: 'alice', + generatedAt: '2026-04-01T12:00:00.000Z', + maxItems: 3, + sourceResults: [ + { + coverage: { provider: 'opencode_runtime', status: 'included' }, + items: [item('b', '2026-04-01T12:00:00.000Z'), item('a', '2026-04-01T12:00:00.000Z')], + warnings: [], + }, + { + coverage: { provider: 'claude_transcript', status: 'included' }, + items: [ + item('newest', '2026-04-01T12:01:00.000Z'), + item('oldest', '2026-04-01T11:59:00.000Z'), + ], + warnings: [{ code: 'large_log_window_limited', message: 'limited' }], + overflowCount: 1, + }, + ], + }); + + expect(member.items.map((preview) => preview.id)).toEqual(['newest', 'a', 'b']); + expect(member.coverage.map((coverage) => coverage.provider)).toEqual([ + 'claude_transcript', + 'opencode_runtime', + ]); + expect(member.truncated).toBe(true); + expect(member.overflowCount).toBe(2); + }); +}); diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts new file mode 100644 index 00000000..9ca89177 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -0,0 +1,1098 @@ +import type { + MemberLogPreviewItem, + MemberLogPreviewItemKind, + MemberLogPreviewItemTone, + MemberLogStreamProvider, +} from '../../../contracts'; + +export type MemberLogPreviewContentBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; thinking: string; signature?: string } + | { type: 'tool_use'; id: string; name: string; input?: unknown } + | { type: 'tool_result'; tool_use_id: string; content?: unknown; is_error?: boolean } + | { type: 'image'; source?: unknown } + | { type: string; [key: string]: unknown }; + +export interface MemberLogPreviewParsedMessage { + uuid?: string; + type?: string; + role?: string; + timestamp: Date | string; + content: string | MemberLogPreviewContentBlock[]; + toolCalls?: readonly { + id: string; + name: string; + input?: unknown; + isTask?: boolean; + }[]; + toolResults?: readonly { + toolUseId: string; + content: unknown; + isError?: boolean; + }[]; + sourceToolUseID?: string; + toolUseResult?: Record; + sessionId?: string; +} + +export interface ExtractMemberLogPreviewInput { + messages: readonly MemberLogPreviewParsedMessage[]; + provider: MemberLogStreamProvider; + maxItems: number; + textLimit: number; + sourceId?: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; +} + +export interface ExtractMemberLogPreviewResult { + items: MemberLogPreviewItem[]; + truncated: boolean; + overflowCount: number; +} + +interface Candidate { + item: MemberLogPreviewItem; + timestampMs: number; + order: number; + textTruncated: boolean; +} + +const UNKNOWN_TIMESTAMP_MS = 0; +const TOOL_INPUT_PRIORITY_KEYS = [ + 'command', + 'description', + 'summary', + 'text', + 'message', + 'comment', + 'prompt', + 'to', + 'filePath', + 'file_path', + 'path', + 'url', + 'query', +] as const; +const TOOL_RESULT_PRIORITY_KEYS = [ + 'error', + 'stderr', + 'stdout', + 'content', + 'result', + 'summary', + 'message', + 'status', +] as const; + +interface ValuePreview { + preview: string; + truncated: boolean; + title?: string; +} + +interface KnownPayloadPreview { + title?: string; + text: string; +} + +interface ToolUseContext { + id: string; + name: string; + canonicalName: string; + input?: unknown; +} + +function timestampMs(value: Date | string): number { + const time = value instanceof Date ? value.getTime() : Date.parse(value); + return Number.isFinite(time) ? time : UNKNOWN_TIMESTAMP_MS; +} + +function timestampIso(value: Date | string): string { + const time = timestampMs(value); + return new Date(time || 0).toISOString(); +} + +function stripAngleTags(value: string): string { + let result = ''; + let insideTag = false; + for (const char of value) { + if (char === '<') { + insideTag = true; + result += ' '; + continue; + } + if (char === '>') { + insideTag = false; + result += ' '; + continue; + } + if (!insideTag) { + result += char; + } + } + return result; +} + +function compactWhitespace(value: string): string { + return stripAngleTags(value).replace(/\s+/g, ' ').trim(); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + if (!looksLikeJsonPayload(value)) { + return null; + } + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } { + const compact = compactWhitespace(value); + if (compact.length <= limit) { + return { preview: compact, truncated: false }; + } + const allowed = Math.max(1, limit - 3); + return { preview: `${compact.slice(0, allowed)}...`, truncated: true }; +} + +function stringifyPrimitive(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value == null) return ''; + return ''; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function textFromTextContentBlocks(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + const text = value + .map((item) => { + const record = asRecord(item); + return record?.type === 'text' && typeof record.text === 'string' ? record.text : ''; + }) + .filter(Boolean) + .join(' '); + return text.trim().length > 0 ? text : null; +} + +function unwrapAgentTeamsResponsePayload(payload: Record): { + payload: Record; + wrapperKey?: string; +} { + const wrapperKey = Object.keys(payload).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ); + if (!wrapperKey) { + return { payload }; + } + const nested = payload[wrapperKey]; + return { payload: asRecord(nested) ?? payload, wrapperKey }; +} + +function recordFromUnknownWithWrapper( + value: unknown +): { payload: Record; wrapperKey?: string } | null { + const textBlocks = textFromTextContentBlocks(value); + if (textBlocks) { + return recordFromUnknownWithWrapper(textBlocks); + } + + if (typeof value === 'string') { + const parsed = parseJsonLikeString(value); + const record = asRecord(parsed); + return record ? unwrapAgentTeamsResponsePayload(record) : null; + } + + const record = asRecord(value); + if (!record) { + return null; + } + + if (typeof record.content === 'string') { + const nested = recordFromUnknownWithWrapper(record.content); + if (nested) { + return nested; + } + } + + return unwrapAgentTeamsResponsePayload(record); +} + +function recordFromUnknown(value: unknown): Record | null { + return recordFromUnknownWithWrapper(value)?.payload ?? null; +} + +function findPriorityValue( + record: Record, + keys: readonly string[] +): string | null { + for (const key of keys) { + const value = stringifyPrimitive(record[key]); + if (value.trim().length > 0) { + return value; + } + } + return null; +} + +function canonicalToolName(value: string): string { + const trimmed = value.trim(); + const lower = trimmed.toLowerCase(); + const doubleUnderscoreName = lower.split('__').at(-1) ?? lower; + return doubleUnderscoreName + .replace(/^agent-teams_/, '') + .replace(/^agent_teams_/, '') + .replace(/^mcp_/, ''); +} + +function canonicalToolNameFromWrapperKey(value: string | undefined): string | null { + if (!value) return null; + return ( + value + .replace(/^agent_teams_/, '') + .replace(/_response$/, '') + .trim() + .toLowerCase() || null + ); +} + +function formatToolTitle(toolName: string): string { + const canonical = canonicalToolName(toolName); + if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; + if (canonical === 'task_complete') return 'Complete task'; + if (canonical === 'task_add_comment') return 'Add comment'; + if (canonical === 'task_get_comment') return 'Read comment'; + if (canonical === 'task_get') return 'Read task'; + if (canonical === 'task_start') return 'Start task'; + if (canonical === 'task_set_status') return 'Set status'; + if (canonical === 'task_set_owner') return 'Set owner'; + if (canonical === 'task_set_clarification') return 'Set clarification'; + if (canonical === 'task_attach_comment_file') return 'Attach comment file'; + if (canonical === 'review_request') return 'Request review'; + if (canonical === 'review_start') return 'Start review'; + if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; + if (canonical === 'member_briefing') return 'Member briefing'; + if (canonical === 'task_add') return 'Add task'; + return toolName.trim() || 'Tool use'; +} + +function stringField( + record: Record | null | undefined, + key: string +): string | null { + const value = record?.[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function formatTaskRef(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed; + const shortRef = + withoutHash.includes('-') && withoutHash.length > 12 ? withoutHash.slice(0, 8) : withoutHash; + return `#${shortRef}`; +} + +function taskRefFromPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const task = asRecord(payload.task); + return formatTaskRef( + stringField(payload, 'displayId') ?? + stringField(task, 'displayId') ?? + stringField(payload, 'taskId') ?? + stringField(fallbackInput ?? undefined, 'taskId') ?? + stringField(payload, 'id') ?? + stringField(task, 'id') + ); +} + +function shortTaskSummary(task: Record | undefined): string | null { + const title = stringField(task, 'title') ?? stringField(task, 'name'); + const status = stringField(task, 'status'); + const owner = stringField(task, 'owner'); + const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter( + Boolean + ); + return parts.length > 0 ? parts.join(', ') : null; +} + +function formatTaskStatusPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const taskRef = taskRefFromPayload(payload, fallbackInput); + const status = stringField(payload, 'status') ?? stringField(asRecord(payload.task), 'status'); + if (!taskRef || !status) { + return null; + } + return `Task ${taskRef} ${status}`; +} + +function formatTaskCommentPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const commentRecord = asRecord(payload.comment) ?? undefined; + const commentText = + stringField(commentRecord, 'text') ?? + stringField(payload, 'text') ?? + stringField(payload, 'comment') ?? + stringField(fallbackInput ?? undefined, 'text'); + const taskRef = taskRefFromPayload(payload, fallbackInput); + if (!commentText) { + return taskRef ? `Comment added to ${taskRef}` : null; + } + + const author = + stringField(commentRecord, 'author') ?? + stringField(payload, 'author') ?? + stringField(fallbackInput ?? undefined, 'from') ?? + stringField(fallbackInput ?? undefined, 'author'); + if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`; + if (author) return `Comment by ${author}: ${commentText}`; + if (taskRef) return `Comment on ${taskRef}: ${commentText}`; + return `Comment: ${commentText}`; +} + +function formatTaskToolPayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + const taskRef = taskRefFromPayload(payload, fallbackInput); + const task = asRecord(payload.task) ?? undefined; + const taskSummary = shortTaskSummary(task); + const status = stringField(payload, 'status') ?? stringField(task, 'status'); + const owner = + stringField(payload, 'owner') ?? + stringField(task, 'owner') ?? + stringField(fallbackInput ?? undefined, 'owner'); + const clarification = + stringField(payload, 'clarification') ?? + stringField(fallbackInput ?? undefined, 'clarification'); + const filename = + stringField(payload, 'filename') ?? + stringField(payload, 'fileName') ?? + stringField(fallbackInput ?? undefined, 'filename') ?? + stringField(fallbackInput ?? undefined, 'fileName'); + + if (canonical === 'task_add_comment') { + const text = formatTaskCommentPayload(payload, fallbackInput); + return text ? { title: 'Comment added', text } : null; + } + if (canonical === 'task_start') { + return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null; + } + if (canonical === 'task_complete') { + return taskRef ? { title: 'Task completed', text: `Completed ${taskRef}` } : null; + } + if (canonical === 'task_get') { + if (taskRef && taskSummary) return { title: 'Task loaded', text: `${taskRef}: ${taskSummary}` }; + return taskRef ? { title: 'Task loaded', text: `Loaded ${taskRef}` } : null; + } + if (canonical === 'task_set_status') { + if (taskRef && status) return { title: 'Task status', text: `${taskRef} -> ${status}` }; + return taskRef ? { title: 'Task status', text: `Updated ${taskRef}` } : null; + } + if (canonical === 'task_set_owner') { + if (taskRef && owner) return { title: 'Task owner', text: `${taskRef} -> ${owner}` }; + return taskRef ? { title: 'Task owner', text: `Updated ${taskRef}` } : null; + } + if (canonical === 'task_set_clarification') { + if (taskRef && clarification) { + return { title: 'Clarification', text: `${taskRef} -> ${clarification}` }; + } + return taskRef ? { title: 'Clarification', text: `Updated ${taskRef}` } : null; + } + if (canonical === 'task_attach_comment_file') { + if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` }; + return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null; + } + if (canonical === 'review_request') { + const reviewer = + stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer'); + if (taskRef && reviewer) + return { title: 'Review requested', text: `${taskRef} -> ${reviewer}` }; + return taskRef ? { title: 'Review requested', text: `Requested review for ${taskRef}` } : null; + } + if (canonical === 'review_start') { + return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null; + } + if (taskRef && status) { + return { title: 'Task update', text: `Task ${taskRef} ${status}` }; + } + if (taskRef && taskSummary) { + return { title: 'Task update', text: `${taskRef}: ${taskSummary}` }; + } + return null; +} + +function formatRuntimePayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if (canonical === 'runtime_bootstrap_checkin') { + const memberName = + stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); + return { + title: 'Runtime check-in', + text: memberName ? `${memberName} checked in` : 'Runtime checked in', + }; + } + if (canonical === 'member_briefing') { + const memberName = + stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); + return { + title: 'Member briefing', + text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', + }; + } + return null; +} + +function formatErrorPayload(payload: Record): KnownPayloadPreview | null { + const error = + stringField(payload, 'error') ?? + stringField(payload, 'errorMessage') ?? + stringField(payload, 'message'); + if ( + error && + (payload.ok === false || payload.success === false || payload.error || payload.errorMessage) + ) { + return { title: 'Tool error', text: error }; + } + if (payload.ok === false || payload.success === false) { + return { title: 'Tool error', text: 'Tool reported failure' }; + } + return null; +} + +function formatMessageSendPayload(payload: Record): string | null { + const routing = asRecord(payload.routing) ?? undefined; + const messageRecord = asRecord(payload.message) ?? undefined; + const deliveryMessage = stringField(payload, 'message'); + const summary = stringField(messageRecord, 'summary') ?? stringField(routing, 'summary'); + const target = stringField(messageRecord, 'to') ?? stringField(routing, 'target'); + const messageText = + stringField(messageRecord, 'text') ?? + stringField(messageRecord, 'content') ?? + stringField(routing, 'content'); + + if (deliveryMessage && summary) return `${deliveryMessage} - ${summary}`; + if (summary && target) return `Message sent to ${target} - ${summary}`; + if (summary) return summary; + if (deliveryMessage) return deliveryMessage; + if (messageText && target) return `Message sent to ${target} - ${messageText}`; + if (messageText) return messageText; + if (target) return `Message sent to ${target}`; + return null; +} + +function formatMessageSendResultFromInput(payload: Record): string | null { + const target = stringField(payload, 'to') ?? stringField(payload, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'text') ?? + stringField(payload, 'message') ?? + stringField(payload, 'content'); + if (target && summary) return `Message sent to ${target} - ${summary}`; + if (target) return `Message sent to ${target}`; + if (summary) return summary; + return null; +} + +function formatMessageSendInputPayload(payload: Record): string | null { + const target = stringField(payload, 'to') ?? stringField(payload, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'text') ?? + stringField(payload, 'message') ?? + stringField(payload, 'content'); + if (target && summary) return `to ${target}: ${summary}`; + if (summary) return summary; + if (target) return `to ${target}`; + return null; +} + +function formatPlainToolResultStatus( + value: string, + toolContext: ToolUseContext | undefined +): KnownPayloadPreview | null { + if (!toolContext) { + return null; + } + const normalized = compactWhitespace(value).toLowerCase(); + if (!['ok', 'done', 'success', 'comment added', 'message sent'].includes(normalized)) { + return null; + } + const fallbackInput = asRecord(toolContext.input); + if (toolContext.canonicalName === 'sendmessage' || toolContext.canonicalName === 'message_send') { + const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null; + return text ? { title: 'Message sent', text } : null; + } + return ( + formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? + formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) + ); +} + +function formatTaskToolInputPayload( + canonicalToolNameValue: string, + payload: Record +): string | null { + const taskRef = taskRefFromPayload(payload, payload); + const text = stringField(payload, 'text') ?? stringField(payload, 'comment'); + const status = stringField(payload, 'status'); + const owner = stringField(payload, 'owner'); + const clarification = stringField(payload, 'clarification'); + const reviewer = stringField(payload, 'reviewer'); + + if (canonicalToolNameValue === 'task_add_comment') { + if (taskRef && text) return `on ${taskRef}: ${text}`; + if (taskRef) return `on ${taskRef}`; + if (text) return text; + return null; + } + if (canonicalToolNameValue === 'task_set_status') { + if (taskRef && status) return `${taskRef} -> ${status}`; + } + if (canonicalToolNameValue === 'task_set_owner') { + if (taskRef && owner) return `${taskRef} -> ${owner}`; + } + if (canonicalToolNameValue === 'task_set_clarification') { + if (taskRef && clarification) return `${taskRef} -> ${clarification}`; + } + if (canonicalToolNameValue === 'review_request') { + if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`; + } + if (taskRef) return taskRef; + return null; +} + +function formatKnownPayloadPreview( + value: unknown, + toolContext?: ToolUseContext +): KnownPayloadPreview | null { + const record = recordFromUnknownWithWrapper(value); + if (!record) { + return null; + } + const payload = record.payload; + const fallbackInput = asRecord(toolContext?.input); + const canonical = + toolContext?.canonicalName ?? canonicalToolNameFromWrapperKey(record.wrapperKey) ?? null; + + const errorText = formatErrorPayload(payload); + if (errorText) { + return errorText; + } + const taskToolText = formatTaskToolPayload(payload, canonical, fallbackInput); + if (taskToolText) { + return taskToolText; + } + const runtimeText = formatRuntimePayload(payload, canonical, fallbackInput); + if (runtimeText) { + return runtimeText; + } + const messageText = formatMessageSendPayload(payload); + if (messageText) { + return { title: 'Message sent', text: messageText }; + } + const commentText = formatTaskCommentPayload(payload); + if (commentText) { + return { title: 'Comment added', text: commentText }; + } + const taskText = formatTaskStatusPayload(payload, fallbackInput); + if (taskText) { + return { title: 'Task update', text: taskText }; + } + return null; +} + +function previewUnknownValue( + value: unknown, + limit: number, + priorityKeys: readonly string[], + toolContext?: ToolUseContext +): ValuePreview { + if (typeof value === 'string') { + const known = formatKnownPayloadPreview(value, toolContext); + if (known) { + return { ...truncatePreview(known.text, limit), title: known.title }; + } + const plainStatus = formatPlainToolResultStatus(value, toolContext); + if (plainStatus) { + return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; + } + return truncatePreview(value, limit); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return { preview: String(value), truncated: false }; + } + if (Array.isArray(value)) { + const textBlocks = textFromTextContentBlocks(value); + if (textBlocks) { + return previewUnknownValue(textBlocks, limit, priorityKeys, toolContext); + } + const parts = value + .slice(0, 3) + .map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview) + .filter(Boolean); + return truncatePreview(parts.join(' '), limit); + } + if (value && typeof value === 'object') { + const record = value as Record; + const known = formatKnownPayloadPreview(record, toolContext); + if (known) { + return { ...truncatePreview(known.text, limit), title: known.title }; + } + const priority = findPriorityValue(record, priorityKeys); + if (priority) { + return truncatePreview(priority, limit); + } + const parts = Object.entries(record) + .filter(([, item]) => item != null && typeof item !== 'object') + .slice(0, 4) + .map(([key, item]) => `${key}: ${String(item)}`); + if (parts.length > 0) { + return truncatePreview(parts.join(', '), limit); + } + const keys = Object.keys(record).slice(0, 5); + return truncatePreview(keys.length > 0 ? `fields: ${keys.join(', ')}` : '', limit); + } + return { preview: '', truncated: false }; +} + +function previewToolInputValue(toolName: string, value: unknown, limit: number): ValuePreview { + const canonical = canonicalToolName(toolName); + if (canonical === 'sendmessage' || canonical === 'message_send') { + const payload = recordFromUnknown(value); + const formatted = payload ? formatMessageSendInputPayload(payload) : null; + if (formatted) { + return truncatePreview(formatted, limit); + } + } + const payload = recordFromUnknown(value); + if (payload) { + const taskFormatted = formatTaskToolInputPayload(canonical, payload); + if (taskFormatted) { + return truncatePreview(taskFormatted, limit); + } + } + return previewUnknownValue(value, limit, TOOL_INPUT_PRIORITY_KEYS); +} + +function extractTextPreview( + content: string | MemberLogPreviewContentBlock[], + textLimit: number +): { preview: string; truncated: boolean } | null { + if (typeof content === 'string') { + const text = truncatePreview(content, textLimit); + return text.preview.length > 0 ? text : null; + } + const text = content + .filter((block): block is Extract => { + return block.type === 'text' && typeof block.text === 'string'; + }) + .map((block) => block.text) + .join(' '); + const preview = truncatePreview(text, textLimit); + return preview.preview.length > 0 ? preview : null; +} + +function isToolUseBlock( + block: MemberLogPreviewContentBlock +): block is Extract { + return ( + block.type === 'tool_use' && + typeof (block as { id?: unknown }).id === 'string' && + typeof (block as { name?: unknown }).name === 'string' + ); +} + +function isToolResultBlock( + block: MemberLogPreviewContentBlock +): block is Extract { + return ( + block.type === 'tool_result' && + typeof (block as { tool_use_id?: unknown }).tool_use_id === 'string' + ); +} + +function buildToolUseContexts( + messages: readonly MemberLogPreviewParsedMessage[] +): Map { + const contexts = new Map(); + const addContext = (tool: { id: string; name: string; input?: unknown }): void => { + const id = tool.id.trim(); + if (!id || contexts.has(id)) return; + contexts.set(id, { + id, + name: tool.name, + canonicalName: canonicalToolName(tool.name), + input: tool.input, + }); + }; + + for (const message of messages) { + message.toolCalls?.forEach((toolCall) => addContext(toolCall)); + if (!Array.isArray(message.content)) continue; + message.content.forEach((block) => { + if (!isToolUseBlock(block)) return; + addContext({ + id: block.id, + name: block.name, + input: block.input, + }); + }); + } + return contexts; +} + +function extractThinkingPreview( + content: string | MemberLogPreviewContentBlock[], + textLimit: number +): { preview: string; truncated: boolean } | null { + if (!Array.isArray(content)) { + return null; + } + const text = content + .filter((block): block is Extract => { + return block.type === 'thinking' && typeof block.thinking === 'string'; + }) + .map((block) => block.thinking) + .join(' '); + const preview = truncatePreview(text, textLimit); + return preview.preview.length > 0 ? preview : null; +} + +function resolveMessageRole(message: MemberLogPreviewParsedMessage): string { + return message.role ?? message.type ?? ''; +} + +function buildItemId(input: { + provider: MemberLogStreamProvider; + sourceId: string; + messageId: string; + kind: MemberLogPreviewItemKind; + token: string; +}): string { + return [ + input.provider, + input.sourceId.replace(/\s+/g, '_'), + input.messageId.replace(/\s+/g, '_'), + input.kind, + input.token.replace(/\s+/g, '_'), + ].join(':'); +} + +function buildCandidate(input: { + provider: MemberLogStreamProvider; + sourceId: string; + message: MemberLogPreviewParsedMessage; + messageIndex: number; + blockIndex: number; + kind: MemberLogPreviewItemKind; + title: string; + preview?: string; + tone?: MemberLogPreviewItemTone; + toolName?: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; + token: string; + textTruncated: boolean; +}): Candidate { + const timestamp = timestampIso(input.message.timestamp); + const messageId = input.message.uuid ?? `message-${input.messageIndex}`; + return { + item: { + id: buildItemId({ + provider: input.provider, + sourceId: input.sourceId, + messageId, + kind: input.kind, + token: input.token, + }), + kind: input.kind, + provider: input.provider, + timestamp, + title: input.title, + ...(input.preview ? { preview: input.preview } : {}), + tone: input.tone ?? 'neutral', + ...(input.toolName ? { toolName: input.toolName } : {}), + ...(input.sourceLabel ? { sourceLabel: input.sourceLabel } : {}), + ...(input.sessionId ? { sessionId: input.sessionId } : {}), + ...(input.laneId ? { laneId: input.laneId } : {}), + }, + timestampMs: timestampMs(input.message.timestamp), + order: input.messageIndex * 1_000 + input.blockIndex, + textTruncated: input.textTruncated, + }; +} + +function collectToolUseCandidates(input: { + message: MemberLogPreviewParsedMessage; + messageIndex: number; + provider: MemberLogStreamProvider; + sourceId: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; + textLimit: number; +}): Candidate[] { + const candidates: Candidate[] = []; + const seen = new Set(); + const addTool = ( + tool: { id: string; name: string; input?: unknown }, + blockIndex: number + ): void => { + const id = tool.id || `${tool.name}:${blockIndex}`; + if (seen.has(id)) return; + seen.add(id); + const preview = previewToolInputValue(tool.name, tool.input, input.textLimit); + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId: input.sourceId, + message: input.message, + messageIndex: input.messageIndex, + blockIndex, + kind: 'tool_use', + title: formatToolTitle(tool.name), + preview: preview.preview, + tone: 'warning', + toolName: tool.name, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? input.message.sessionId, + laneId: input.laneId, + token: id, + textTruncated: preview.truncated, + }) + ); + }; + + input.message.toolCalls?.forEach((toolCall, index) => addTool(toolCall, 100 + index)); + if (Array.isArray(input.message.content)) { + input.message.content.forEach((block, index) => { + if (!isToolUseBlock(block)) return; + addTool( + { + id: block.id, + name: block.name, + input: block.input, + }, + index + ); + }); + } + + return candidates; +} + +function collectToolResultCandidates(input: { + message: MemberLogPreviewParsedMessage; + messageIndex: number; + provider: MemberLogStreamProvider; + sourceId: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; + textLimit: number; + toolUseContexts: ReadonlyMap; +}): Candidate[] { + const candidates: Candidate[] = []; + const seen = new Set(); + const addResult = ( + result: { toolUseId: string; content: unknown; isError?: boolean }, + blockIndex: number + ): void => { + const id = result.toolUseId || `result:${blockIndex}`; + if (seen.has(id)) return; + seen.add(id); + const toolContext = input.toolUseContexts.get(id); + const preview = previewUnknownValue( + result.content, + input.textLimit, + TOOL_RESULT_PRIORITY_KEYS, + toolContext + ); + const isError = result.isError === true || preview.title === 'Tool error'; + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId: input.sourceId, + message: input.message, + messageIndex: input.messageIndex, + blockIndex, + kind: 'tool_result', + title: isError ? 'Tool error' : (preview.title ?? 'Tool result'), + preview: preview.preview, + tone: isError ? 'error' : 'success', + toolName: toolContext?.name, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? input.message.sessionId, + laneId: input.laneId, + token: id, + textTruncated: preview.truncated, + }) + ); + }; + + input.message.toolResults?.forEach((result, index) => + addResult( + { + toolUseId: result.toolUseId, + content: result.content, + isError: result.isError, + }, + 200 + index + ) + ); + if (input.message.sourceToolUseID && input.message.toolUseResult) { + addResult( + { + toolUseId: input.message.sourceToolUseID, + content: input.message.toolUseResult, + isError: input.message.toolUseResult.isError === true, + }, + 240 + ); + } + if (Array.isArray(input.message.content)) { + input.message.content.forEach((block, index) => { + if (!isToolResultBlock(block)) return; + addResult( + { + toolUseId: block.tool_use_id, + content: block.content, + isError: block.is_error === true, + }, + index + ); + }); + } + + return candidates; +} + +export function extractMemberLogPreviewItems( + input: ExtractMemberLogPreviewInput +): ExtractMemberLogPreviewResult { + const maxItems = Math.max(1, Math.min(3, Math.floor(input.maxItems))); + const textLimit = Math.max(80, Math.min(240, Math.floor(input.textLimit))); + const sourceId = input.sourceId ?? input.sourceLabel ?? input.provider; + const candidates: Candidate[] = []; + const toolUseContexts = buildToolUseContexts(input.messages); + + input.messages.forEach((message, messageIndex) => { + candidates.push( + ...collectToolUseCandidates({ + message, + messageIndex, + provider: input.provider, + sourceId, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId, + laneId: input.laneId, + textLimit, + }), + ...collectToolResultCandidates({ + message, + messageIndex, + provider: input.provider, + sourceId, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId, + laneId: input.laneId, + textLimit, + toolUseContexts, + }) + ); + + const role = resolveMessageRole(message); + if (role === 'assistant') { + const textPreview = extractTextPreview(message.content, textLimit); + if (textPreview) { + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId, + message, + messageIndex, + blockIndex: 10, + kind: 'text', + title: 'Assistant', + preview: textPreview.preview, + tone: 'neutral', + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? message.sessionId, + laneId: input.laneId, + token: 'assistant-text', + textTruncated: textPreview.truncated, + }) + ); + } + + const thinkingPreview = extractThinkingPreview(message.content, textLimit); + if (thinkingPreview) { + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId, + message, + messageIndex, + blockIndex: 9, + kind: 'thinking', + title: 'Thinking', + preview: thinkingPreview.preview, + tone: 'neutral', + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? message.sessionId, + laneId: input.laneId, + token: 'thinking', + textTruncated: thinkingPreview.truncated, + }) + ); + } + } + }); + + const sorted = [...candidates]; + sorted.sort((left, right) => { + const byTime = right.timestampMs - left.timestampMs; + if (byTime !== 0) return byTime; + const byOrder = right.order - left.order; + if (byOrder !== 0) return byOrder; + return left.item.id.localeCompare(right.item.id); + }); + const items = sorted.slice(0, maxItems).map((candidate) => candidate.item); + const overflowCount = Math.max(0, sorted.length - items.length); + return { + items, + truncated: overflowCount > 0 || sorted.some((candidate) => candidate.textTruncated), + overflowCount, + }; +} diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts new file mode 100644 index 00000000..4cb20e61 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts @@ -0,0 +1,83 @@ +import { MEMBER_LOG_STREAM_PROVIDER_ORDER } from './memberLogStreamMergePolicy'; + +import type { + MemberLogPreviewItem, + MemberLogPreviewMember, + MemberLogStreamCoverage, + MemberLogStreamWarning, +} from '../../../contracts'; + +export interface MemberLogPreviewSourceMergeResult { + coverage: MemberLogStreamCoverage; + items: readonly MemberLogPreviewItem[]; + warnings: readonly MemberLogStreamWarning[]; + truncated?: boolean; + overflowCount?: number; +} + +function getItemTime(item: MemberLogPreviewItem): number { + const parsed = Date.parse(item.timestamp); + return Number.isFinite(parsed) ? parsed : 0; +} + +function dedupeWarnings(warnings: readonly MemberLogStreamWarning[]): MemberLogStreamWarning[] { + const seen = new Set(); + const result: MemberLogStreamWarning[] = []; + for (const warning of warnings) { + const key = `${warning.code}:${warning.message}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(warning); + } + return result; +} + +function dedupeItems(items: readonly MemberLogPreviewItem[]): MemberLogPreviewItem[] { + const byId = new Map(); + for (const item of items) { + if (!byId.has(item.id)) { + byId.set(item.id, item); + } + } + return [...byId.values()]; +} + +export function buildMemberLogPreviewMember(input: { + memberName: string; + sourceResults: readonly MemberLogPreviewSourceMergeResult[]; + generatedAt: string; + maxItems: number; +}): MemberLogPreviewMember { + const maxItems = Math.max(1, Math.min(3, Math.floor(input.maxItems))); + const sortedItems = dedupeItems(input.sourceResults.flatMap((result) => [...result.items])).sort( + (left, right) => { + const byTime = getItemTime(right) - getItemTime(left); + return byTime !== 0 ? byTime : left.id.localeCompare(right.id); + } + ); + const items = sortedItems.slice(0, maxItems); + const sourceOverflow = input.sourceResults.reduce( + (sum, result) => sum + Math.max(0, result.overflowCount ?? 0), + 0 + ); + const overCap = Math.max(0, sortedItems.length - items.length); + + return { + memberName: input.memberName, + items, + coverage: input.sourceResults + .map((result) => result.coverage) + .sort( + (left, right) => + MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(left.provider) - + MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(right.provider) + ), + warnings: dedupeWarnings(input.sourceResults.flatMap((result) => [...result.warnings])), + truncated: + overCap > 0 || + sourceOverflow > 0 || + input.sourceResults.some((result) => result.truncated === true), + overflowCount: sourceOverflow + overCap, + generatedAt: input.generatedAt, + }; +} diff --git a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts index 322ce182..8f1569fa 100644 --- a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts +++ b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_SET_TRACKING } from '../../../../../contracts'; +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, + MEMBER_LOG_STREAM_SET_TRACKING, +} from '../../../../../contracts'; import { registerMemberLogStreamIpc, removeMemberLogStreamIpc, } from '../registerMemberLogStreamIpc'; -import type { MemberLogStreamResponse } from '../../../../../contracts'; +import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../../../../contracts'; import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature'; import type { IpcMainInvokeEvent } from 'electron'; @@ -39,6 +43,13 @@ function emptyResponse(): MemberLogStreamResponse { }; } +function emptyPreviewResponse(): MemberLogPreviewResponse { + return { + members: [], + generatedAt: '2026-03-01T00:00:00.000Z', + }; +} + function createFakeIpcMain(): { handlers: Map unknown>; ipcMain: { @@ -66,6 +77,7 @@ describe('registerMemberLogStreamIpc', () => { const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -98,6 +110,7 @@ describe('registerMemberLogStreamIpc', () => { const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -124,6 +137,7 @@ describe('registerMemberLogStreamIpc', () => { const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -172,6 +186,7 @@ describe('registerMemberLogStreamIpc', () => { const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking, }; @@ -190,6 +205,70 @@ describe('registerMemberLogStreamIpc', () => { removeMemberLogStreamIpc(ipcMain as never); expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false); + expect(handlers.has(MEMBER_LOG_STREAM_GET_PREVIEWS)).toBe(false); expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false); }); + + it('validates batch preview requests before calling the feature facade', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const getPreviews = handlers.get(MEMBER_LOG_STREAM_GET_PREVIEWS)!; + + await expect( + getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice', 'bob'], { + maxItemsPerMember: 10, + textLimit: 999, + laneIdsByMember: { + alice: ' secondary:opencode:alice ', + }, + forceRefresh: true, + }) + ).resolves.toEqual({ success: true, data: emptyPreviewResponse() }); + expect(getMemberLogPreviews).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberNames: ['alice', 'bob'], + maxItemsPerMember: 3, + textLimit: 240, + laneIdsByMember: { + alice: 'secondary:opencode:alice', + }, + forceRefresh: true, + }); + }); + + it('rejects unknown batch preview options and unsafe lane maps', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const getPreviews = handlers.get(MEMBER_LOG_STREAM_GET_PREVIEWS)!; + + await expect( + getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice'], { nope: true }) + ).resolves.toEqual({ + success: false, + error: 'Unknown getMemberLogPreviews option: nope', + }); + await expect( + getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice'], { + laneIdsByMember: { alice: '../bad' }, + }) + ).resolves.toEqual({ + success: false, + error: 'laneId contains invalid characters', + }); + expect(getMemberLogPreviews).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts index f7a50ce2..ddc18b93 100644 --- a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts +++ b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts @@ -3,17 +3,30 @@ import { createLogger } from '@shared/utils/logger'; import { MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogPreviewResponse, normalizeMemberLogStreamResponse, } from '../../../../contracts'; -import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from '../../../../contracts'; +import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from '../../../../contracts'; import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature'; import type { IpcResult } from '@shared/types'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('Feature:MemberLogStream:IPC'); const ALLOWED_OPTION_KEYS = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']); +const ALLOWED_PREVIEW_OPTION_KEYS = new Set([ + 'maxItemsPerMember', + 'textLimit', + 'laneIdsByMember', + 'forceRefresh', +]); interface ValidationResult { valid: boolean; @@ -104,6 +117,106 @@ function normalizeOptions(options: unknown): ValidationResult<{ }; } +function validateMemberNames(value: unknown): ValidationResult { + if (!Array.isArray(value)) { + return { valid: false, error: 'memberNames must be an array' }; + } + if (value.length > 80) { + return { valid: false, error: 'memberNames exceeds max length (80)' }; + } + const memberNames: string[] = []; + for (const item of value) { + const vMember = validateMemberName(item); + if (!vMember.valid) { + return { valid: false, error: vMember.error ?? 'Invalid memberName' }; + } + memberNames.push(vMember.value!); + } + return { valid: true, value: memberNames }; +} + +function normalizePreviewOptions(options: unknown): ValidationResult<{ + maxItemsPerMember?: number; + textLimit?: number; + laneIdsByMember?: Record; + forceRefresh?: boolean; +}> { + if (options == null) { + return { valid: true, value: {} }; + } + if (typeof options !== 'object' || Array.isArray(options)) { + return { valid: false, error: 'options must be an object' }; + } + + const record = options as Record; + for (const key of Object.keys(record)) { + if (!ALLOWED_PREVIEW_OPTION_KEYS.has(key)) { + return { valid: false, error: `Unknown getMemberLogPreviews option: ${key}` }; + } + } + + let maxItemsPerMember: number | undefined; + if (record.maxItemsPerMember != null) { + if ( + typeof record.maxItemsPerMember !== 'number' || + !Number.isFinite(record.maxItemsPerMember) + ) { + return { valid: false, error: 'maxItemsPerMember must be a finite number' }; + } + maxItemsPerMember = Math.max(1, Math.min(3, Math.floor(record.maxItemsPerMember))); + } + + let textLimit: number | undefined; + if (record.textLimit != null) { + if (typeof record.textLimit !== 'number' || !Number.isFinite(record.textLimit)) { + return { valid: false, error: 'textLimit must be a finite number' }; + } + textLimit = Math.max(80, Math.min(240, Math.floor(record.textLimit))); + } + + let laneIdsByMember: Record | undefined; + if (record.laneIdsByMember != null) { + if (typeof record.laneIdsByMember !== 'object' || Array.isArray(record.laneIdsByMember)) { + return { valid: false, error: 'laneIdsByMember must be an object' }; + } + laneIdsByMember = {}; + for (const [memberName, laneId] of Object.entries( + record.laneIdsByMember as Record + )) { + const vMember = validateMemberName(memberName); + if (!vMember.valid) { + return { valid: false, error: vMember.error ?? 'Invalid laneIdsByMember key' }; + } + const vLane = validateOptionalRuntimeLaneId(laneId); + if (!vLane.valid) { + return { valid: false, error: vLane.error ?? 'Invalid laneId' }; + } + if (vLane.value) { + laneIdsByMember[vMember.value!] = vLane.value; + laneIdsByMember[vMember.value!.toLowerCase()] = vLane.value; + } + } + } + + let forceRefresh: boolean | undefined; + if (record.forceRefresh != null) { + if (typeof record.forceRefresh !== 'boolean') { + return { valid: false, error: 'forceRefresh must be a boolean' }; + } + forceRefresh = record.forceRefresh; + } + + return { + valid: true, + value: { + ...(maxItemsPerMember !== undefined ? { maxItemsPerMember } : {}), + ...(textLimit !== undefined ? { textLimit } : {}), + ...(laneIdsByMember !== undefined ? { laneIdsByMember } : {}), + ...(forceRefresh !== undefined ? { forceRefresh } : {}), + }, + }; +} + export function registerMemberLogStreamIpc( ipcMain: IpcMain, feature: MemberLogStreamFeatureFacade @@ -168,9 +281,45 @@ export function registerMemberLogStreamIpc( return { success: false, error: - error instanceof Error - ? error.message - : 'Failed to update member log stream tracking', + error instanceof Error ? error.message : 'Failed to update member log stream tracking', + }; + } + } + ); + + ipcMain.handle( + MEMBER_LOG_STREAM_GET_PREVIEWS, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberNames: unknown, + options?: MemberLogPreviewRequestOptions + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vMembers = validateMemberNames(memberNames); + if (!vMembers.valid) { + return { success: false, error: vMembers.error ?? 'Invalid memberNames' }; + } + const vOptions = normalizePreviewOptions(options); + if (!vOptions.valid) { + return { success: false, error: vOptions.error ?? 'Invalid options' }; + } + + try { + const response = await feature.getMemberLogPreviews({ + teamName: vTeam.value!, + memberNames: vMembers.value!, + ...vOptions.value!, + }); + return { success: true, data: normalizeMemberLogPreviewResponse(response) }; + } catch (error) { + logger.error('Failed to load member log previews', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load member log previews', }; } } @@ -179,5 +328,6 @@ export function registerMemberLogStreamIpc( export function removeMemberLogStreamIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); + ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS); ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); } diff --git a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts new file mode 100644 index 00000000..fecff42d --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts @@ -0,0 +1,106 @@ +import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor'; + +import { dedupeMemberLogRefs } from './memberLogStreamSourceUtils'; + +import type { MemberLogStreamWarning } from '../../../../contracts'; +import type { LoggerPort } from '../../../../core/application/ports/LoggerPort'; +import type { + MemberLogPreviewSource, + MemberLogPreviewSourceInput, + MemberLogPreviewSourceResult, +} from '../../../../core/application/ports/MemberLogPreviewSource'; +import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; +import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; +import type { ParsedMessage } from '@main/types'; + +function recentMessages( + messages: readonly ParsedMessage[], + maxMessages: number +): { messages: ParsedMessage[]; dropped: number } { + if (messages.length <= maxMessages) { + return { messages: [...messages], dropped: 0 }; + } + return { + messages: messages.slice(-maxMessages), + dropped: messages.length - maxMessages, + }; +} + +export class ClaudeMemberTranscriptPreviewSource implements MemberLogPreviewSource { + readonly provider = 'claude_transcript' as const; + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly parser: BoardTaskExactLogStrictParser, + private readonly logger: LoggerPort + ) {} + + async loadPreview(input: MemberLogPreviewSourceInput): Promise { + const warnings: MemberLogStreamWarning[] = []; + const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember( + input.teamName, + [input.memberName], + { + forceRefresh: input.forceRefresh === true, + } + ); + const dedupedRefs = dedupeMemberLogRefs(refs); + const cappedRefs = dedupedRefs.slice(0, input.budget.maxTranscriptFiles); + const droppedRefCount = Math.max(0, dedupedRefs.length - cappedRefs.length); + if (droppedRefCount > 0) { + warnings.push({ + code: 'large_log_window_limited', + message: `Scanning ${cappedRefs.length} recent transcript files for graph log preview.`, + }); + } + + const parsedByPath = await this.parser.parseFiles(cappedRefs.map((ref) => ref.filePath)); + const items = []; + let droppedMessageCount = 0; + let sourceOverflowCount = 0; + let sourceTruncated = droppedRefCount > 0; + + for (const ref of cappedRefs) { + const parsedMessages = parsedByPath.get(ref.filePath) ?? []; + if (parsedMessages.length === 0) continue; + + const limited = recentMessages(parsedMessages, input.budget.maxSourceMessagesPerProvider); + droppedMessageCount += limited.dropped; + sourceTruncated = sourceTruncated || limited.dropped > 0; + + const extracted = extractMemberLogPreviewItems({ + messages: limited.messages, + provider: this.provider, + maxItems: input.maxItems, + textLimit: input.textLimit, + sourceId: ref.filePath, + sourceLabel: ref.kind === 'lead_session' ? 'Claude lead transcript' : 'Claude transcript', + sessionId: ref.sessionId, + }); + items.push(...extracted.items); + sourceOverflowCount += extracted.overflowCount; + sourceTruncated = sourceTruncated || extracted.truncated; + } + + if (droppedMessageCount > 0) { + warnings.push({ + code: 'segment_message_window_limited', + message: 'Some transcript files were trimmed to recent messages for graph preview.', + }); + } + + this.logger.debug?.( + `Claude member log preview ${input.teamName}/${input.memberName}: refs=${refs.length}, items=${items.length}` + ); + + return { + provider: this.provider, + status: items.length > 0 ? 'included' : 'skipped', + reason: items.length > 0 ? undefined : 'no_member_transcripts', + items, + warnings, + truncated: sourceTruncated, + overflowCount: sourceOverflowCount, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts index cb255dd6..d26d302c 100644 --- a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts +++ b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts @@ -4,7 +4,7 @@ import { buildMemberActor, buildMemberParticipant, buildSegmentId, - normalizeMemberName, + dedupeMemberLogRefs, shortHash, withSegmentSource, } from './memberLogStreamSourceUtils'; @@ -18,55 +18,9 @@ import type { } from '../../../../core/application/ports/MemberLogStreamSource'; import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; -import type { - MemberLogFileRef, - TeamMemberLogsFinder, -} from '@main/services/team/TeamMemberLogsFinder'; +import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import type { ParsedMessage } from '@main/types'; -function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean { - const candidateMessageCount = candidate.messageCount ?? -1; - const existingMessageCount = existing.messageCount ?? -1; - if (candidateMessageCount !== existingMessageCount) { - return candidateMessageCount > existingMessageCount; - } - - const candidateSize = candidate.sizeBytes ?? -1; - const existingSize = existing.sizeBytes ?? -1; - if (candidateSize !== existingSize) { - return candidateSize > existingSize; - } - - return candidate.mtimeMs > existing.mtimeMs; -} - -function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] { - const byFilePath = new Map(); - const bySession = new Map(); - const passthrough: MemberLogFileRef[] = []; - - for (const ref of refs) { - if (byFilePath.has(ref.filePath)) continue; - byFilePath.set(ref.filePath, ref); - - if (ref.kind === 'lead_session') { - passthrough.push(ref); - continue; - } - - const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`; - const existing = bySession.get(key); - if (!existing || isPreferredRef(ref, existing)) { - bySession.set(key, ref); - } - } - - return [...passthrough, ...bySession.values()].sort((left, right) => { - const byTime = right.mtimeMs - left.mtimeMs; - return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); - }); -} - function filterSourceMessageBudget( messages: readonly ParsedMessage[], remaining: number diff --git a/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts new file mode 100644 index 00000000..2bc20a57 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts @@ -0,0 +1,42 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + MemberLogPreviewSource, + MemberLogPreviewSourceInput, + MemberLogPreviewSourceResult, +} from '../../../../core/application/ports/MemberLogPreviewSource'; +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; + +export class CodexNativeMemberTracePreviewSource implements MemberLogPreviewSource { + readonly provider = 'codex_native_trace' as const; + + constructor(private readonly configReader: TeamConfigReader) {} + + async loadPreview(input: MemberLogPreviewSourceInput): Promise { + const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const member = config?.members?.find( + (item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase() + ); + const isCodexMember = + member?.providerId === 'codex' || + member?.providerBackendId === 'codex-native' || + (member ? false : isLeadMember({ name: input.memberName })); + + return { + provider: this.provider, + status: 'skipped', + reason: 'codex_member_wide_not_supported', + items: [], + warnings: isCodexMember + ? [ + { + code: 'codex_member_wide_not_supported', + message: 'Codex member-wide native trace is not available in this variant yet.', + }, + ] + : [], + truncated: false, + overflowCount: 0, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts new file mode 100644 index 00000000..281cbdec --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts @@ -0,0 +1,188 @@ +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper'; + +import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor'; + +import { normalizeMemberName } from './memberLogStreamSourceUtils'; + +import type { MemberLogStreamWarning } from '../../../../contracts'; +import type { + MemberLogPreviewSource, + MemberLogPreviewSourceInput, + MemberLogPreviewSourceResult, +} from '../../../../core/application/ports/MemberLogPreviewSource'; +import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; + +interface BinaryResolverLike { + resolve(): Promise; +} + +function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + if (normalized.includes('timed out') || normalized.includes('timeout')) { + return { + code: 'opencode_runtime_timeout', + message: 'OpenCode runtime preview timed out; graph preview will use other sources.', + }; + } + if ( + normalized.includes('--lane') || + normalized.includes('multiple') || + normalized.includes('ambiguous') + ) { + return { + code: 'opencode_ambiguous_lane', + message: 'OpenCode runtime session is ambiguous without a safe lane id.', + }; + } + return { + code: 'opencode_runtime_unavailable', + message: `OpenCode runtime preview is unavailable: ${message}`, + }; +} + +export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSource { + readonly provider = 'opencode_runtime' as const; + private readonly cache = new Map< + string, + { expiresAt: number; result: MemberLogPreviewSourceResult } + >(); + private readonly inFlight = new Map>(); + + constructor( + private readonly runtimeBridge: ClaudeMultimodelBridgeService, + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver + ) {} + + async loadPreview(input: MemberLogPreviewSourceInput): Promise { + const cacheKey = [ + input.teamName, + normalizeMemberName(input.memberName), + input.laneId ?? '', + input.maxItems, + input.textLimit, + input.budget.openCodeMessageLimit, + ].join('::'); + + if (!input.forceRefresh) { + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } + } + + const inFlightKey = input.forceRefresh ? `${cacheKey}::force` : cacheKey; + const existing = this.inFlight.get(inFlightKey); + if (existing) { + return existing; + } + + const promise = this.buildResult(input) + .then((result) => { + this.cache.set(cacheKey, { + expiresAt: Date.now() + input.budget.cacheTtlMs, + result, + }); + return result; + }) + .finally(() => { + this.inFlight.delete(inFlightKey); + }); + this.inFlight.set(inFlightKey, promise); + return promise; + } + + private async buildResult( + input: MemberLogPreviewSourceInput + ): Promise { + if (!input.laneId) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_safe_lane_unavailable', + items: [], + warnings: [], + truncated: false, + overflowCount: 0, + }; + } + + const binaryPath = await this.binaryResolver.resolve(); + if (!binaryPath) { + return this.skipped( + 'opencode_runtime_unavailable', + 'OpenCode runtime bridge is unavailable.' + ); + } + + try { + const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { + teamId: input.teamName, + memberName: input.memberName, + limit: input.budget.openCodeMessageLimit, + laneId: input.laneId, + timeoutMs: input.budget.openCodeTimeoutMs, + }); + const projectedMessages = transcript?.logProjection?.messages ?? []; + const parsedMessages = mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(projectedMessages) + .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()) + .slice(-input.budget.maxSourceMessagesPerProvider); + if (parsedMessages.length === 0) { + return { + provider: this.provider, + status: 'skipped', + reason: 'opencode_missing_runtime_session', + items: [], + warnings: [], + truncated: false, + overflowCount: 0, + }; + } + + const sessionId = + transcript?.sessionId ?? + parsedMessages[0]?.sessionId ?? + `opencode:${normalizeMemberName(input.memberName)}`; + const extracted = extractMemberLogPreviewItems({ + messages: parsedMessages, + provider: this.provider, + maxItems: input.maxItems, + textLimit: input.textLimit, + sourceId: sessionId, + sourceLabel: 'OpenCode runtime', + sessionId, + laneId: input.laneId, + }); + + return { + provider: this.provider, + status: extracted.items.length > 0 ? 'included' : 'skipped', + reason: extracted.items.length > 0 ? undefined : 'opencode_no_renderable_preview', + items: extracted.items, + warnings: [], + truncated: extracted.truncated, + overflowCount: extracted.overflowCount, + }; + } catch (error) { + const warning = classifyOpenCodePreviewError(error); + return this.skipped(warning.code, warning.message, warning); + } + } + + private skipped( + code: MemberLogStreamWarning['code'], + reason: string, + warning: MemberLogStreamWarning = { code, message: reason } + ): MemberLogPreviewSourceResult { + return { + provider: this.provider, + status: 'skipped', + reason, + items: [], + warnings: [warning], + truncated: false, + overflowCount: 0, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts index 179f3706..6d86937e 100644 --- a/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts +++ b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_MEMBER_LOG_PREVIEW_BUDGET } from '../../../../../core/domain/models/MemberLogPreviewBudget'; import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget'; +import { ClaudeMemberTranscriptPreviewSource } from '../ClaudeMemberTranscriptPreviewSource'; import { ClaudeMemberTranscriptStreamSource } from '../ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTracePreviewSource } from '../CodexNativeMemberTracePreviewSource'; import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimePreviewSource } from '../OpenCodeMemberRuntimePreviewSource'; import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource'; +import type { MemberLogPreviewSourceInput } from '../../../../../core/application/ports/MemberLogPreviewSource'; import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource'; import type { EnhancedChunk, ParsedMessage } from '@main/types'; @@ -49,7 +54,9 @@ function fakeChunk(id: string): EnhancedChunk { }; } -function sourceInput(overrides: Partial = {}): MemberLogStreamSourceInput { +function sourceInput( + overrides: Partial = {} +): MemberLogStreamSourceInput { return { teamName: 'alpha-team', memberName: 'alice', @@ -58,6 +65,19 @@ function sourceInput(overrides: Partial = {}): Membe }; } +function previewInput( + overrides: Partial = {} +): MemberLogPreviewSourceInput { + return { + teamName: 'alpha-team', + memberName: 'alice', + budget: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, + maxItems: 3, + textLimit: 200, + ...overrides, + }; +} + describe('ClaudeMemberTranscriptStreamSource', () => { it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => { const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => { @@ -114,6 +134,67 @@ describe('ClaudeMemberTranscriptStreamSource', () => { }); }); +describe('ClaudeMemberTranscriptPreviewSource', () => { + it('builds compact previews from parsed transcript messages without chunk building', async () => { + const parseFiles = vi.fn().mockResolvedValue( + new Map([ + [ + '/transcripts/latest.jsonl', + [ + { + ...parsedMessage('tool-call', '2026-04-04T00:00:00.000Z'), + content: [ + { + type: 'tool_use', + id: 'toolu-1', + name: 'Bash', + input: { command: 'pnpm test', ignored: 'x'.repeat(5_000) }, + }, + ], + }, + { + ...parsedMessage('tool-result', '2026-04-04T00:01:00.000Z'), + type: 'user', + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu-1', + content: 'x'.repeat(5_000), + }, + ], + }, + ], + ], + ]) + ); + const source = new ClaudeMemberTranscriptPreviewSource( + { + findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([ + { + memberName: 'alice', + sessionId: 'session-1', + filePath: '/transcripts/latest.jsonl', + mtimeMs: 20, + sizeBytes: 5_000, + messageCount: 2, + kind: 'subagent', + }, + ]), + } as never, + { parseFiles } as never, + { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + ); + + const result = await source.loadPreview(previewInput({ textLimit: 160 })); + + expect(result.status).toBe('included'); + expect(result.items.map((item) => item.kind)).toEqual(['tool_result', 'tool_use']); + expect(result.items[0]?.preview?.length).toBeLessThanOrEqual(160); + expect(parseFiles).toHaveBeenCalledWith(['/transcripts/latest.jsonl']); + }); +}); + describe('OpenCodeMemberRuntimeStreamSource', () => { it('enforces member message and content budgets before building OpenCode chunks', async () => { const getOpenCodeTranscript = vi.fn().mockResolvedValue({ @@ -133,9 +214,7 @@ describe('OpenCodeMemberRuntimeStreamSource', () => { })), }, }); - const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [ - fakeChunk('opencode-budgeted-chunk'), - ]); + const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [fakeChunk('opencode-budgeted-chunk')]); const source = new OpenCodeMemberRuntimeStreamSource( { getOpenCodeTranscript } as never, { buildBundleChunks } as never, @@ -226,7 +305,9 @@ describe('OpenCodeMemberRuntimeStreamSource', () => { it('reports ambiguous OpenCode lane errors as skipped provider warnings', async () => { const source = new OpenCodeMemberRuntimeStreamSource( { - getOpenCodeTranscript: vi.fn().mockRejectedValue(new Error('multiple records, pass --lane')), + getOpenCodeTranscript: vi + .fn() + .mockRejectedValue(new Error('multiple records, pass --lane')), } as never, { buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never, { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } @@ -247,6 +328,77 @@ describe('OpenCodeMemberRuntimeStreamSource', () => { }); }); +describe('OpenCodeMemberRuntimePreviewSource', () => { + it('skips OpenCode preview without a safe lane id before touching the runtime bridge', async () => { + const getOpenCodeTranscript = vi.fn(); + const resolve = vi.fn(); + const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, { + resolve, + }); + + const result = await source.loadPreview(previewInput()); + + expect(result).toMatchObject({ + provider: 'opencode_runtime', + status: 'skipped', + reason: 'opencode_safe_lane_unavailable', + items: [], + warnings: [], + }); + expect(resolve).not.toHaveBeenCalled(); + expect(getOpenCodeTranscript).not.toHaveBeenCalled(); + }); + + it('uses bounded OpenCode projection messages and preserves safe lane ids', async () => { + const getOpenCodeTranscript = vi.fn().mockResolvedValue({ + sessionId: 'opencode-session', + logProjection: { + messages: [ + { + uuid: 'opencode-1', + parentUuid: null, + type: 'assistant', + timestamp: '2026-04-04T00:00:00.000Z', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu-1', + name: 'Edit', + input: { filePath: 'src/app.ts' }, + }, + ], + toolCalls: [], + toolResults: [], + isMeta: false, + sessionId: 'opencode-session', + }, + ], + }, + }); + const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, { + resolve: vi.fn().mockResolvedValue('/mock/orchestrator'), + }); + + const result = await source.loadPreview(previewInput({ laneId: 'secondary:opencode:alice' })); + + expect(result.status).toBe('included'); + expect(result.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Edit', + laneId: 'secondary:opencode:alice', + }); + expect(getOpenCodeTranscript).toHaveBeenCalledWith( + '/mock/orchestrator', + expect.objectContaining({ + limit: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET.openCodeMessageLimit, + timeoutMs: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET.openCodeTimeoutMs, + laneId: 'secondary:opencode:alice', + }) + ); + }); +}); + describe('CodexNativeMemberTraceStreamSource', () => { it('returns an honest skipped warning for Codex members only', async () => { const codexSource = new CodexNativeMemberTraceStreamSource({ @@ -270,3 +422,20 @@ describe('CodexNativeMemberTraceStreamSource', () => { }); }); }); + +describe('CodexNativeMemberTracePreviewSource', () => { + it('returns unsupported empty coverage for Codex preview without breaking the batch', async () => { + const source = new CodexNativeMemberTracePreviewSource({ + getConfig: vi.fn().mockResolvedValue({ + members: [{ name: 'alice', providerId: 'codex' }], + }), + } as never); + + await expect(source.loadPreview(previewInput())).resolves.toMatchObject({ + provider: 'codex_native_trace', + status: 'skipped', + items: [], + warnings: [{ code: 'codex_member_wide_not_supported' }], + }); + }); +}); diff --git a/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts index 5dcbc046..084cb588 100644 --- a/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts +++ b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts @@ -1,9 +1,7 @@ import { createHash } from 'crypto'; -import type { - MemberLogStreamProvider, - MemberLogStreamSegmentSource, -} from '../../../../contracts'; +import type { MemberLogStreamProvider, MemberLogStreamSegmentSource } from '../../../../contracts'; +import type { MemberLogFileRef } from '@main/services/team/TeamMemberLogsFinder'; import type { BoardTaskLogActor, BoardTaskLogParticipant, @@ -18,6 +16,49 @@ export function normalizeTeamName(value: string): string { return value.trim().toLowerCase(); } +function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean { + const candidateMessageCount = candidate.messageCount ?? -1; + const existingMessageCount = existing.messageCount ?? -1; + if (candidateMessageCount !== existingMessageCount) { + return candidateMessageCount > existingMessageCount; + } + + const candidateSize = candidate.sizeBytes ?? -1; + const existingSize = existing.sizeBytes ?? -1; + if (candidateSize !== existingSize) { + return candidateSize > existingSize; + } + + return candidate.mtimeMs > existing.mtimeMs; +} + +export function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] { + const byFilePath = new Map(); + const bySession = new Map(); + const passthrough: MemberLogFileRef[] = []; + + for (const ref of refs) { + if (byFilePath.has(ref.filePath)) continue; + byFilePath.set(ref.filePath, ref); + + if (ref.kind === 'lead_session') { + passthrough.push(ref); + continue; + } + + const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`; + const existing = bySession.get(key); + if (!existing || isPreferredRef(ref, existing)) { + bySession.set(key, ref); + } + } + + return [...passthrough, ...bySession.values()].sort((left, right) => { + const byTime = right.mtimeMs - left.mtimeMs; + return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); + }); +} + export function buildMemberParticipant( memberName: string, role: 'member' | 'lead' = 'member' diff --git a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts index 7cbf3c3f..6ad0cfb1 100644 --- a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts +++ b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts @@ -2,17 +2,25 @@ import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exac import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; -import { createEmptyMemberLogStreamResponse } from '../../contracts'; +import { + createEmptyMemberLogPreviewResponse, + createEmptyMemberLogStreamResponse, +} from '../../contracts'; +import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase'; import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase'; +import { ClaudeMemberTranscriptPreviewSource } from '../adapters/output/sources/ClaudeMemberTranscriptPreviewSource'; import { ClaudeMemberTranscriptStreamSource } from '../adapters/output/sources/ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTracePreviewSource } from '../adapters/output/sources/CodexNativeMemberTracePreviewSource'; import { CodexNativeMemberTraceStreamSource } from '../adapters/output/sources/CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimePreviewSource } from '../adapters/output/sources/OpenCodeMemberRuntimePreviewSource'; import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; import { isMemberLogStreamReadEnabled } from '../featureGates'; -import type { MemberLogStreamResponse } from '../../contracts'; +import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../contracts'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort'; +import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase'; import type { GetMemberLogStreamInput } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import type { TeamLogSourceTracker } from '@main/services/team/TeamLogSourceTracker'; @@ -20,6 +28,7 @@ import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFin export interface MemberLogStreamFeatureFacade { getMemberLogStream(input: GetMemberLogStreamInput): Promise; + getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise; setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; } @@ -43,21 +52,33 @@ export function createMemberLogStreamFeature(deps: { logger: LoggerPort; }): MemberLogStreamFeatureFacade { const chunkBuilder = new BoardTaskExactLogChunkBuilder(); + const strictParser = new BoardTaskExactLogStrictParser(); + const configReader = deps.configReader ?? new TeamConfigReader(); const sources = [ new ClaudeMemberTranscriptStreamSource( deps.logsFinder, - new BoardTaskExactLogStrictParser(), + strictParser, chunkBuilder, deps.logger ), new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder), - new CodexNativeMemberTraceStreamSource(deps.configReader ?? new TeamConfigReader()), + new CodexNativeMemberTraceStreamSource(configReader), + ]; + const previewSources = [ + new ClaudeMemberTranscriptPreviewSource(deps.logsFinder, strictParser, deps.logger), + new OpenCodeMemberRuntimePreviewSource(deps.runtimeBridge), + new CodexNativeMemberTracePreviewSource(configReader), ]; const getUseCase = new GetMemberLogStreamUseCase({ sources, clock: { now: () => Date.now() }, logger: deps.logger, }); + const getPreviewsUseCase = new GetMemberLogPreviewsUseCase({ + sources: previewSources, + clock: { now: () => Date.now() }, + logger: deps.logger, + }); const trackingUseCase = new SetMemberLogStreamTrackingUseCase( new TeamLogSourceTrackerMemberStreamPort(deps.logSourceTracker) ); @@ -69,7 +90,12 @@ export function createMemberLogStreamFeature(deps: { } return getUseCase.execute(input); }, - setMemberLogStreamTracking: (teamName, enabled) => - trackingUseCase.execute(teamName, enabled), + getMemberLogPreviews: async (input) => { + if (!isMemberLogStreamReadEnabled()) { + return createEmptyMemberLogPreviewResponse(); + } + return getPreviewsUseCase.execute(input); + }, + setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled), }; } diff --git a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts index d6d955f1..50a7e489 100644 --- a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts +++ b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, MEMBER_LOG_STREAM_SET_TRACKING, } from '../../contracts'; import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge'; @@ -79,4 +80,46 @@ describe('createMemberLogStreamBridge', () => { true ); }); + + it('forwards batch member log preview IPC requests and normalizes response payloads', async () => { + mocks.ipcRenderer.invoke.mockResolvedValueOnce({ + success: true, + data: { + members: [ + { + memberName: 'alice', + items: [], + generatedAt: '2026-04-02T00:00:00.000Z', + }, + ], + generatedAt: '2026-04-02T00:00:00.000Z', + }, + }); + const bridge = createMemberLogStreamBridge(); + + const response = await bridge.getMemberLogPreviews('alpha-team', ['alice'], { + maxItemsPerMember: 3, + textLimit: 200, + laneIdsByMember: { alice: 'secondary:opencode:alice' }, + }); + + expect(response.members[0]).toMatchObject({ + memberName: 'alice', + items: [], + coverage: [], + warnings: [], + truncated: false, + overflowCount: 0, + }); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith( + MEMBER_LOG_STREAM_GET_PREVIEWS, + 'alpha-team', + ['alice'], + { + maxItemsPerMember: 3, + textLimit: 200, + laneIdsByMember: { alice: 'secondary:opencode:alice' }, + } + ); + }); }); diff --git a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts index fa971599..2c988513 100644 --- a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts +++ b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts @@ -2,11 +2,15 @@ import { ipcRenderer } from 'electron'; import { MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogPreviewResponse, normalizeMemberLogStreamResponse, } from '../contracts'; import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, MemberLogStreamApi, MemberLogStreamRequestOptions, MemberLogStreamResponse, @@ -36,6 +40,19 @@ export function createMemberLogStreamBridge(): MemberLogStreamApi { options ) ), + getMemberLogPreviews: async ( + teamName: string, + memberNames: string[], + options?: MemberLogPreviewRequestOptions + ): Promise => + normalizeMemberLogPreviewResponse( + await invokeIpcWithResult( + MEMBER_LOG_STREAM_GET_PREVIEWS, + teamName, + memberNames, + options + ) + ), setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise => invokeIpcWithResult(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled), }; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index f219c606..d1c8f753 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,10 @@ * to run in a regular browser connected to an HTTP server. */ -import { createEmptyMemberLogStreamResponse } from '@features/member-log-stream/contracts'; +import { + createEmptyMemberLogPreviewResponse, + createEmptyMemberLogStreamResponse, +} from '@features/member-log-stream/contracts'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { MemberLogStreamApi } from '@features/member-log-stream/contracts'; @@ -259,6 +262,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getMemberLogStream is not available in browser mode'); return createEmptyMemberLogStreamResponse(); }, + getMemberLogPreviews: async () => { + console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode'); + return createEmptyMemberLogPreviewResponse(); + }, setMemberLogStreamTracking: async () => { // Not available in browser mode - no-op. }, diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx new file mode 100644 index 00000000..c75c49a1 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -0,0 +1,221 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +const previewsByMember = new Map([ + [ + 'team-lead', + { + memberName: 'team-lead', + items: [ + { + id: 'lead-preview-1', + kind: 'text' as const, + provider: 'claude_transcript' as const, + timestamp: '2026-04-03T00:00:00.000Z', + title: 'Assistant', + preview: 'lead log preview', + tone: 'neutral' as const, + }, + ], + coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }], + warnings: [], + truncated: false, + overflowCount: 0, + generatedAt: '2026-04-03T00:00:00.000Z', + }, + ], + [ + 'alice', + { + memberName: 'alice', + items: [ + { + id: 'preview-1', + kind: 'tool_use' as const, + provider: 'claude_transcript' as const, + timestamp: '2026-04-03T00:00:00.000Z', + title: 'Bash', + preview: 'pnpm test', + tone: 'warning' as const, + }, + ], + coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }], + warnings: [], + truncated: true, + overflowCount: 2, + generatedAt: '2026-04-03T00:00:00.000Z', + }, + ], +]); + +vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({ + buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }), + useGraphMemberLogPreviews: () => ({ + previewsByMember, + loading: false, + error: null, + reload: vi.fn(), + }), +})); + +vi.mock('@features/agent-graph/renderer/hooks/useGraphActivityContext', () => ({ + useGraphActivityContext: () => ({ + teamData: { + members: [ + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + providerId: 'opencode', + laneOwnerProviderId: 'opencode', + laneId: 'secondary:opencode:alice', + }, + ], + }, + }), +})); + +describe('GraphMemberLogPreviewHud', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1) + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z')); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('opens the member profile on the logs tab when a preview row or overflow is clicked', async () => { + const node: GraphNode = { + id: 'member:alpha-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, + }; + const onOpenMemberProfile = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + onOpenMemberProfile={onOpenMemberProfile} + /> + ); + await Promise.resolve(); + }); + + const row = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('pnpm test') + ); + expect(row).not.toBeUndefined(); + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenMemberProfile).toHaveBeenCalledWith('alice', { initialTab: 'logs' }); + + const moreButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('+2 more') + ); + expect(moreButton).not.toBeUndefined(); + + await act(async () => { + moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenMemberProfile).toHaveBeenCalledTimes(2); + + act(() => { + root.unmount(); + }); + }); + + it('renders lead log previews and opens the lead profile logs tab', async () => { + const leadNode: GraphNode = { + id: 'lead:alpha-team', + kind: 'lead', + label: 'alpha-team', + state: 'active', + domainRef: { kind: 'lead', teamName: 'alpha-team', memberName: 'team-lead' }, + }; + const onOpenMemberProfile = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + onOpenMemberProfile={onOpenMemberProfile} + /> + ); + await Promise.resolve(); + }); + + const row = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('lead log preview') + ); + expect(row).not.toBeUndefined(); + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenMemberProfile).toHaveBeenCalledWith('team-lead', { initialTab: 'logs' }); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx new file mode 100644 index 00000000..66aefaeb --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx @@ -0,0 +1,297 @@ +import React, { act, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphMemberLogPreviews } from '@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews'; + +import type { MemberLogPreviewResponse } from '@features/member-log-stream/contracts'; + +const apiMock = vi.hoisted(() => ({ + memberLogStream: { + getMemberLogPreviews: vi.fn(), + }, + teams: { + onTeamChange: vi.fn(), + }, +})); + +vi.mock('@renderer/api', () => ({ + api: apiMock, +})); + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function response(memberName: string, generatedAt: string): MemberLogPreviewResponse { + return { + generatedAt, + members: [ + { + memberName, + items: [ + { + id: `${memberName}:${generatedAt}`, + kind: 'text', + provider: 'claude_transcript', + timestamp: generatedAt, + title: 'Assistant', + preview: memberName, + tone: 'neutral', + }, + ], + coverage: [{ provider: 'claude_transcript', status: 'included' }], + warnings: [], + truncated: false, + overflowCount: 0, + generatedAt, + }, + ], + }; +} + +const HookProbe = ({ + teamName, + memberNames, + laneIdsByMember, + enabled = true, + onState, +}: { + teamName: string; + memberNames: string[]; + laneIdsByMember?: Record; + enabled?: boolean; + onState: (state: ReturnType) => void; +}): React.JSX.Element | null => { + const state = useGraphMemberLogPreviews({ + teamName, + memberNames, + laneIdsByMember, + enabled, + }); + useEffect(() => { + onState(state); + }, [onState, state]); + return null; +}; + +describe('useGraphMemberLogPreviews', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiMock.memberLogStream.getMemberLogPreviews.mockReset(); + apiMock.teams.onTeamChange.mockReset(); + apiMock.teams.onTeamChange.mockReturnValue(() => undefined); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('debounces visible member batch requests and passes safe lane ids', async () => { + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ + maxItemsPerMember: 3, + textLimit: 200, + laneIdsByMember: { alice: 'secondary:opencode:alice' }, + }) + ); + + act(() => { + root.unmount(); + }); + }); + + it('keeps completed previews cached after the visible member set changes', async () => { + const aliceLoad = createDeferred(); + const bobLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockReturnValueOnce(aliceLoad.promise) + .mockReturnValueOnce(bobLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + aliceLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + await act(async () => { + bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob'); + + act(() => { + root.unmount(); + }); + }); + + it('keeps cached previews while pan or zoom changes the visible member batch', async () => { + const bobLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockResolvedValueOnce(response('alice', '2026-04-03T00:00:00.000Z')) + .mockReturnValueOnce(bobLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + await act(async () => { + bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob'); + + act(() => { + root.unmount(); + }); + }); + + it('reloads visible members on log-source events with force refresh', async () => { + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 99a012e9..e9dca0aa 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -149,7 +149,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); - it('builds a board band that contains both the activity column and kanban band', () => { + it('builds a board band that contains activity, logs, and kanban without overlap', () => { const teamName = 'team-process-width'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -170,9 +170,14 @@ describe('stable slot layout planner', () => { const frame = snapshot?.memberSlotFrames[0]; expect(frame).toBeDefined(); expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); + expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top); expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); - expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0); + expect(rectsOverlap(frame!.activityColumnRect, frame!.logColumnRect)).toBe(false); + expect(rectsOverlap(frame!.logColumnRect, frame!.kanbanBandRect)).toBe(false); + expect(rectsOverlap(frame!.logColumnRect, frame!.processBandRect)).toBe(false); expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight); }); @@ -346,6 +351,7 @@ describe('stable slot layout planner', () => { expect(footprint).toBeDefined(); expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width); + expect(footprint?.logColumnWidth).toBe(260); expect(footprint?.activityColumnHeight).toBe( ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + @@ -381,11 +387,15 @@ describe('stable slot layout planner', () => { expect(footprint).toBeDefined(); expect(footprint?.activityColumnWidth).toBe(0); expect(footprint?.activityColumnHeight).toBe(0); + expect(footprint?.logColumnWidth).toBe(0); + expect(footprint?.logColumnHeight).toBe(0); expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth); expect(snapshot).not.toBeNull(); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); expect(frame?.activityColumnRect.width).toBe(0); expect(frame?.activityColumnRect.height).toBe(0); + expect(frame?.logColumnRect.width).toBe(0); + expect(frame?.logColumnRect.height).toBe(0); expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left); }); @@ -1072,6 +1082,7 @@ describe('stable slot layout planner', () => { expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect); + expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.logColumnRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect); expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan( snapshot!.leadSlotFrame.bounds.width From 9a1b01b2b6b7b5ea160034d77ff4cca8a285cdb4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 17:16:06 +0300 Subject: [PATCH 03/83] chore(runtime): bump runtime lock to 0.0.22 --- .../page-2026-05-07T11-17-03-761Z.yml | 6 + graph-log-preview-smoke.png | Bin 0 -> 68578 bytes .../agent-graph/src/layout/stableSlots.ts | 19 +- runtime.lock.json | 12 +- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 157 +++++- src/features/codex-account/contracts/dto.ts | 1 + .../composition/createCodexAccountFeature.ts | 1 + .../CodexLoginSessionManager.ts | 12 + .../memberLogPreviewExtractor.test.ts | 453 ++++++++++++++++- .../policies/memberLogPreviewExtractor.ts | 481 +++++++++++++++++- .../components/dashboard/CliStatusBanner.tsx | 46 +- .../runtime/CodexLoginLinkCopyButton.tsx | 56 ++ .../runtime/ProviderRuntimeSettingsDialog.tsx | 59 ++- .../components/sidebar/GlobalTaskList.tsx | 9 + .../components/sidebar/SidebarTaskItem.tsx | 2 + .../components/sidebar/TaskContextMenu.tsx | 14 +- .../components/team/TeamDetailView.tsx | 1 + .../team/dialogs/CodexReconnectPrompt.tsx | 101 ++++ .../team/dialogs/CreateTeamDialog.tsx | 68 +-- .../team/dialogs/LaunchTeamDialog.tsx | 72 +-- .../team/dialogs/ProjectPathSelector.tsx | 62 ++- .../ProvisioningProviderStatusList.tsx | 4 +- .../team/dialogs/TeamModelSelector.tsx | 6 +- .../team/dialogs/projectPathOptions.ts | 16 +- .../team/dialogs/projectPathProjects.ts | 140 +++++ .../team/members/CurrentTaskIndicator.tsx | 81 ++- .../components/team/members/MemberCard.tsx | 13 + .../team/members/MemberHoverCard.tsx | 18 +- .../components/team/members/MemberList.tsx | 230 ++++++++- src/renderer/services/commentReadStorage.ts | 53 +- src/renderer/store/index.ts | 23 +- ...teamModelAvailability.codexCatalog.test.ts | 54 +- src/renderer/utils/memberActivityTimer.ts | 374 ++++++++++++++ src/renderer/utils/memberHelpers.ts | 48 ++ src/renderer/utils/teamModelCatalog.ts | 2 + .../main/CodexLoginSessionManager.test.ts | 3 + .../main/createCodexAccountFeature.test.ts | 5 + .../members/CurrentTaskIndicator.test.tsx | 68 +++ .../team/members/MemberHoverCard.test.ts | 56 +- .../team/members/MemberList.test.ts | 73 +++ .../GraphMemberLogPreviewHud.test.tsx | 115 ++++- .../agent-graph/useGraphSimulation.test.ts | 10 +- .../utils/memberActivityTimer.test.ts | 284 +++++++++++ test/renderer/utils/memberHelpers.test.ts | 68 +++ 44 files changed, 3191 insertions(+), 185 deletions(-) create mode 100644 .playwright-mcp/page-2026-05-07T11-17-03-761Z.yml create mode 100644 graph-log-preview-smoke.png create mode 100644 src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx create mode 100644 src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx create mode 100644 src/renderer/components/team/dialogs/projectPathProjects.ts create mode 100644 src/renderer/utils/memberActivityTimer.ts create mode 100644 test/renderer/components/team/members/CurrentTaskIndicator.test.tsx create mode 100644 test/renderer/utils/memberActivityTimer.test.ts diff --git a/.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml b/.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml new file mode 100644 index 00000000..80eb866f --- /dev/null +++ b/.playwright-mcp/page-2026-05-07T11-17-03-761Z.yml @@ -0,0 +1,6 @@ +- generic [ref=e1]: + - img + - img [ref=e2] + - generic [ref=e12]: + - generic [ref=e13]: Agent Teams AI + - generic [ref=e15]: Get more done by doing less. \ No newline at end of file diff --git a/graph-log-preview-smoke.png b/graph-log-preview-smoke.png new file mode 100644 index 0000000000000000000000000000000000000000..b8aec00b6f3d88f113c3cc084acb583e48b18153 GIT binary patch literal 68578 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 literal 0 HcmV?d00001 diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 5f8672c2..a08ea95c 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -128,6 +128,7 @@ const SLOT_GEOMETRY = { const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; const GEOMETRY_EPSILON = 0.001; +const FEED_HEADER_BOTTOM_GAP = 4; const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7; const GRID_UNDER_LEAD_COLUMN_COUNT = 2; @@ -372,7 +373,7 @@ function buildOwnerFootprint(args: { const boardBandHeight = Math.max( activityColumnHeight, logColumnHeight, - SLOT_GEOMETRY.kanbanBandHeight + SLOT_GEOMETRY.kanbanBandHeight + getKanbanBandTopInset({ activityColumnWidth, logColumnWidth }) ); const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth); const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; @@ -1362,9 +1363,10 @@ function buildSlotFrameAtOwnerAnchor( footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0; + const kanbanBandTopInset = getKanbanBandTopInset(footprint); const kanbanBandRect = createRect( logColumnRect.right + feedToKanbanGap, - boardBandRect.top, + boardBandRect.top + kanbanBandTopInset, footprint.kanbanBandWidth, footprint.kanbanBandHeight ); @@ -1390,6 +1392,19 @@ function getOwnerAnchorTopOffset(): number { return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2; } +function getKanbanBandTopInset(args: { + activityColumnWidth: number; + logColumnWidth: number; +}): number { + if (args.activityColumnWidth <= 0 && args.logColumnWidth <= 0) { + return 0; + } + + const feedCardTopInset = ACTIVITY_LANE.headerHeight + FEED_HEADER_BOTTOM_GAP; + const taskPillTopInset = KANBAN_ZONE.headerHeight - TASK_PILL.height / 2; + return Math.max(0, feedCardTopInset - taskPillTopInset); +} + function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] { const candidates: GraphOwnerSlotAssignment[] = []; for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) { diff --git a/runtime.lock.json b/runtime.lock.json index 7c6fc290..8f4467c8 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.21", - "sourceRef": "v0.0.21", + "version": "0.0.22", + "sourceRef": "v0.0.22", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.22.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.22.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.22.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.21.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.22.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 26f0e196..9a298332 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -27,6 +27,7 @@ import type { const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_HEIGHT = 292; +const NEW_LOG_HIGHLIGHT_MS = 1_000; interface StableRectLike { left: number; @@ -75,7 +76,12 @@ function formatRelativeTime(timestamp: string): string { function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { const className = 'size-3.5 shrink-0'; const title = item.title.trim().toLowerCase(); + if (item.tone === 'error') { + return ; + } if ( + title.includes('message') || + title.includes('comment') || title === 'send message' || title === 'message sent' || title === 'add comment' || @@ -83,9 +89,6 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { ) { return ; } - if (item.tone === 'error') { - return ; - } if (item.kind === 'tool_result') { return ; } @@ -106,6 +109,14 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: return 'No recent logs'; } +function compactDisplayTitle(item: MemberLogPreviewItem): string { + const title = item.title.trim(); + if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) { + return title.slice(0, -' result'.length).trim() || title; + } + return title; +} + function setShellHidden(shell: HTMLDivElement): void { shell.style.opacity = '0'; shell.style.pointerEvents = 'none'; @@ -125,7 +136,12 @@ export const GraphMemberLogPreviewHud = ({ const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const visibleKeyRef = useRef(''); + const knownItemIdsByMemberRef = useRef(new Map>()); + const highlightTimersRef = useRef(new Map>()); const [visibleMemberNames, setVisibleMemberNames] = useState([]); + const [highlightedItemIds, setHighlightedItemIds] = useState>( + () => new Set() + ); const { teamData } = useGraphActivityContext(teamName); const members = teamData?.members ?? []; const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]); @@ -155,6 +171,69 @@ export const GraphMemberLogPreviewHud = ({ [onOpenMemberProfile] ); + useEffect(() => { + knownItemIdsByMemberRef.current.clear(); + setHighlightedItemIds(new Set()); + for (const timer of highlightTimersRef.current.values()) { + clearTimeout(timer); + } + highlightTimersRef.current.clear(); + }, [teamName]); + + useEffect(() => { + return () => { + for (const timer of highlightTimersRef.current.values()) { + clearTimeout(timer); + } + highlightTimersRef.current.clear(); + }; + }, []); + + useEffect(() => { + if (!enabled) return; + + const newItemIds: string[] = []; + for (const [memberKey, preview] of previewsByMember) { + const currentIds = new Set(preview.items.map((item) => item.id)); + const knownIds = knownItemIdsByMemberRef.current.get(memberKey); + if (knownIds) { + for (const itemId of currentIds) { + if (!knownIds.has(itemId)) { + newItemIds.push(itemId); + } + } + } + knownItemIdsByMemberRef.current.set(memberKey, currentIds); + } + + if (newItemIds.length === 0) return; + + setHighlightedItemIds((current) => { + const next = new Set(current); + for (const itemId of newItemIds) { + next.add(itemId); + } + return next; + }); + + for (const itemId of newItemIds) { + const existingTimer = highlightTimersRef.current.get(itemId); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + highlightTimersRef.current.delete(itemId); + setHighlightedItemIds((current) => { + if (!current.has(itemId)) return current; + const next = new Set(current); + next.delete(itemId); + return next; + }); + }, NEW_LOG_HIGHLIGHT_MS); + highlightTimersRef.current.set(itemId, timer); + } + }, [enabled, previewsByMember]); + useLayoutEffect(() => { if (!enabled || ownerNodes.length === 0) { for (const shell of shellRefs.current.values()) { @@ -285,29 +364,57 @@ export const GraphMemberLogPreviewHud = ({ }, [enabled, forwardWheelToGraph, ownerNodes]); const renderItem = useCallback( - (memberName: string, item: MemberLogPreviewItem) => ( - - ), - [openLogs] + + + + {displayTitle} + + {relativeTime ? ( + + {relativeTime} + + ) : null} + + + {previewText} + + + ); + }, + [highlightedItemIds, openLogs] ); if (!enabled || ownerNodes.length === 0) { diff --git a/src/features/codex-account/contracts/dto.ts b/src/features/codex-account/contracts/dto.ts index 341ab757..7e8b8361 100644 --- a/src/features/codex-account/contracts/dto.ts +++ b/src/features/codex-account/contracts/dto.ts @@ -62,6 +62,7 @@ export interface CodexLoginStateDto { status: CodexAccountLoginStatus; error: string | null; startedAt: string | null; + authUrl?: string | null; } export interface CodexRuntimeContextDto { diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 728e4c16..05cb12a3 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -692,6 +692,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { status: 'idle', error: loginState.status === 'failed' ? loginState.error : null, startedAt: null, + authUrl: null, }; } diff --git a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts index be71bef5..81551872 100644 --- a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts +++ b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts @@ -26,6 +26,7 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }; private pendingStartToken: symbol | null = null; private activeSession: { @@ -71,6 +72,7 @@ export class CodexLoginSessionManager { status: 'starting', error: null, startedAt: new Date().toISOString(), + authUrl: null, }); try { @@ -135,6 +137,7 @@ export class CodexLoginSessionManager { status: 'pending', error: null, startedAt: this.state.startedAt, + authUrl: authUrl.toString(), }); await shell.openExternal(authUrl.toString()); @@ -158,6 +161,7 @@ export class CodexLoginSessionManager { status: 'failed', error: error instanceof Error ? error.message : String(error), startedAt: this.state.startedAt, + authUrl: this.state.authUrl, }); throw error; } @@ -170,6 +174,7 @@ export class CodexLoginSessionManager { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); this.emitSettled(); return; @@ -180,6 +185,7 @@ export class CodexLoginSessionManager { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); return; } @@ -207,6 +213,7 @@ export class CodexLoginSessionManager { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); this.emitSettled(); } @@ -221,6 +228,7 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }); return; } @@ -234,6 +242,7 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }); } @@ -255,12 +264,14 @@ export class CodexLoginSessionManager { status: 'idle', error: null, startedAt: null, + authUrl: null, }); } else { this.setState({ status: 'failed', error: notification.error ?? 'ChatGPT login failed.', startedAt: this.state.startedAt, + authUrl: this.state.authUrl, }); } @@ -281,6 +292,7 @@ export class CodexLoginSessionManager { status: 'failed', error: errorMessage, startedAt: this.state.startedAt, + authUrl: this.state.authUrl, }); this.emitSettled(); } diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index da45a508..a8bc4e24 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -53,6 +53,76 @@ describe('memberLogPreviewExtractor', () => { expect(result.items[1]?.preview).toBe('older answer'); }); + it('extracts readable inbound task and comment messages without agent-only blocks', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'assigned', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:00:00.000Z', + content: `New task assigned to you: #01d7462a *Calculator - final build and test command* + + +Hidden tool protocol that must not be rendered. + + +Description: +Run final validation.`, + }), + message({ + uuid: 'comment', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: `**Comment on task #1dcfefd2** _Calculator - logic smoke checklist_ + +> Logic smoke check passed. + + +Reply to this comment using MCP tool task_add_comment. +`, + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'text', + title: 'Comment received', + preview: '#1dcfefd2: Logic smoke check passed.', + }); + expect(result.items[1]).toMatchObject({ + kind: 'text', + title: 'Task assigned', + preview: '#01d7462a Calculator - final build and test command', + }); + expect(JSON.stringify(result.items)).not.toContain('info_for_agent'); + expect(JSON.stringify(result.items)).not.toContain('task_add_comment'); + }); + + it('skips meta tool-result user messages for inbound text extraction', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'meta', + type: 'user', + role: 'user', + isMeta: true, + timestamp: '2026-04-01T10:00:00.000Z', + content: 'Internal runtime metadata', + }), + ], + }); + + expect(result.items).toEqual([]); + }); + it('extracts tool_use input and tool_result output without rendering huge payloads', () => { const hugeOutput = 'x'.repeat(10_000); const result = extractMemberLogPreviewItems({ @@ -95,7 +165,7 @@ describe('memberLogPreviewExtractor', () => { expect(result.items[0]).toMatchObject({ kind: 'tool_result', - title: 'Tool error', + title: 'Bash error', tone: 'error', laneId: 'secondary:opencode:alice', }); @@ -166,15 +236,64 @@ describe('memberLogPreviewExtractor', () => { title: 'Message sent', preview: 'Message sent to team-lead - #abc done', }); + expect(result.items).toHaveLength(1); + expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox'); + }); + + it('keeps known tool names on structured error payloads', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'send-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-send', + name: 'agent-teams_message_send', + input: { + to: 'team-lead', + summary: '#abc done', + }, + }, + ], + }), + message({ + uuid: 'send-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-send', + content: { + success: false, + message: 'Delivery failed', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Send message error', + preview: 'Delivery failed', + tone: 'error', + }); expect(result.items[1]).toMatchObject({ kind: 'tool_use', title: 'Send message', preview: 'to team-lead: #abc done', }); - expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox'); }); - it('formats task comment result payloads without raw JSON noise', () => { + it('formats orphan comment result payloads without guessing add vs read semantics', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', maxItems: 3, @@ -211,12 +330,119 @@ describe('memberLogPreviewExtractor', () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ kind: 'tool_result', - title: 'Comment added', + title: 'Comment', preview: 'Comment by tom on #task-799: Done with UI review', }); expect(JSON.stringify(result.items)).not.toContain('"comment"'); }); + it('uses tool context to name comment add results precisely', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + taskId: 'task-799', + text: 'Done with UI review', + }, + }, + ], + }), + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: JSON.stringify({ + taskId: 'task-799', + comment: { + id: 'comment-1', + author: 'tom', + text: 'Done with UI review', + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment added', + preview: 'Comment by tom on #task-799: Done with UI review', + }); + expect(result.items).toHaveLength(1); + }); + + it('distinguishes read-comment results from add-comment results', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-comment', + name: 'mcp__agent-teams__task_get_comment', + input: { + taskId: 'task-799', + commentId: '47697aeb', + }, + }, + ], + }), + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: JSON.stringify({ + agent_teams_task_get_comment_response: { + taskId: 'task-799', + comment: { + id: '47697aeb-3734-4d5c-ae3e-42fafcbdea0b', + author: 'tom', + text: 'Готово по UI', + }, + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment loaded', + preview: 'Comment by tom on #task-799: Готово по UI', + }); + expect(result.items).toHaveLength(1); + expect(JSON.stringify(result.items)).not.toContain('Comment added'); + }); + it('formats plain board tool results through the paired tool_use context', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', @@ -257,6 +483,47 @@ describe('memberLogPreviewExtractor', () => { preview: 'Completed #abc12345', toolName: 'mcp__agent-teams__task_complete', }); + expect(result.items).toHaveLength(1); + }); + + it('keeps board tool input visible when the paired successful result is empty', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'complete-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-complete', + name: 'mcp__agent-teams__task_complete', + input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' }, + }, + ], + }), + message({ + uuid: 'complete-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-complete', + content: '', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Complete task result', + }); expect(result.items[1]).toMatchObject({ kind: 'tool_use', title: 'Complete task', @@ -284,7 +551,7 @@ describe('memberLogPreviewExtractor', () => { task: { id: 'abc12345-0000-0000-0000-000000000000', displayId: 'abc12345', - title: 'Fix preview alignment', + subject: 'Fix preview alignment', status: 'in_progress', owner: 'tom', }, @@ -304,6 +571,182 @@ describe('memberLogPreviewExtractor', () => { expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response'); }); + it('formats common board and cross-team tool previews compactly', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'cross-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-cross', + name: 'agent-teams_cross_team_send', + input: { + toTeam: 'design-team', + summary: 'Need UI review', + text: 'Please review compact logs', + }, + }, + { + type: 'tool_use', + id: 'tool-link', + name: 'agent-teams_task_link', + input: { + taskId: 'abc12345', + targetId: 'def67890', + relationship: 'blocked-by', + }, + }, + ], + }), + message({ + uuid: 'cross-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-cross', + content: 'ok', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Cross-team message', + preview: 'to design-team: Need UI review', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Link tasks', + preview: '#abc12345 blocked-by #def67890', + }); + expect(result.items).toHaveLength(2); + }); + + it('uses concrete names for generic runtime tool results', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'bash-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-bash', + name: 'bash', + input: { + command: 'pnpm test', + }, + }, + ], + }), + message({ + uuid: 'bash-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-bash', + content: 'Tests passed', + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Bash result', + preview: 'Tests passed', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Bash', + preview: 'pnpm test', + }); + }); + + it('does not label arbitrary message fields as sent messages', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'generic-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-generic', + content: { + message: 'generic tool status', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool result', + preview: 'generic tool status', + }); + }); + + it('formats unknown JSON string results without leaking raw JSON syntax', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'generic-json', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-generic', + content: JSON.stringify({ + payload: { + nested: true, + }, + status: 'stored', + count: 2, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool result', + preview: 'stored', + }); + expect(result.items[0]?.preview).not.toContain('{'); + }); + it('keeps orphan tool results visible because graph preview is diagnostic', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 9ca89177..fad0ef24 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -19,6 +19,7 @@ export interface MemberLogPreviewParsedMessage { role?: string; timestamp: Date | string; content: string | MemberLogPreviewContentBlock[]; + isMeta?: boolean; toolCalls?: readonly { id: string; name: string; @@ -57,6 +58,8 @@ interface Candidate { timestampMs: number; order: number; textTruncated: boolean; + toolUseKey?: string; + supersededByResult?: boolean; } const UNKNOWN_TIMESTAMP_MS = 0; @@ -139,6 +142,19 @@ function compactWhitespace(value: string): string { return stripAngleTags(value).replace(/\s+/g, ' ').trim(); } +function removeHiddenInstructionBlocks(value: string): string { + let result = value; + for (const tag of [ + 'info_for_agent', + 'opencode_runtime_identity', + 'opencode_app_message_delivery', + 'system-reminder', + ]) { + result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' '); + } + return result; +} + function looksLikeJsonPayload(value: string): boolean { const trimmed = value.trim(); return trimmed.startsWith('{') || trimmed.startsWith('['); @@ -272,24 +288,89 @@ function canonicalToolNameFromWrapperKey(value: string | undefined): string | nu ); } +function humanizeFallbackToolName(toolName: string): string { + const stripped = canonicalToolName(toolName); + if (!stripped) return 'Tool use'; + const compact = stripped.replace(/[_-]+/g, ' ').trim(); + if (!compact) return toolName.trim() || 'Tool use'; + const lower = compact.toLowerCase(); + if (lower === 'bash' || lower === 'shell') return 'Bash'; + if (lower === 'read') return 'Read'; + if (lower === 'write') return 'Write'; + if (lower === 'edit') return 'Edit'; + if (lower === 'grep') return 'Grep'; + if (lower === 'glob') return 'Glob'; + if (lower === 'ls') return 'List files'; + return compact + .split(' ') + .map((part) => (part.length > 0 ? `${part[0]?.toUpperCase()}${part.slice(1)}` : part)) + .join(' '); +} + function formatToolTitle(toolName: string): string { const canonical = canonicalToolName(toolName); if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; + if (canonical === 'cross_team_send') return 'Cross-team message'; + if (canonical === 'runtime_deliver_message') return 'Runtime delivery'; + if (canonical === 'task_create' || canonical === 'task_create_from_message') return 'Create task'; if (canonical === 'task_complete') return 'Complete task'; if (canonical === 'task_add_comment') return 'Add comment'; if (canonical === 'task_get_comment') return 'Read comment'; if (canonical === 'task_get') return 'Read task'; + if (canonical === 'task_list') return 'List tasks'; + if (canonical === 'task_briefing') return 'Task briefing'; if (canonical === 'task_start') return 'Start task'; if (canonical === 'task_set_status') return 'Set status'; if (canonical === 'task_set_owner') return 'Set owner'; if (canonical === 'task_set_clarification') return 'Set clarification'; + if (canonical === 'task_attach_file') return 'Attach file'; if (canonical === 'task_attach_comment_file') return 'Attach comment file'; + if (canonical === 'task_link') return 'Link tasks'; + if (canonical === 'task_unlink') return 'Unlink tasks'; + if (canonical === 'task_restore') return 'Restore task'; if (canonical === 'review_request') return 'Request review'; if (canonical === 'review_start') return 'Start review'; + if (canonical === 'review_approve') return 'Approve review'; + if (canonical === 'review_request_changes') return 'Request changes'; if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; if (canonical === 'member_briefing') return 'Member briefing'; if (canonical === 'task_add') return 'Add task'; - return toolName.trim() || 'Tool use'; + if (canonical === 'task_update') return 'Update task'; + if (canonical === 'task_delete') return 'Delete task'; + if (canonical === 'process_list') return 'List processes'; + return humanizeFallbackToolName(toolName); +} + +function formatGenericToolResultTitle( + toolContext: ToolUseContext | undefined, + isError: boolean +): string { + if (!toolContext) { + return isError ? 'Tool error' : 'Tool result'; + } + return `${formatToolTitle(toolContext.name)} ${isError ? 'error' : 'result'}`; +} + +function buildToolUseKey(input: { + provider: MemberLogStreamProvider; + sourceId: string; + toolUseId: string; +}): string { + return [input.provider, input.sourceId, input.toolUseId.trim()].join(':'); +} + +function isToolUseSupersededBySuccessResult(toolName: string): boolean { + const canonical = canonicalToolName(toolName); + return ( + canonical === 'sendmessage' || + canonical === 'message_send' || + canonical === 'cross_team_send' || + canonical === 'runtime_deliver_message' || + canonical === 'runtime_bootstrap_checkin' || + canonical === 'member_briefing' || + canonical.startsWith('task_') || + canonical.startsWith('review_') + ); } function stringField( @@ -326,7 +407,8 @@ function taskRefFromPayload( } function shortTaskSummary(task: Record | undefined): string | null { - const title = stringField(task, 'title') ?? stringField(task, 'name'); + const title = + stringField(task, 'title') ?? stringField(task, 'subject') ?? stringField(task, 'name'); const status = stringField(task, 'status'); const owner = stringField(task, 'owner'); const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter( @@ -373,6 +455,64 @@ function formatTaskCommentPayload( return `Comment: ${commentText}`; } +function countArrayField(payload: Record, keys: readonly string[]): number | null { + for (const key of keys) { + const value = payload[key]; + if (Array.isArray(value)) { + return value.length; + } + } + return null; +} + +function formatTaskCollectionPayload(payload: Record): KnownPayloadPreview | null { + const taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'message') ?? + stringField(payload, 'text'); + if (taskCount != null) { + return { + title: 'Task list', + text: summary ? `${taskCount} tasks - ${summary}` : `${taskCount} tasks`, + }; + } + return summary ? { title: 'Task list', text: summary } : null; +} + +function formatRelationshipPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const sourceRef = taskRefFromPayload(payload, fallbackInput); + const targetRef = formatTaskRef( + stringField(payload, 'targetId') ?? + stringField(payload, 'targetTaskId') ?? + stringField(fallbackInput ?? undefined, 'targetId') ?? + stringField(fallbackInput ?? undefined, 'targetTaskId') + ); + const relationship = + stringField(payload, 'relationship') ?? stringField(fallbackInput ?? undefined, 'relationship'); + if (sourceRef && targetRef && relationship) return `${sourceRef} ${relationship} ${targetRef}`; + if (sourceRef && targetRef) return `${sourceRef} -> ${targetRef}`; + if (sourceRef) return sourceRef; + return targetRef; +} + +function formatReviewChangesText( + payload: Record, + fallbackInput?: Record | null +): string | null { + return ( + stringField(payload, 'comment') ?? + stringField(payload, 'note') ?? + stringField(payload, 'message') ?? + stringField(fallbackInput ?? undefined, 'comment') ?? + stringField(fallbackInput ?? undefined, 'note') ?? + stringField(fallbackInput ?? undefined, 'message') + ); +} + function formatTaskToolPayload( payload: Record, canonicalToolNameValue: string | null, @@ -393,13 +533,42 @@ function formatTaskToolPayload( const filename = stringField(payload, 'filename') ?? stringField(payload, 'fileName') ?? + stringField(payload, 'path') ?? + stringField(payload, 'filePath') ?? stringField(fallbackInput ?? undefined, 'filename') ?? - stringField(fallbackInput ?? undefined, 'fileName'); + stringField(fallbackInput ?? undefined, 'fileName') ?? + stringField(fallbackInput ?? undefined, 'path') ?? + stringField(fallbackInput ?? undefined, 'filePath'); if (canonical === 'task_add_comment') { const text = formatTaskCommentPayload(payload, fallbackInput); return text ? { title: 'Comment added', text } : null; } + if (canonical === 'task_get_comment') { + const text = formatTaskCommentPayload(payload, fallbackInput); + if (text) return { title: 'Comment loaded', text }; + const commentId = + stringField(payload, 'commentId') ?? stringField(fallbackInput ?? undefined, 'commentId'); + if (taskRef && commentId) { + return { title: 'Comment loaded', text: `${commentId} on ${taskRef}` }; + } + return taskRef ? { title: 'Comment loaded', text: `Loaded comment on ${taskRef}` } : null; + } + if (canonical === 'task_create' || canonical === 'task_create_from_message') { + if (taskRef && taskSummary) { + return { title: 'Task created', text: `${taskRef}: ${taskSummary}` }; + } + if (taskRef) return { title: 'Task created', text: `Created ${taskRef}` }; + } + if (canonical === 'task_list' || canonical === 'task_briefing') { + const collectionText = formatTaskCollectionPayload(payload); + if (collectionText) { + return { + title: canonical === 'task_briefing' ? 'Task briefing' : collectionText.title, + text: collectionText.text, + }; + } + } if (canonical === 'task_start') { return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null; } @@ -428,6 +597,19 @@ function formatTaskToolPayload( if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` }; return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null; } + if (canonical === 'task_attach_file') { + if (taskRef && filename) return { title: 'Task file', text: `${filename} on ${taskRef}` }; + return taskRef ? { title: 'Task file', text: `Attached file to ${taskRef}` } : null; + } + if (canonical === 'task_link' || canonical === 'task_unlink') { + const relationshipText = formatRelationshipPayload(payload, fallbackInput); + if (relationshipText) { + return { + title: canonical === 'task_link' ? 'Tasks linked' : 'Tasks unlinked', + text: relationshipText, + }; + } + } if (canonical === 'review_request') { const reviewer = stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer'); @@ -438,6 +620,21 @@ function formatTaskToolPayload( if (canonical === 'review_start') { return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null; } + if (canonical === 'review_approve') { + const note = formatReviewChangesText(payload, fallbackInput); + if (taskRef && note) return { title: 'Review approved', text: `${taskRef}: ${note}` }; + return taskRef ? { title: 'Review approved', text: `Approved ${taskRef}` } : null; + } + if (canonical === 'review_request_changes') { + const comment = formatReviewChangesText(payload, fallbackInput); + if (taskRef && comment) return { title: 'Changes requested', text: `${taskRef}: ${comment}` }; + return taskRef + ? { title: 'Changes requested', text: `Requested changes for ${taskRef}` } + : null; + } + if (canonical === 'task_restore') { + return taskRef ? { title: 'Task restored', text: `Restored ${taskRef}` } : null; + } if (taskRef && status) { return { title: 'Task update', text: `Task ${taskRef} ${status}` }; } @@ -510,6 +707,22 @@ function formatMessageSendPayload(payload: Record): string | nu return null; } +function looksLikeMessageSendPayload(payload: Record): boolean { + const routing = asRecord(payload.routing); + const messageRecord = asRecord(payload.message); + if (payload.deliveredToInbox === true || routing) { + return true; + } + return Boolean( + messageRecord && + (stringField(messageRecord, 'to') || + stringField(messageRecord, 'from') || + stringField(messageRecord, 'summary') || + stringField(messageRecord, 'text') || + stringField(messageRecord, 'content')) + ); +} + function formatMessageSendResultFromInput(payload: Record): string | null { const target = stringField(payload, 'to') ?? stringField(payload, 'target'); const summary = @@ -536,6 +749,27 @@ function formatMessageSendInputPayload(payload: Record): string return null; } +function formatCrossTeamPayload(payload: Record): string | null { + const routing = asRecord(payload.routing) ?? undefined; + const target = + stringField(payload, 'toTeam') ?? + stringField(payload, 'targetTeam') ?? + stringField(routing, 'toTeam') ?? + stringField(routing, 'targetTeam') ?? + stringField(routing, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'message') ?? + stringField(payload, 'text') ?? + stringField(payload, 'content') ?? + stringField(routing, 'summary') ?? + stringField(routing, 'content'); + if (target && summary) return `to ${target}: ${summary}`; + if (target) return `to ${target}`; + if (summary) return summary; + return null; +} + function formatPlainToolResultStatus( value: string, toolContext: ToolUseContext | undefined @@ -552,6 +786,10 @@ function formatPlainToolResultStatus( const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null; return text ? { title: 'Message sent', text } : null; } + if (toolContext.canonicalName === 'cross_team_send') { + const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null; + return text ? { title: 'Cross-team message', text } : null; + } return ( formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) @@ -568,6 +806,13 @@ function formatTaskToolInputPayload( const owner = stringField(payload, 'owner'); const clarification = stringField(payload, 'clarification'); const reviewer = stringField(payload, 'reviewer'); + const commentId = stringField(payload, 'commentId'); + const filename = + stringField(payload, 'filename') ?? + stringField(payload, 'fileName') ?? + stringField(payload, 'filePath'); + const relationship = formatRelationshipPayload(payload, payload); + const reviewText = formatReviewChangesText(payload, payload); if (canonicalToolNameValue === 'task_add_comment') { if (taskRef && text) return `on ${taskRef}: ${text}`; @@ -575,6 +820,10 @@ function formatTaskToolInputPayload( if (text) return text; return null; } + if (canonicalToolNameValue === 'task_get_comment') { + if (taskRef && commentId) return `${commentId} on ${taskRef}`; + if (taskRef) return `comment on ${taskRef}`; + } if (canonicalToolNameValue === 'task_set_status') { if (taskRef && status) return `${taskRef} -> ${status}`; } @@ -587,6 +836,21 @@ function formatTaskToolInputPayload( if (canonicalToolNameValue === 'review_request') { if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`; } + if ( + canonicalToolNameValue === 'review_approve' || + canonicalToolNameValue === 'review_request_changes' + ) { + if (taskRef && reviewText) return `${taskRef}: ${reviewText}`; + } + if ( + canonicalToolNameValue === 'task_attach_file' || + canonicalToolNameValue === 'task_attach_comment_file' + ) { + if (taskRef && filename) return `${filename} on ${taskRef}`; + } + if (canonicalToolNameValue === 'task_link' || canonicalToolNameValue === 'task_unlink') { + if (relationship) return relationship; + } if (taskRef) return taskRef; return null; } @@ -616,13 +880,24 @@ function formatKnownPayloadPreview( if (runtimeText) { return runtimeText; } - const messageText = formatMessageSendPayload(payload); + if (canonical === 'cross_team_send') { + const crossTeamText = formatCrossTeamPayload(payload); + if (crossTeamText) { + return { title: 'Cross-team message', text: crossTeamText }; + } + } + const messageText = + canonical === 'sendmessage' || + canonical === 'message_send' || + looksLikeMessageSendPayload(payload) + ? formatMessageSendPayload(payload) + : null; if (messageText) { return { title: 'Message sent', text: messageText }; } const commentText = formatTaskCommentPayload(payload); if (commentText) { - return { title: 'Comment added', text: commentText }; + return { title: 'Comment', text: commentText }; } const taskText = formatTaskStatusPayload(payload, fallbackInput); if (taskText) { @@ -646,6 +921,10 @@ function previewUnknownValue( if (plainStatus) { return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; } + const parsed = parseJsonLikeString(value); + if (parsed != null) { + return previewUnknownValue(parsed, limit, priorityKeys, toolContext); + } return truncatePreview(value, limit); } if (typeof value === 'number' || typeof value === 'boolean') { @@ -694,6 +973,13 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): return truncatePreview(formatted, limit); } } + if (canonical === 'cross_team_send') { + const payload = recordFromUnknown(value); + const formatted = payload ? formatCrossTeamPayload(payload) : null; + if (formatted) { + return truncatePreview(formatted, limit); + } + } const payload = recordFromUnknown(value); if (payload) { const taskFormatted = formatTaskToolInputPayload(canonical, payload); @@ -722,6 +1008,118 @@ function extractTextPreview( return preview.preview.length > 0 ? preview : null; } +function firstQuotedLine(value: string): string | null { + const line = value + .split(/\r?\n/) + .map((item) => item.trim()) + .find((item) => item.startsWith('>')); + return line ? line.replace(/^>\s*/, '').trim() || null : null; +} + +function findLineByPrefix(value: string, prefix: string): string | null { + const normalizedPrefix = prefix.toLowerCase(); + for (const line of value.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.toLowerCase().startsWith(normalizedPrefix)) { + return trimmed; + } + } + return null; +} + +function parseTaskAssignmentLine(line: string): { taskRef: string; subject?: string } | null { + const prefix = 'New task assigned to you:'; + if (!line.toLowerCase().startsWith(prefix.toLowerCase())) { + return null; + } + const rest = line.slice(prefix.length).trim(); + const [taskRefCandidate = '', ...restParts] = rest.split(/\s+/); + if (!taskRefCandidate.startsWith('#')) { + return null; + } + const restText = restParts.join(' ').trim(); + const firstStar = restText.indexOf('*'); + const secondStar = firstStar >= 0 ? restText.indexOf('*', firstStar + 1) : -1; + const subject = + firstStar >= 0 && secondStar > firstStar + ? restText.slice(firstStar + 1, secondStar).trim() + : restText.replaceAll('*', '').trim(); + return { + taskRef: taskRefCandidate, + ...(subject ? { subject } : {}), + }; +} + +function parseCommentHeadingLine(line: string): { taskRef: string; subject?: string } | null { + const prefix = '**Comment on task '; + if (!line.toLowerCase().startsWith(prefix.toLowerCase())) { + return null; + } + const afterPrefix = line.slice(prefix.length); + const endRef = afterPrefix.indexOf('**'); + if (endRef <= 0) { + return null; + } + const taskRef = afterPrefix.slice(0, endRef).trim(); + if (!taskRef.startsWith('#')) { + return null; + } + const afterRef = afterPrefix.slice(endRef + 2).trim(); + const firstUnderscore = afterRef.indexOf('_'); + const secondUnderscore = firstUnderscore >= 0 ? afterRef.indexOf('_', firstUnderscore + 1) : -1; + const subject = + firstUnderscore >= 0 && secondUnderscore > firstUnderscore + ? afterRef.slice(firstUnderscore + 1, secondUnderscore).trim() + : undefined; + return { + taskRef, + ...(subject ? { subject } : {}), + }; +} + +function extractInboundTextPreview( + content: string | MemberLogPreviewContentBlock[], + textLimit: number +): { title: string; preview: string; truncated: boolean } | null { + const raw = + typeof content === 'string' + ? content + : content + .filter((block): block is Extract => { + return block.type === 'text' && typeof block.text === 'string'; + }) + .map((block) => block.text) + .join('\n'); + const visibleRaw = removeHiddenInstructionBlocks(raw); + const compact = compactWhitespace(visibleRaw); + if (!compact) { + return null; + } + + const assigned = parseTaskAssignmentLine( + findLineByPrefix(visibleRaw, 'New task assigned to you:') ?? '' + ); + if (assigned) { + const taskRef = assigned.taskRef; + const subject = assigned.subject; + const preview = truncatePreview(subject ? `${taskRef} ${subject}` : taskRef, textLimit); + return { title: 'Task assigned', ...preview }; + } + + const comment = parseCommentHeadingLine(findLineByPrefix(visibleRaw, '**Comment on task ') ?? ''); + if (comment) { + const taskRef = comment.taskRef; + const quoted = firstQuotedLine(visibleRaw); + const subject = comment.subject; + const text = quoted ?? subject ?? 'Comment received'; + const preview = truncatePreview(`${taskRef}: ${text}`, textLimit); + return { title: 'Comment received', ...preview }; + } + + const preview = truncatePreview(compact, textLimit); + return preview.preview ? { title: 'Message', ...preview } : null; +} + function isToolUseBlock( block: MemberLogPreviewContentBlock ): block is Extract { @@ -792,6 +1190,13 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string { return message.role ?? message.type ?? ''; } +function messageHasToolResult(message: MemberLogPreviewParsedMessage): boolean { + if ((message.toolResults?.length ?? 0) > 0) { + return true; + } + return Array.isArray(message.content) && message.content.some(isToolResultBlock); +} + function buildItemId(input: { provider: MemberLogStreamProvider; sourceId: string; @@ -824,6 +1229,8 @@ function buildCandidate(input: { laneId?: string; token: string; textTruncated: boolean; + toolUseKey?: string; + supersededByResult?: boolean; }): Candidate { const timestamp = timestampIso(input.message.timestamp); const messageId = input.message.uuid ?? `message-${input.messageIndex}`; @@ -850,6 +1257,8 @@ function buildCandidate(input: { timestampMs: timestampMs(input.message.timestamp), order: input.messageIndex * 1_000 + input.blockIndex, textTruncated: input.textTruncated, + ...(input.toolUseKey ? { toolUseKey: input.toolUseKey } : {}), + ...(input.supersededByResult ? { supersededByResult: true } : {}), }; } @@ -873,6 +1282,11 @@ function collectToolUseCandidates(input: { if (seen.has(id)) return; seen.add(id); const preview = previewToolInputValue(tool.name, tool.input, input.textLimit); + const toolUseKey = buildToolUseKey({ + provider: input.provider, + sourceId: input.sourceId, + toolUseId: id, + }); candidates.push( buildCandidate({ provider: input.provider, @@ -890,6 +1304,8 @@ function collectToolUseCandidates(input: { laneId: input.laneId, token: id, textTruncated: preview.truncated, + toolUseKey, + supersededByResult: isToolUseSupersededBySuccessResult(tool.name), }) ); }; @@ -933,6 +1349,11 @@ function collectToolResultCandidates(input: { if (seen.has(id)) return; seen.add(id); const toolContext = input.toolUseContexts.get(id); + const toolUseKey = buildToolUseKey({ + provider: input.provider, + sourceId: input.sourceId, + toolUseId: id, + }); const preview = previewUnknownValue( result.content, input.textLimit, @@ -940,6 +1361,10 @@ function collectToolResultCandidates(input: { toolContext ); const isError = result.isError === true || preview.title === 'Tool error'; + const title = + preview.title === 'Tool error' + ? formatGenericToolResultTitle(toolContext, true) + : (preview.title ?? formatGenericToolResultTitle(toolContext, isError)); candidates.push( buildCandidate({ provider: input.provider, @@ -948,7 +1373,7 @@ function collectToolResultCandidates(input: { messageIndex: input.messageIndex, blockIndex, kind: 'tool_result', - title: isError ? 'Tool error' : (preview.title ?? 'Tool result'), + title, preview: preview.preview, tone: isError ? 'error' : 'success', toolName: toolContext?.name, @@ -957,6 +1382,7 @@ function collectToolResultCandidates(input: { laneId: input.laneId, token: id, textTruncated: preview.truncated, + toolUseKey, }) ); }; @@ -1078,9 +1504,50 @@ export function extractMemberLogPreviewItems( ); } } + + if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) { + const inboundPreview = extractInboundTextPreview(message.content, textLimit); + if (inboundPreview) { + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId, + message, + messageIndex, + blockIndex: 8, + kind: 'text', + title: inboundPreview.title, + preview: inboundPreview.preview, + tone: 'neutral', + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? message.sessionId, + laneId: input.laneId, + token: 'inbound-text', + textTruncated: inboundPreview.truncated, + }) + ); + } + } }); - const sorted = [...candidates]; + const successfulResultToolKeys = new Set( + candidates + .filter( + (candidate) => + candidate.item.kind === 'tool_result' && + candidate.item.tone !== 'error' && + Boolean(candidate.item.preview?.trim()) + ) + .map((candidate) => candidate.toolUseKey) + .filter((toolUseKey): toolUseKey is string => Boolean(toolUseKey)) + ); + const compactCandidates = candidates.filter((candidate) => { + if (candidate.item.kind !== 'tool_use') return true; + if (!candidate.supersededByResult || !candidate.toolUseKey) return true; + return !successfulResultToolKeys.has(candidate.toolUseKey); + }); + + const sorted = [...compactCandidates]; sorted.sort((left, right) => { const byTime = right.timestampMs - left.timestampMs; if (byTime !== 0) return byTime; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 4e656dde..1a01a12d 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -29,6 +29,7 @@ import { isConnectionManagedRuntimeProvider, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; +import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; @@ -102,7 +103,7 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null { } if (codex.login.status === 'starting' || codex.login.status === 'pending') { - return null; + return codex.login.authUrl ? 'Finish ChatGPT login in the browser.' : null; } const usageHint = codex.localActiveChatgptAccountPresent @@ -731,6 +732,8 @@ const InstalledBanner = ({ provider.connection?.codex?.launchAllowed !== true && provider.connection?.codex?.login.status !== 'starting' && provider.connection?.codex?.login.status !== 'pending'; + const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null; + const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl); const disconnectAction = getProviderDisconnectAction(provider); const providerLoading = cliProviderStatusLoading[provider.providerId] === true; const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null; @@ -897,20 +900,33 @@ const InstalledBanner = ({ >
{codexDashboardHint} - {codexNeedsReconnect ? ( - + {showCodexLoginActions ? ( + <> + + + ) : null}
diff --git a/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx new file mode 100644 index 00000000..a37277e6 --- /dev/null +++ b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; + +interface CodexLoginLinkCopyButtonProps { + authUrl?: string | null; + disabled?: boolean; + size?: 'xs' | 'sm'; +} + +export function CodexLoginLinkCopyButton({ + authUrl, + disabled = false, + size = 'sm', +}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + + useEffect(() => { + setCopyState('idle'); + }, [authUrl]); + + if (!authUrl) { + return null; + } + + const handleCopyAuthUrl = (): void => { + if (!navigator.clipboard) { + setCopyState('failed'); + return; + } + + void navigator.clipboard.writeText(authUrl).then( + () => setCopyState('copied'), + () => setCopyState('failed') + ); + }; + + const sizeClassName = size === 'xs' ? 'px-2 py-1 text-[10px]' : 'px-2.5 py-1.5 text-xs'; + + return ( + + ); +} diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 108e2155..3c04faae 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -39,6 +39,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; +import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { useStore } from '@renderer/store'; import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react'; @@ -715,6 +716,7 @@ export const ProviderRuntimeSettingsDialog = ({ Boolean(codexConnection?.localActiveChatgptAccountPresent) && !codexHasActiveChatgptSession; const codexLoginPending = codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending'; + const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null; const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; const configuredAuthMode: CliProviderAuthMode | undefined = selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; @@ -1389,14 +1391,31 @@ export const ProviderRuntimeSettingsDialog = ({ Refresh {codexLoginPending ? ( - + <> + + {codexLoginAuthUrl ? ( + + ) : null} + + ) : codexHasActiveChatgptSession ? ( + <> + + + )} diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index f236f190..51abfb7f 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups'; import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState'; import { cn } from '@renderer/lib/utils'; +import { markTaskUnread } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { projectColor } from '@renderer/utils/projectColor'; @@ -283,6 +284,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({ setRenamingTaskKey(null); }, []); + const handleMarkTaskUnread = useCallback((teamName: string, taskId: string): void => { + markTaskUnread(teamName, taskId); + }, []); + const handleDeleteTask = useCallback( async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ @@ -548,6 +553,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ isArchived={false} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > @@ -641,6 +647,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ isArchived={taskLocalState.isArchived(task.teamName, task.id)} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > @@ -726,6 +733,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id) } + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > @@ -832,6 +840,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({ onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id) } + onMarkUnread={() => handleMarkTaskUnread(task.teamName, task.id)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onDelete={() => handleDeleteTask(task.teamName, task.id)} > diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 13557262..fe775386 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; +import { clearTaskManualUnread } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -157,6 +158,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({ style={{ borderColor: 'var(--color-border)' }} onClick={() => { if (!isRenaming) { + clearTaskManualUnread(task.teamName, task.id); openGlobalTaskDetail(task.teamName, task.id); } }} diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx index b5866643..98310b12 100644 --- a/src/renderer/components/sidebar/TaskContextMenu.tsx +++ b/src/renderer/components/sidebar/TaskContextMenu.tsx @@ -5,7 +5,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@renderer/components/ui/context-menu'; -import { Archive, ArchiveRestore, Pencil, Pin, PinOff, Trash2 } from 'lucide-react'; +import { Archive, ArchiveRestore, Mail, Pencil, Pin, PinOff, Trash2 } from 'lucide-react'; import type { GlobalTask } from '@shared/types'; @@ -15,6 +15,7 @@ export interface TaskContextMenuProps { isArchived: boolean; onTogglePin: () => void; onToggleArchive: () => void; + onMarkUnread: () => void; onRename: () => void; onDelete?: () => void; children: React.ReactNode; @@ -26,6 +27,7 @@ export const TaskContextMenu = ({ isArchived, onTogglePin, onToggleArchive, + onMarkUnread, onRename, onDelete, children, @@ -55,6 +57,11 @@ export const TaskContextMenu = ({ Rename + + + Mark as unread + + @@ -74,10 +81,7 @@ export const TaskContextMenu = ({ {onDelete && ( <> - + Delete task diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index bcf58b78..967080db 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ return ( provider.providerId === 'codex' + ); + const codexConnection = codexProvider?.connection?.codex; + const loginStatus = codexConnection?.login.status; + const loginPending = loginStatus === 'starting' || loginStatus === 'pending'; + if (loginPending && codexConnection?.login.authUrl) { + return true; + } + + const codexNeedsReconnect = + Boolean(codexConnection?.localActiveChatgptAccountPresent) && + codexConnection?.launchAllowed !== true && + !loginPending; + + if (!codexNeedsReconnect) { + return false; + } + + if (containsReconnectCue(prepareMessage)) { + return true; + } + + return prepareChecks.some( + (check) => + check.providerId === 'codex' && check.details.some((detail) => containsReconnectCue(detail)) + ); +} + +export function CodexReconnectPrompt({ + authUrl, + reconnectBusy, + onReconnect, +}: { + authUrl: string | null; + reconnectBusy: boolean; + onReconnect: () => void; +}): React.JSX.Element { + return ( +
+
+

+ Codex found the local ChatGPT account, but this session is stale. Reconnect ChatGPT, then + finish login in the browser and retry this dialog. +

+ + +
+
+ ); +} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 08c4e4e0..dd653a33 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -88,6 +88,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; +import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { CodexFastModeSelector } from './CodexFastModeSelector'; import { clearInheritedMemberModelsUnavailableForProvider, @@ -95,6 +96,7 @@ import { } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildReusableProviderPrepareModelResults, @@ -155,7 +157,6 @@ const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskU import type { EffortLevel, - Project, TeamCreateRequest, TeamFastMode, TeamProviderId, @@ -402,7 +403,7 @@ export const CreateTeamDialog = ({ const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips'); // ── Transient UI state (NOT persisted) ─────────────────────────────── - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); @@ -709,6 +710,19 @@ export const CreateTeamDialog = ({ }); }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]); + const handleCodexReconnect = useCallback(() => { + void (async () => { + const success = await codexAccount.startChatgptLogin(); + if (success) { + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + } + })(); + }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + useEffect(() => { if (!open || !canCreate || !launchTeam) { prepareRequestSeqRef.current += 1; @@ -948,36 +962,11 @@ export const CreateTeamDialog = ({ let cancelled = false; void (async () => { try { - const nextProjects = (await api.getProjects()).filter( - (project) => !isEphemeralProjectPath(project.path) - ); + const nextProjects = await loadProjectPathProjects({ defaultProjectPath }); if (cancelled) { return; } - // If defaultProjectPath is set but not in the fetched list (e.g. new project - // without Claude sessions), add it as a synthetic entry so the Combobox can - // display and select it. - const normalizedDefaultProjectPath = defaultProjectPath - ? normalizePath(defaultProjectPath) - : null; - if ( - defaultProjectPath && - normalizedDefaultProjectPath && - !isEphemeralProjectPath(defaultProjectPath) && - !nextProjects.some((p) => normalizePath(p.path) === normalizedDefaultProjectPath) - ) { - const folderName = - defaultProjectPath.split(/[/\\]/).filter(Boolean).pop() ?? defaultProjectPath; - nextProjects.unshift({ - id: defaultProjectPath.replace(/[/\\]/g, '-'), - path: defaultProjectPath, - name: folderName, - sessions: [], - createdAt: Date.now(), - }); - } - setProjects(nextProjects); } catch (error) { if (cancelled) { @@ -1552,6 +1541,12 @@ export const CreateTeamDialog = ({ }), [prepareChecks, prepareMessage, prepareState, prepareWarnings] ); + const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({ + effectiveCliStatus, + selectedProviderIds: selectedMemberProviders, + prepareMessage: effectivePrepare.message, + prepareChecks, + }); const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -2117,8 +2112,8 @@ export const CreateTeamDialog = ({ {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( -

+ {prepareWarnings.map((warning, index) => ( +

{warning}

))} @@ -2152,9 +2147,9 @@ export const CreateTeamDialog = ({ ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( + {prepareWarnings.map((warning, index) => (

@@ -2166,6 +2161,15 @@ export const CreateTeamDialog = ({

{getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}

+ {showCodexReconnectPrompt ? ( +
+ +
+ ) : null}
) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 115d6873..8fabcc54 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -91,6 +91,7 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; +import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { CodexFastModeSelector } from './CodexFastModeSelector'; import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; @@ -100,6 +101,7 @@ import { } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildReusableProviderPrepareModelResults, @@ -153,7 +155,6 @@ import type { MentionSuggestion } from '@renderer/types/mention'; import type { CreateScheduleInput, EffortLevel, - Project, ResolvedTeamMember, Schedule, ScheduleLaunchConfig, @@ -404,7 +405,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const chipDraft = useChipDraftPersistence( `launchTeam:${effectiveTeamName || 'standalone'}:${props.mode}:chips` ); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); const [localError, setLocalError] = useState(null); @@ -586,6 +587,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }); }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled, open]); + const handleCodexReconnect = React.useCallback(() => { + void (async () => { + const success = await codexAccount.startChatgptLogin(); + if (success) { + await refreshCliStatusForCurrentMode({ + multimodelEnabled, + bootstrapCliStatus, + fetchCliStatus, + }); + } + })(); + }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); const updateSchedule = useStore((s) => s.updateSchedule); @@ -1579,6 +1593,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen // --------------------------------------------------------------------------- const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups)); + const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; useEffect(() => { if (!open) return; @@ -1589,30 +1604,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen let cancelled = false; void (async () => { try { - const apiProjects = (await api.getProjects()).filter( - (project) => !isEphemeralProjectPath(project.path) - ); + const nextProjects = await loadProjectPathProjects({ + defaultProjectPath, + repositoryGroups, + }); if (cancelled) return; - const pathSet = new Set(apiProjects.map((p) => p.path)); - const extras: Project[] = []; - for (const repo of repositoryGroups) { - for (const wt of repo.worktrees) { - if (!isEphemeralProjectPath(wt.path) && !pathSet.has(wt.path)) { - pathSet.add(wt.path); - extras.push({ - id: wt.id, - path: wt.path, - name: wt.name, - sessions: [], - totalSessions: 0, - createdAt: wt.createdAt ?? Date.now(), - }); - } - } - } - - setProjects([...apiProjects, ...extras]); + setProjects(nextProjects); } catch (error) { if (cancelled) return; setProjectsError(error instanceof Error ? error.message : 'Failed to load projects'); @@ -1625,10 +1623,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, repositoryGroups]); + }, [open, repositoryGroups, defaultProjectPath]); // Pre-select defaultProjectPath (launch mode) or first project - const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; useEffect(() => { if (!open || cwdMode !== 'project' || selectedProjectPath) return; @@ -1920,6 +1917,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }), [prepareChecks, prepareMessage, prepareState, prepareWarnings] ); + const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({ + effectiveCliStatus, + selectedProviderIds: selectedMemberProviders, + prepareMessage: effectivePrepare.message, + prepareChecks, + }); const launchInFlight = useStore((s) => isLaunchMode && effectiveTeamName ? isTeamProvisioningActive(s, effectiveTeamName) : false ); @@ -2819,8 +2822,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( -

+ {prepareWarnings.map((warning, index) => ( +

{warning}

))} @@ -2858,9 +2861,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null} {prepareWarnings.length > 0 && prepareChecks.length === 0 ? (
- {prepareWarnings.map((warning) => ( + {prepareWarnings.map((warning, index) => (

@@ -2889,6 +2892,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) : null}

+ {showCodexReconnectPrompt ? ( +
+ +
+ ) : null}
) : null} diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 071b5fbc..da65e517 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { api } from '@renderer/api'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Button } from '@renderer/components/ui/button'; import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; @@ -8,9 +9,14 @@ import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; import { Check, FolderOpen } from 'lucide-react'; -import { buildProjectPathOptions } from './projectPathOptions'; +import { + buildProjectPathOptions, + type ProjectPathOptionMeta, + type ProjectPathProject, +} from './projectPathOptions'; -import type { Project } from '@shared/types'; +import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts'; +import type { ComboboxOption } from '@renderer/components/ui/combobox'; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -45,6 +51,49 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element { ); } +function getOptionSource(option: ComboboxOption): DashboardRecentProjectSource | undefined { + return (option.meta as ProjectPathOptionMeta | undefined)?.discoverySource; +} + +function getSourceLabel(source: DashboardRecentProjectSource): string { + switch (source) { + case 'claude': + return 'Found by Claude'; + case 'codex': + return 'Found by Codex'; + case 'mixed': + return 'Found by Claude and Codex'; + } +} + +function ProjectSourceBadge({ + source, +}: { + source?: DashboardRecentProjectSource; +}): React.JSX.Element | null { + if (!source) { + return null; + } + + const logos = + source === 'mixed' + ? (['anthropic', 'codex'] as const) + : source === 'codex' + ? (['codex'] as const) + : (['anthropic'] as const); + + return ( + + {logos.map((providerId) => ( + + ))} + + ); +} + export type CwdMode = 'project' | 'custom'; interface ProjectPathSelectorProps { @@ -54,7 +103,7 @@ interface ProjectPathSelectorProps { onSelectedProjectPathChange: (path: string) => void; customCwd: string; onCustomCwdChange: (cwd: string) => void; - projects: Project[]; + projects: ProjectPathProject[]; projectsLoading: boolean; projectsError: string | null; fieldError?: string | null; @@ -123,6 +172,12 @@ export const ProjectPathSelector = ({ searchPlaceholder="Search project by name or path" emptyMessage="Nothing found" disabled={projectsLoading || projectOptions.length === 0} + renderTriggerLabel={(option) => ( + + + {option.label} + + )} renderOption={(option, isSelected, query) => ( <> +

{renderHighlightedText(option.label, query)} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index a092ec38..dd4ff008 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -619,9 +619,9 @@ export const ProvisioningProviderStatusList = ({

{visibleDetails.length > 0 ? (
- {visibleDetails.map((detail) => ( + {visibleDetails.map((detail, index) => (

{detail} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index b112b872..50c5007d 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -563,7 +563,11 @@ export const TeamModelSelector: React.FC = ({ }} > - {opt.label} + + {opt.label} + {sourceBadgeLabel ? ( , + order: string[], + project: ProjectPathProject +): void { + if (isEphemeralProjectPath(project.path)) { + return; + } + + const normalizedPath = normalizePath(project.path); + const existing = byNormalizedPath.get(normalizedPath); + if (!existing) { + byNormalizedPath.set(normalizedPath, project); + order.push(normalizedPath); + return; + } + + existing.discoverySource = mergeDiscoverySource( + existing.discoverySource, + project.discoverySource + ); + if (!existing.mostRecentSession && project.mostRecentSession) { + existing.mostRecentSession = project.mostRecentSession; + } +} + +function recentProjectToProject(project: { + id: string; + name: string; + primaryPath: string; + mostRecentActivity: number; + source: DashboardRecentProjectSource; +}): ProjectPathProject { + return { + id: `recent:${project.id}`, + path: project.primaryPath, + name: project.name, + sessions: [], + totalSessions: 0, + createdAt: project.mostRecentActivity, + mostRecentSession: project.mostRecentActivity, + discoverySource: project.source, + }; +} + +function repositoryWorktreeToProject(worktree: RepositoryGroup['worktrees'][number]): Project { + return { + id: worktree.id, + path: worktree.path, + name: worktree.name, + sessions: [], + totalSessions: 0, + createdAt: worktree.createdAt ?? Date.now(), + }; +} + +function syntheticProjectFromPath(projectPath: string): Project { + return { + id: projectPath.replace(/[/\\]/g, '-'), + path: projectPath, + name: getPathName(projectPath), + sessions: [], + totalSessions: 0, + createdAt: Date.now(), + }; +} + +export async function loadProjectPathProjects({ + defaultProjectPath, + repositoryGroups = [], +}: LoadProjectPathProjectsOptions = {}): Promise { + const [projectsResult, recentProjectsResult] = await Promise.allSettled([ + api.getProjects(), + api.getDashboardRecentProjects(), + ]); + + if (projectsResult.status === 'rejected' && recentProjectsResult.status === 'rejected') { + throw projectsResult.reason; + } + + const byNormalizedPath = new Map(); + const order: string[] = []; + const apiProjects = projectsResult.status === 'fulfilled' ? projectsResult.value : []; + const recentProjects = + recentProjectsResult.status === 'fulfilled' ? recentProjectsResult.value.projects : []; + + for (const project of apiProjects) { + upsertProject(byNormalizedPath, order, { + ...project, + discoverySource: 'claude', + }); + } + + for (const project of recentProjects) { + upsertProject(byNormalizedPath, order, recentProjectToProject(project)); + } + + for (const repo of repositoryGroups) { + for (const worktree of repo.worktrees) { + upsertProject(byNormalizedPath, order, repositoryWorktreeToProject(worktree)); + } + } + + if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) { + upsertProject(byNormalizedPath, order, syntheticProjectFromPath(defaultProjectPath)); + } + + return order.flatMap((path) => { + const project = byNormalizedPath.get(path); + return project ? [project] : []; + }); +} diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index 594eabaf..220ce60c 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -1,8 +1,14 @@ -import { memo } from 'react'; +import { memo, useEffect, useState } from 'react'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; +import { + formatMemberActivityElapsed, + readMemberActivityTimerElapsed, + syncMemberActivityTimer, +} from '@renderer/utils/memberActivityTimer'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { TeamTaskWithKanban } from '@shared/types'; interface CurrentTaskIndicatorProps { @@ -10,9 +16,71 @@ interface CurrentTaskIndicatorProps { borderColor: string; maxSubjectLength?: number; activityLabel?: string; + activityTimer?: MemberActivityTimerAnchor | null; + isTimerRunning?: boolean; onOpenTask?: () => void; } +function useActivityTimerLabel( + activityTimer: MemberActivityTimerAnchor | null | undefined, + isTimerRunning: boolean +): string | null { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (!activityTimer) return; + const now = Date.now(); + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: isTimerRunning, + runId: activityTimer.runId, + nowMs: now, + }); + + return () => { + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: isTimerRunning, + runId: activityTimer.runId, + nowMs: Date.now(), + }); + }; + }, [activityTimer, isTimerRunning]); + + useEffect(() => { + if (!activityTimer || !isTimerRunning) return; + const handle = window.setInterval(() => { + const now = Date.now(); + syncMemberActivityTimer({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: true, + runId: activityTimer.runId, + nowMs: now, + }); + setNowMs(now); + }, 1000); + return () => window.clearInterval(handle); + }, [activityTimer, isTimerRunning]); + + if (!activityTimer) return null; + return formatMemberActivityElapsed( + readMemberActivityTimerElapsed({ + timerId: activityTimer.timerId, + startedAtMs: activityTimer.startedAtMs, + baseElapsedMs: activityTimer.baseElapsedMs, + running: isTimerRunning, + runId: activityTimer.runId, + nowMs, + }) + ); +} + /** * Inline indicator showing a spinning loader + "working on" + task label button. * Shared between MemberCard and MemberHoverCard. @@ -23,8 +91,11 @@ export const CurrentTaskIndicator = memo( borderColor, maxSubjectLength, activityLabel = 'working on', + activityTimer, + isTimerRunning = true, onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { + const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning); const subjectText = typeof maxSubjectLength === 'number' && maxSubjectLength > 0 && @@ -54,6 +125,14 @@ export const CurrentTaskIndicator = memo( > {formatTaskDisplayLabel(task)} {subjectText} + {timerLabel ? ( + + {timerLabel} + + ) : null}

); } diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 7008575b..8b7e02a1 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -30,6 +30,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberPresenceDot } from './MemberPresenceDot'; +import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, @@ -54,6 +55,10 @@ interface MemberCardProps { leadActivity?: LeadActivityState; currentTask?: TeamTaskWithKanban | null; reviewTask?: TeamTaskWithKanban | null; + currentTaskTimer?: MemberActivityTimerAnchor | null; + reviewTaskTimer?: MemberActivityTimerAnchor | null; + currentTaskTimerRunning?: boolean; + reviewTaskTimerRunning?: boolean; isAwaitingReply?: boolean; isRemoved?: boolean; spawnStatus?: MemberSpawnStatus; @@ -132,6 +137,10 @@ export const MemberCard = memo(function MemberCard({ leadActivity, currentTask, reviewTask, + currentTaskTimer, + reviewTaskTimer, + currentTaskTimerRunning = isTeamAlive !== false, + reviewTaskTimerRunning = isTeamAlive !== false, isAwaitingReply, isRemoved, spawnStatus, @@ -433,6 +442,8 @@ export const MemberCard = memo(function MemberCard({ task={currentTask} borderColor={colors.border} activityLabel="working on" + activityTimer={currentTaskTimer} + isTimerRunning={currentTaskTimerRunning} onOpenTask={onOpenTask} /> ) : null} @@ -441,6 +452,8 @@ export const MemberCard = memo(function MemberCard({ task={reviewTask} borderColor={colors.border} activityLabel="reviewing" + activityTimer={reviewTaskTimer} + isTimerRunning={reviewTaskTimerRunning} onOpenTask={onOpenReviewTask} /> ) : null} diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index dd1ce9e2..81402d29 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -23,14 +23,15 @@ import { buildMemberAvatarMap, buildMemberLaunchPresentation, displayMemberName, + shouldDisplayMemberCurrentTask, } from '@renderer/utils/memberHelpers'; -import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { buildMemberLaunchDiagnosticsPayload, getMemberLaunchDiagnosticsErrorMessage, hasMemberLaunchDiagnosticsDetails, hasMemberLaunchDiagnosticsError, } from '@renderer/utils/memberLaunchDiagnostics'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { ExternalLink } from 'lucide-react'; @@ -42,7 +43,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberPresenceDot } from './MemberPresenceDot'; -import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types'; +import type { TeamTaskWithKanban } from '@shared/types'; interface MemberHoverCardProps { /** The member name to look up */ @@ -131,7 +132,18 @@ export const MemberHoverCard = memo(function MemberHoverCard({ const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) : null; - const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null; + const currentTask = + isDisplayableCurrentTask(currentTaskCandidate) && + shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, + }) + ? currentTaskCandidate + : null; const presentationMember = member.currentTaskId && !currentTask ? { diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 334d8c2c..0368b32e 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,6 +1,11 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { + deriveReviewActivityTimerAnchor, + deriveWorkActivityTimerAnchor, + syncMemberActivityTimer, +} from '@renderer/utils/memberActivityTimer'; +import { buildMemberColorMap, shouldDisplayMemberCurrentTask } from '@renderer/utils/memberHelpers'; import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; @@ -9,6 +14,7 @@ import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { MemberCard } from './MemberCard'; import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; +import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, @@ -22,6 +28,7 @@ import type { } from '@shared/types'; interface MemberListProps { + teamName?: string; members: ResolvedTeamMember[]; memberTaskCounts?: Map; taskMap?: Map; @@ -101,6 +108,45 @@ function areTaskStatusCountsMapsEquivalent( return true; } +function areTaskWorkIntervalsEquivalent( + left: TeamTaskWithKanban['workIntervals'], + right: TeamTaskWithKanban['workIntervals'] +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + return left.every((interval, index) => { + const other = right[index]; + if (!other) return false; + return interval.startedAt === other.startedAt && interval.completedAt === other.completedAt; + }); +} + +function areTaskHistoryEventsEquivalent( + left: TeamTaskWithKanban['historyEvents'], + right: TeamTaskWithKanban['historyEvents'] +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.length !== right.length) return false; + return left.every((event, index) => { + const other = right[index]; + if (!other) return false; + const leftRow = event as unknown as Record; + const rightRow = other as unknown as Record; + return ( + event.id === other.id && + event.type === other.type && + event.timestamp === other.timestamp && + leftRow.actor === rightRow.actor && + leftRow.reviewer === rightRow.reviewer && + leftRow.from === rightRow.from && + leftRow.to === rightRow.to && + leftRow.status === rightRow.status + ); + }); +} + function areMemberTaskMapsEquivalent( left: Map | undefined, right: Map | undefined @@ -118,7 +164,9 @@ function areMemberTaskMapsEquivalent( leftTask.status !== rightTask.status || leftTask.reviewer !== rightTask.reviewer || leftTask.reviewState !== rightTask.reviewState || - leftTask.kanbanColumn !== rightTask.kanbanColumn + leftTask.kanbanColumn !== rightTask.kanbanColumn || + !areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) || + !areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents) ) { return false; } @@ -243,6 +291,7 @@ function areMemberListPropsEqual( next: Readonly ): boolean { return ( + prev.teamName === next.teamName && areResolvedMembersEquivalent(prev.members, next.members) && areTaskStatusCountsMapsEquivalent(prev.memberTaskCounts, next.memberTaskCounts) && areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && @@ -270,6 +319,10 @@ interface MemberCardRowProps { memberColor: string; currentTask: TeamTaskWithKanban | null; reviewTask: TeamTaskWithKanban | null; + currentTaskTimer: MemberActivityTimerAnchor | null; + reviewTaskTimer: MemberActivityTimerAnchor | null; + currentTaskTimerRunning: boolean; + reviewTaskTimerRunning: boolean; awaitingReply: boolean; taskCounts?: TaskStatusCounts | null; runtimeSummary?: string; @@ -299,6 +352,10 @@ const MemberCardRow = memo(function MemberCardRow({ memberColor, currentTask, reviewTask, + currentTaskTimer, + reviewTaskTimer, + currentTaskTimerRunning, + reviewTaskTimerRunning, awaitingReply, taskCounts, runtimeSummary, @@ -346,6 +403,10 @@ const MemberCardRow = memo(function MemberCardRow({ leadActivity={isLeadMember(member) ? leadActivity : undefined} currentTask={currentTask} reviewTask={reviewTask} + currentTaskTimer={currentTaskTimer} + reviewTaskTimer={reviewTaskTimer} + currentTaskTimerRunning={currentTaskTimerRunning} + reviewTaskTimerRunning={reviewTaskTimerRunning} isAwaitingReply={awaitingReply} isRemoved={isRemoved} runtimeSummary={runtimeSummary} @@ -370,6 +431,7 @@ const MemberCardRow = memo(function MemberCardRow({ }); export const MemberList = memo(function MemberList({ + teamName = '__unknown_team__', members, memberTaskCounts, taskMap, @@ -434,6 +496,124 @@ export const MemberList = memo(function MemberList({ return result; }, [taskMap]); + const isMemberActivityTimerRunning = useCallback( + ( + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined + ): boolean => { + if (isTeamAlive === false) return false; + if ( + spawnEntry?.status === 'offline' || + spawnEntry?.status === 'error' || + spawnEntry?.status === 'skipped' + ) { + return false; + } + if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') { + return false; + } + if ( + runtimeEntry?.livenessKind === 'shell_only' || + runtimeEntry?.livenessKind === 'registered_only' || + runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found' + ) { + return false; + } + return true; + }, + [isTeamAlive] + ); + + const getActivityTimerRunId = useCallback( + (running: boolean): string | null => { + if (!running) return null; + return runtimeRunId ?? 'runtime:unknown'; + }, + [runtimeRunId] + ); + + const withActivityTimerRunId = useCallback( + ( + anchor: MemberActivityTimerAnchor | null, + running: boolean + ): MemberActivityTimerAnchor | null => { + if (!anchor) return null; + return { + ...anchor, + runId: getActivityTimerRunId(running), + }; + }, + [getActivityTimerRunId] + ); + + useEffect(() => { + if (!taskMap) return; + const nowMs = Date.now(); + for (const member of activeMembers) { + const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); + const running = isMemberActivityTimerRunning(spawnEntry, runtimeEntry); + const currentTaskCandidate = member.currentTaskId + ? (taskMap.get(member.currentTaskId) ?? null) + : null; + if (isDisplayableCurrentTask(currentTaskCandidate)) { + const anchor = deriveWorkActivityTimerAnchor(currentTaskCandidate, { + teamName, + memberName: member.name, + }); + if (anchor) { + const visible = + running && + shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, + }); + syncMemberActivityTimer({ + timerId: anchor.timerId, + startedAtMs: anchor.startedAtMs, + baseElapsedMs: anchor.baseElapsedMs, + running: visible, + runId: getActivityTimerRunId(visible), + nowMs, + }); + } + } + + const reviewTask = reviewTaskByMember.get(member.name) ?? null; + if (reviewTask) { + const anchor = deriveReviewActivityTimerAnchor(reviewTask, { + teamName, + memberName: member.name, + }); + if (anchor) { + syncMemberActivityTimer({ + timerId: anchor.timerId, + startedAtMs: anchor.startedAtMs, + baseElapsedMs: anchor.baseElapsedMs, + running, + runId: getActivityTimerRunId(running), + nowMs, + }); + } + } + } + }, [ + activeMembers, + getActivityTimerRunId, + isMemberActivityTimerRunning, + isTeamAlive, + memberRuntimeEntries, + memberSpawnStatuses, + reviewTaskByMember, + taskMap, + teamName, + ]); + const buildRuntimeSummary = useCallback( ( member: ResolvedTeamMember, @@ -457,16 +637,44 @@ export const MemberList = memo(function MemberList({
{activeMembers.map((member) => { + const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); const currentTaskCandidate = member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; - const currentTask = isDisplayableCurrentTask(currentTaskCandidate) - ? currentTaskCandidate - : null; + const currentTask = + isDisplayableCurrentTask(currentTaskCandidate) && + shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus: spawnEntry?.status, + spawnLaunchState: spawnEntry?.launchState, + spawnRuntimeAlive: spawnEntry?.runtimeAlive, + runtimeEntry, + }) + ? currentTaskCandidate + : null; const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; const reviewTask = reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null; - const spawnEntry = memberSpawnStatuses?.get(member.name); - const runtimeEntry = memberRuntimeEntries?.get(member.name); + const activityTimerRunning = isMemberActivityTimerRunning(spawnEntry, runtimeEntry); + const currentTaskTimer = withActivityTimerRunId( + currentTask + ? deriveWorkActivityTimerAnchor(currentTask, { + teamName, + memberName: member.name, + }) + : null, + activityTimerRunning + ); + const reviewTaskTimer = withActivityTimerRunId( + reviewTask + ? deriveReviewActivityTimerAnchor(reviewTask, { + teamName, + memberName: member.name, + }) + : null, + activityTimerRunning + ); return ( ; // key = "teamName/taskId" @@ -116,9 +117,12 @@ export function getSnapshot(): ReadState { * Mark specific comment IDs as read for a given team/task. */ export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void { - if (commentIds.length === 0) return; const key = `${teamName}/${taskId}`; const prev = cache[key]; + if (commentIds.length === 0) { + if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId); + return; + } const prevSet = new Set(prev?.readIds ?? []); let changed = false; for (const id of commentIds) { @@ -127,7 +131,7 @@ export function markCommentsRead(teamName: string, taskId: string, commentIds: s changed = true; } } - if (!changed) return; + if (!changed && !prev?.manualUnread) return; cache = { ...cache, [key]: { @@ -148,7 +152,7 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu const prev = cache[key]; // Update lastUpdated to at least this timestamp (for legacy migration support) const prevLastUpdated = prev?.lastUpdated ?? 0; - if (latestTimestamp <= prevLastUpdated && prev) return; + if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return; cache = { ...cache, [key]: { @@ -160,6 +164,43 @@ export function markAsRead(teamName: string, taskId: string, latestTimestamp: nu scheduleSave(); } +/** + * Manually mark a task as unread even when it has no unread comments. + */ +export function markTaskUnread(teamName: string, taskId: string): void { + const key = `${teamName}/${taskId}`; + const prev = cache[key]; + if (prev?.manualUnread) return; + cache = { + ...cache, + [key]: { + readIds: prev?.readIds ?? [], + lastUpdated: Date.now(), + manualUnread: true, + }, + }; + notify(); + scheduleSave(); +} + +/** + * Clear only the manual unread marker. Comment read state is preserved. + */ +export function clearTaskManualUnread(teamName: string, taskId: string): void { + const key = `${teamName}/${taskId}`; + const prev = cache[key]; + if (!prev?.manualUnread) return; + cache = { + ...cache, + [key]: { + readIds: prev.readIds, + lastUpdated: Date.now(), + }, + }; + notify(); + scheduleSave(); +} + /** * Count unread comments for a task. * A comment is unread if its ID is NOT in the readIds set. @@ -177,9 +218,9 @@ export function getUnreadCount( taskId: string, comments: { id?: string; createdAt: string }[] ): number { - if (!comments || comments.length === 0) return 0; const key = `${teamName}/${taskId}`; const entry = readState[key]; + if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0; if (!entry) return comments.length; const readSet = new Set(entry.readIds); @@ -200,7 +241,7 @@ export function getUnreadCount( // Otherwise → unread count++; } - return count; + return entry.manualUnread && count === 0 ? 1 : count; } /** @@ -272,6 +313,7 @@ async function load(): Promise { merged[k] = { readIds: Array.from(mergedIds), lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated), + ...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}), }; } } @@ -290,6 +332,7 @@ async function load(): Promise { merged[k] = { readIds: [...new Set([...merged[k].readIds, ...v.readIds])], lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated), + ...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}), }; } } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 8d7fa9d9..e57fea10 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -80,6 +80,7 @@ import type { } from '@shared/types'; const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; +const ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL = false; const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000; const FINISHED_TOOL_DISPLAY_MS = 1_500; const MAX_TOOL_HISTORY_PER_MEMBER = 6; @@ -257,14 +258,20 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); }); - // This lightweight renderer-side poll keeps visible in-progress task badges fresh. - // It is intentionally independent from the backend log-source tracking feature flag below. - const inProgressChangePresencePollTimer = setInterval(() => { - void pollVisibleTeamInProgressChangePresence(); - }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); - cleanupFns.push(() => { - clearInterval(inProgressChangePresencePollTimer); - }); + // TODO(task-change-presence): re-enable this only after the board uses a bounded + // batch/priority presence pipeline. The old one-task-per-tick poll was accurate + // only after enough time or after opening a task popup, while still doing periodic + // summary extraction work in the background. The replacement should check visible + // tasks first, dedupe in-flight requests, keep popup/full diff requests higher + // priority, and never render "unknown" as "no_changes". + if (ENABLE_IN_PROGRESS_CHANGE_PRESENCE_BACKGROUND_POLL) { + const inProgressChangePresencePollTimer = setInterval(() => { + void pollVisibleTeamInProgressChangePresence(); + }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); + cleanupFns.push(() => { + clearInterval(inProgressChangePresencePollTimer); + }); + } const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); const teamLastRelevantActivityAt = new Map(); diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index 555233f9..a579f5cc 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -116,7 +116,7 @@ function createAnthropicProviderStatus( } describe('team model availability Codex catalog integration', () => { - it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => { + it('uses app-server catalog models with runtime-backed labels', () => { const providerStatus = createCodexProviderStatus( [ { @@ -171,12 +171,62 @@ describe('team model availability Codex catalog integration', () => { expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({ value: 'gpt-5.5', label: '5.5', - badgeLabel: 'New', availabilityStatus: 'available', }); expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull(); }); + it('orders GPT-5.5 first after the virtual default option', () => { + const providerStatus = createCodexProviderStatus([ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: '5.4', + }, + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: '5.5', + }, + { + id: 'gpt-5.2', + launchModel: 'gpt-5.2', + displayName: 'GPT-5.2', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: '5.2', + }, + ]); + + expect( + getAvailableTeamProviderModelOptions('codex', providerStatus).map((model) => model.value) + ).toEqual(['', 'gpt-5.5', 'gpt-5.4', 'gpt-5.2']); + }); + it('keeps existing disabled model policy on top of the dynamic catalog', () => { const providerStatus = createCodexProviderStatus([ { diff --git a/src/renderer/utils/memberActivityTimer.ts b/src/renderer/utils/memberActivityTimer.ts new file mode 100644 index 00000000..d52f5c43 --- /dev/null +++ b/src/renderer/utils/memberActivityTimer.ts @@ -0,0 +1,374 @@ +import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +export type MemberActivityPhase = 'work' | 'review'; + +export interface MemberActivityTimerAnchor { + timerId: string; + startedAt: string; + startedAtMs: number; + baseElapsedMs: number; + runId?: string | null; +} + +interface StoredActivityTimer { + version: 1; + startedAtMs: number; + baseElapsedMs: number; + elapsedMs: number; + updatedAtMs: number; + running: boolean; + runId?: string | null; +} + +const STORAGE_PREFIX = 'member-activity-timer:'; +const MAX_UNOBSERVED_RUN_TRANSITION_MS = 5_000; +const timers = new Map(); + +function parseIsoMs(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeMemberName(value: string | null | undefined): string { + return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; +} + +function safeStorageGet(key: string): string | null { + try { + return globalThis.localStorage?.getItem(key) ?? null; + } catch { + return null; + } +} + +function safeStorageSet(key: string, value: string): void { + try { + globalThis.localStorage?.setItem(key, value); + } catch { + // localStorage can be unavailable in tests or restricted browser contexts. + } +} + +function storageKey(timerId: string): string { + return `${STORAGE_PREFIX}${timerId}`; +} + +function isStoredTimer(value: unknown): value is StoredActivityTimer { + if (!value || typeof value !== 'object') return false; + const row = value as Partial; + return ( + row.version === 1 && + typeof row.startedAtMs === 'number' && + Number.isFinite(row.startedAtMs) && + (row.baseElapsedMs === undefined || + (typeof row.baseElapsedMs === 'number' && Number.isFinite(row.baseElapsedMs))) && + typeof row.elapsedMs === 'number' && + Number.isFinite(row.elapsedMs) && + typeof row.updatedAtMs === 'number' && + Number.isFinite(row.updatedAtMs) && + typeof row.running === 'boolean' && + (row.runId === undefined || row.runId === null || typeof row.runId === 'string') + ); +} + +function readStoredTimer( + timerId: string, + startedAtMs: number, + baseElapsedMs: number +): StoredActivityTimer | null { + const cached = timers.get(timerId); + if (cached?.startedAtMs === startedAtMs) { + return cached.baseElapsedMs === baseElapsedMs + ? cached + : { ...cached, baseElapsedMs, elapsedMs: Math.max(baseElapsedMs, cached.elapsedMs) }; + } + + const raw = safeStorageGet(storageKey(timerId)); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isStoredTimer(parsed) || parsed.startedAtMs !== startedAtMs) return null; + const sanitized: StoredActivityTimer = { + version: 1, + startedAtMs: parsed.startedAtMs, + baseElapsedMs, + elapsedMs: Math.max(baseElapsedMs, parsed.elapsedMs), + updatedAtMs: Math.max(parsed.startedAtMs, parsed.updatedAtMs), + running: parsed.running, + runId: parsed.runId ?? null, + }; + timers.set(timerId, sanitized); + return sanitized; + } catch { + return null; + } +} + +function writeStoredTimer(timerId: string, timer: StoredActivityTimer): void { + timers.set(timerId, timer); + safeStorageSet(storageKey(timerId), JSON.stringify(timer)); +} + +function createInitialTimer( + startedAtMs: number, + baseElapsedMs: number, + running: boolean, + nowMs: number, + runId: string | null | undefined +): StoredActivityTimer { + if (running) { + return { + version: 1, + startedAtMs, + baseElapsedMs, + elapsedMs: baseElapsedMs, + updatedAtMs: startedAtMs, + running: true, + runId, + }; + } + + return { + version: 1, + startedAtMs, + baseElapsedMs, + elapsedMs: baseElapsedMs, + updatedAtMs: nowMs, + running: false, + runId, + }; +} + +function materializeElapsed( + timer: StoredActivityTimer, + nowMs: number, + runId: string | null | undefined +): number { + const baseElapsedMs = Math.max(0, timer.baseElapsedMs); + if (!timer.running) return Math.max(baseElapsedMs, timer.elapsedMs); + + const rawGapMs = Math.max(0, nowMs - timer.updatedAtMs); + const sameRun = (timer.runId ?? null) === (runId ?? null); + const gapMs = sameRun ? rawGapMs : Math.min(rawGapMs, MAX_UNOBSERVED_RUN_TRANSITION_MS); + return Math.max(baseElapsedMs, timer.elapsedMs + gapMs); +} + +export function createMemberActivityTimerId({ + teamName, + memberName, + phase, + taskId, + startedAt, +}: { + teamName: string; + memberName: string; + phase: MemberActivityPhase; + taskId: string; + startedAt: string; +}): string { + return [teamName, normalizeMemberName(memberName), phase, taskId, startedAt].join('\u0000'); +} + +export function syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs = 0, + running, + runId, + nowMs = Date.now(), +}: { + timerId: string; + startedAtMs: number; + baseElapsedMs?: number; + running: boolean; + runId?: string | null; + nowMs?: number; +}): number { + const existing = + readStoredTimer(timerId, startedAtMs, baseElapsedMs) ?? + createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId); + const elapsedMs = materializeElapsed(existing, nowMs, runId); + const next: StoredActivityTimer = { + version: 1, + startedAtMs, + baseElapsedMs, + elapsedMs, + updatedAtMs: nowMs, + running, + runId, + }; + writeStoredTimer(timerId, next); + return elapsedMs; +} + +export function readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs = 0, + running, + runId, + nowMs = Date.now(), +}: { + timerId: string; + startedAtMs: number; + baseElapsedMs?: number; + running: boolean; + runId?: string | null; + nowMs?: number; +}): number { + const timer = + readStoredTimer(timerId, startedAtMs, baseElapsedMs) ?? + createInitialTimer(startedAtMs, baseElapsedMs, running, nowMs, runId); + return materializeElapsed(timer, nowMs, runId); +} + +export function formatMemberActivityElapsed(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (totalMinutes < 60) { + return `${totalMinutes}m ${String(seconds).padStart(2, '0')}s`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${String(minutes).padStart(2, '0')}m`; +} + +export function deriveWorkActivityTimerAnchor( + task: TeamTaskWithKanban, + params: { + teamName: string; + memberName: string; + } +): MemberActivityTimerAnchor | null { + if (!isTeamTaskActivelyWorked(task)) return null; + + const intervals = Array.isArray(task.workIntervals) ? task.workIntervals : []; + let baseElapsedMs = 0; + for (let index = intervals.length - 1; index >= 0; index -= 1) { + const interval = intervals[index]; + const startedAtMs = parseIsoMs(interval?.startedAt); + if (startedAtMs > 0 && !interval?.completedAt) { + for (let previousIndex = 0; previousIndex < index; previousIndex += 1) { + const previous = intervals[previousIndex]; + const previousStartedAtMs = parseIsoMs(previous?.startedAt); + const previousCompletedAtMs = parseIsoMs(previous?.completedAt); + if (previousStartedAtMs > 0 && previousCompletedAtMs > previousStartedAtMs) { + baseElapsedMs += previousCompletedAtMs - previousStartedAtMs; + } + } + return { + startedAt: interval.startedAt, + startedAtMs, + baseElapsedMs, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'work', + taskId: task.id, + startedAt: interval.startedAt, + }), + }; + } + } + if (intervals.length > 0) return null; + + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'status_changed' && event.to === 'in_progress') { + const startedAtMs = parseIsoMs(event.timestamp); + if (startedAtMs > 0) { + return { + startedAt: event.timestamp, + startedAtMs, + baseElapsedMs: 0, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'work', + taskId: task.id, + startedAt: event.timestamp, + }), + }; + } + } + if (event.type === 'task_created' && event.status === 'in_progress') { + const startedAtMs = parseIsoMs(event.timestamp); + if (startedAtMs > 0) { + return { + startedAt: event.timestamp, + startedAtMs, + baseElapsedMs: 0, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'work', + taskId: task.id, + startedAt: event.timestamp, + }), + }; + } + } + } + + return null; +} + +export function deriveReviewActivityTimerAnchor( + task: TeamTaskWithKanban, + params: { + teamName: string; + memberName: string; + } +): MemberActivityTimerAnchor | null { + const memberKey = normalizeMemberName(params.memberName); + if (!memberKey) return null; + + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'review_started') { + if (normalizeMemberName(event.actor) !== memberKey) { + return null; + } + const startedAtMs = parseIsoMs(event.timestamp); + if (startedAtMs <= 0) return null; + return { + startedAt: event.timestamp, + startedAtMs, + baseElapsedMs: 0, + timerId: createMemberActivityTimerId({ + teamName: params.teamName, + memberName: params.memberName, + phase: 'review', + taskId: task.id, + startedAt: event.timestamp, + }), + }; + } + + if ( + event.type === 'review_approved' || + event.type === 'review_changes_requested' || + event.type === 'task_created' || + (event.type === 'status_changed' && + (event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) + ) { + return null; + } + } + + return null; +} + +export function resetMemberActivityTimerStoreForTests(): void { + timers.clear(); +} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 4c43e506..54caff24 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -711,6 +711,54 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str } } +export function shouldDisplayMemberCurrentTask({ + member, + isTeamAlive, + spawnStatus, + spawnLaunchState, + spawnRuntimeAlive, + runtimeEntry, +}: { + member: ResolvedTeamMember; + isTeamAlive?: boolean; + spawnStatus?: MemberSpawnStatus; + spawnLaunchState?: MemberLaunchState; + spawnRuntimeAlive?: boolean; + runtimeEntry?: TeamAgentRuntimeEntry; +}): boolean { + if (member.removedAt || member.status === 'terminated') { + return false; + } + if (isTeamAlive === false) { + return false; + } + if (spawnStatus === 'offline' || spawnStatus === 'error' || spawnStatus === 'skipped') { + return false; + } + if ( + spawnLaunchState === 'failed_to_start' || + spawnLaunchState === 'skipped_for_launch' || + spawnLaunchState === 'runtime_pending_permission' + ) { + return false; + } + if ( + runtimeEntry?.livenessKind === 'shell_only' || + runtimeEntry?.livenessKind === 'registered_only' || + runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found' + ) { + return false; + } + if (runtimeEntry?.alive === false && spawnStatus !== 'online') { + return false; + } + if (spawnRuntimeAlive === false && spawnStatus !== 'online') { + return false; + } + return true; +} + function isQueuedOpenCodeLaunch( member: ResolvedTeamMember, spawnStatus: MemberSpawnStatus | undefined, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index da480b2c..eb31e052 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -84,6 +84,7 @@ const TEAM_MODEL_LABEL_OVERRIDES: Record = { 'claude-haiku-4-5': 'Haiku 4.5', 'claude-haiku-4-5-20251001': 'Haiku 4.5', 'gpt-5.4': 'GPT-5.4', + 'gpt-5.5': 'GPT-5.5', 'gpt-5.4-mini': 'GPT-5.4 Mini', 'gpt-5.3-codex': 'GPT-5.3 Codex', 'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark', @@ -107,6 +108,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record { expect(fakeSession.request).toHaveBeenCalledTimes(1); expect(openExternalMock).toHaveBeenCalledTimes(1); expect(manager.getState().status).toBe('pending'); + expect(manager.getState().authUrl).toBe('https://chatgpt.com/auth'); }); it('cancels a login cleanly while the app-server session is still starting', async () => { @@ -135,6 +136,7 @@ describe('CodexLoginSessionManager', () => { status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); }); @@ -170,6 +172,7 @@ describe('CodexLoginSessionManager', () => { status: 'idle', error: null, startedAt: null, + authUrl: null, }); }); diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 4d4b802e..3e2d3a44 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -40,6 +40,7 @@ const { status: 'idle' as CodexAccountLoginStatus, error: null as string | null, startedAt: null as string | null, + authUrl: null as string | null, }, }, loginStateListeners: new Set<() => void>(), @@ -857,6 +858,7 @@ describe('createCodexAccountFeature', () => { status: 'pending', error: null, startedAt: '2026-04-20T12:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', }); }); @@ -872,6 +874,7 @@ describe('createCodexAccountFeature', () => { expect(pendingSnapshot.login).toMatchObject({ status: 'pending', startedAt: '2026-04-20T12:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', }); expect(loginStartMock).toHaveBeenCalledTimes(1); } finally { @@ -893,12 +896,14 @@ describe('createCodexAccountFeature', () => { status: 'pending', error: null, startedAt: '2026-04-20T12:00:00.000Z', + authUrl: 'https://chatgpt.com/auth', }); loginCancelMock.mockImplementation(() => { emitLoginState({ status: 'cancelled', error: null, startedAt: null, + authUrl: null, }); for (const listener of loginSettledListeners) { listener(); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.tsx b/test/renderer/components/team/members/CurrentTaskIndicator.test.tsx new file mode 100644 index 00000000..ade3735e --- /dev/null +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.tsx @@ -0,0 +1,68 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CurrentTaskIndicator } from '@renderer/components/team/members/CurrentTaskIndicator'; +import { + createMemberActivityTimerId, + resetMemberActivityTimerStoreForTests, +} from '@renderer/utils/memberActivityTimer'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const task: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abc12345', + subject: 'Build feature', + status: 'in_progress', +}; + +describe('CurrentTaskIndicator', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetMemberActivityTimerStoreForTests(); + globalThis.localStorage?.clear(); + document.body.innerHTML = ''; + }); + + it('renders a compact activity timer from the persisted task start anchor', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-07T09:01:05.000Z')); + const startedAt = '2026-05-07T09:00:00.000Z'; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('1m 05s'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index 09153c73..2bb73ef2 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ResolvedTeamMember } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; const member: ResolvedTeamMember = { name: 'alice', @@ -22,7 +22,7 @@ const storeState = { selectedTeamData: { members: [member], isAlive: true, - tasks: [], + tasks: [] as TeamTaskWithKanban[], }, selectedTeamName: 'northstar-core', progress: null as Record | null, @@ -118,7 +118,18 @@ vi.mock('@renderer/components/ui/tooltip', () => ({ })); vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({ - CurrentTaskIndicator: () => null, + CurrentTaskIndicator: ({ + task, + activityLabel, + }: { + task: TeamTaskWithKanban; + activityLabel?: string; + }) => + React.createElement( + 'span', + { 'data-testid': 'hover-current-task' }, + `${activityLabel ?? 'task'} ${task.id}` + ), })); import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard'; @@ -307,6 +318,45 @@ describe('MemberHoverCard spawn-aware presence', () => { }); }); + it('does not show a working-on task when the member is offline', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const task: TeamTaskWithKanban = { + id: 'task-active', + subject: 'Active work', + status: 'in_progress', + }; + storeState.selectedTeamData.members = [{ ...member, currentTaskId: task.id }]; + storeState.selectedTeamData.tasks = [task]; + storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { + status: 'offline', + launchState: 'confirmed_alive', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: false, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberHoverCard, { + name: 'alice', + children: React.createElement('button', { type: 'button' }, 'alice'), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="hover-current-task"]')).toBeNull(); + expect(host.textContent).not.toContain('working on'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('copies launch diagnostics with the active runtime run id only for launch errors', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const writeText = vi.fn().mockResolvedValue(undefined); diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index b5880a44..83665994 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -89,6 +89,24 @@ function failedSpawnStatus(reason: string): MemberSpawnStatusEntry { }; } +function offlineSpawnStatus(): MemberSpawnStatusEntry { + return { + status: 'offline', + launchState: 'confirmed_alive', + updatedAt: '2026-04-23T10:00:00.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + }; +} + +function activeTask(id = 'task-active'): TeamTaskWithKanban { + return { + id, + subject: 'Active task', + status: 'in_progress', + }; +} + describe('MemberList spawn-status memoization', () => { beforeEach(() => { vi.stubGlobal( @@ -240,6 +258,61 @@ describe('MemberList spawn-status memoization', () => { }); }); + it('does not pass active current tasks to cards while the whole team is offline', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const task = activeTask(); + const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }]; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: false, + taskMap: new Map([[task.id, task]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="current-bob"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not pass active current tasks to cards for individually offline members', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const task = activeTask(); + const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }]; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + taskMap: new Map([[task.id, task]]), + memberSpawnStatuses: new Map([['bob', offlineSpawnStatus()]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="current-bob"]')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index c75c49a1..a438bf52 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -6,7 +6,7 @@ import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/Grap import type { GraphNode } from '@claude-teams/agent-graph'; -const previewsByMember = new Map([ +const basePreviewsByMember = new Map([ [ 'team-lead', { @@ -43,6 +43,24 @@ const previewsByMember = new Map([ preview: 'pnpm test', tone: 'warning' as const, }, + { + id: 'preview-2', + kind: 'tool_result' as const, + provider: 'opencode_runtime' as const, + timestamp: '2026-04-03T00:00:30.000Z', + title: 'Send message error', + preview: 'OpenCode tool failed without output', + tone: 'error' as const, + }, + { + id: 'preview-3', + kind: 'tool_result' as const, + provider: 'opencode_runtime' as const, + timestamp: '2026-04-03T00:00:40.000Z', + title: 'Bash result', + preview: 'Tests passed', + tone: 'success' as const, + }, ], coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }], warnings: [], @@ -52,11 +70,12 @@ const previewsByMember = new Map([ }, ], ]); +let mockedPreviewsByMember = basePreviewsByMember; vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({ buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }), useGraphMemberLogPreviews: () => ({ - previewsByMember, + previewsByMember: mockedPreviewsByMember, loading: false, error: null, reload: vi.fn(), @@ -93,6 +112,7 @@ describe('GraphMemberLogPreviewHud', () => { vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z')); + mockedPreviewsByMember = basePreviewsByMember; }); afterEach(() => { @@ -141,6 +161,20 @@ describe('GraphMemberLogPreviewHud', () => { button.textContent?.includes('pnpm test') ); expect(row).not.toBeUndefined(); + expect(row?.querySelector('.float-left')).not.toBeNull(); + expect(row?.querySelector('.line-clamp-3')).toBeNull(); + expect(row?.textContent).toContain('pnpm test'); + + const errorRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode tool failed') + ); + expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull(); + + const resultRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Tests passed') + ); + expect(resultRow?.textContent).toContain('Bash'); + expect(resultRow?.textContent).not.toContain('Bash result'); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -166,6 +200,83 @@ describe('GraphMemberLogPreviewHud', () => { }); }); + it('briefly highlights a newly appeared preview row', async () => { + const node: GraphNode = { + id: 'member:alpha-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const renderHud = (): void => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + }; + + await act(async () => { + renderHud(); + await Promise.resolve(); + }); + + const alicePreview = basePreviewsByMember.get('alice')!; + mockedPreviewsByMember = new Map(basePreviewsByMember); + mockedPreviewsByMember.set('alice', { + ...alicePreview, + items: [ + { + id: 'preview-new', + kind: 'text' as const, + provider: 'claude_transcript' as const, + timestamp: '2026-04-03T00:01:00.000Z', + title: 'Assistant', + preview: 'new compact log', + tone: 'neutral' as const, + }, + ...alicePreview.items, + ], + }); + + await act(async () => { + renderHud(); + await Promise.resolve(); + }); + + const newRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('new compact log') + ); + expect(newRow?.className).toContain('border-sky-300/70'); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + + expect(newRow?.className).not.toContain('border-sky-300/70'); + + act(() => { + root.unmount(); + }); + }); + it('renders lead log previews and opens the lead profile logs tab', async () => { const leadNode: GraphNode = { id: 'lead:alpha-team', diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 5cb11542..a921e312 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -11,7 +11,10 @@ import { validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; -import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; +import { + KANBAN_ZONE, + TASK_PILL, +} from '../../../../packages/agent-graph/src/constants/canvas-constants'; import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; import { STABLE_SLOT_GEOMETRY, @@ -171,7 +174,10 @@ describe('stable slot layout planner', () => { expect(frame).toBeDefined(); expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top); - expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); + const expectedKanbanTopInset = + ACTIVITY_LANE.headerHeight + 4 - (KANBAN_ZONE.headerHeight - TASK_PILL.height / 2); + expect(frame?.kanbanBandRect.top).toBe(frame!.boardBandRect.top + expectedKanbanTopInset); + expect(frame?.kanbanBandRect.bottom).toBeLessThanOrEqual(frame!.boardBandRect.bottom); expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0); diff --git a/test/renderer/utils/memberActivityTimer.test.ts b/test/renderer/utils/memberActivityTimer.test.ts new file mode 100644 index 00000000..b5f824ee --- /dev/null +++ b/test/renderer/utils/memberActivityTimer.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createMemberActivityTimerId, + deriveReviewActivityTimerAnchor, + deriveWorkActivityTimerAnchor, + formatMemberActivityElapsed, + readMemberActivityTimerElapsed, + resetMemberActivityTimerStoreForTests, + syncMemberActivityTimer, +} from '@renderer/utils/memberActivityTimer'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const baseTask: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abc12345', + subject: 'Build feature', + status: 'in_progress', + createdAt: '2026-05-07T09:00:00.000Z', + reviewState: 'none', +}; + +describe('memberActivityTimer', () => { + afterEach(() => { + vi.useRealTimers(); + resetMemberActivityTimerStoreForTests(); + globalThis.localStorage?.clear(); + }); + + it('anchors work timers to the active work interval', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [ + { + startedAt: '2026-05-07T09:10:00.000Z', + completedAt: '2026-05-07T09:15:00.000Z', + }, + { startedAt: '2026-05-07T09:20:00.000Z' }, + ], + }; + + const anchor = deriveWorkActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'bob', + }); + + expect(anchor?.startedAt).toBe('2026-05-07T09:20:00.000Z'); + expect(anchor?.baseElapsedMs).toBe(300_000); + expect(anchor?.timerId).toContain('task-1'); + }); + + it('adds completed work intervals to the active timer elapsed value', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [ + { + startedAt: '2026-05-07T09:10:00.000Z', + completedAt: '2026-05-07T09:15:00.000Z', + }, + { startedAt: '2026-05-07T09:20:00.000Z' }, + ], + }; + const anchor = deriveWorkActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'bob', + }); + expect(anchor).not.toBeNull(); + + expect( + readMemberActivityTimerElapsed({ + timerId: anchor!.timerId, + startedAtMs: anchor!.startedAtMs, + baseElapsedMs: anchor!.baseElapsedMs, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:21:00.000Z'), + }) + ).toBe(360_000); + }); + + it('does not invent a work timer when task start evidence is missing', () => { + expect( + deriveWorkActivityTimerAnchor(baseTask, { + teamName: 'alpha', + memberName: 'bob', + }) + ).toBeNull(); + }); + + it('treats closed work intervals without an active interval as paused', () => { + const task: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [ + { + startedAt: '2026-05-07T09:10:00.000Z', + completedAt: '2026-05-07T09:15:00.000Z', + }, + ], + historyEvents: [ + { + id: 'evt-1', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-07T09:10:00.000Z', + }, + ], + }; + + expect( + deriveWorkActivityTimerAnchor(task, { + teamName: 'alpha', + memberName: 'bob', + }) + ).toBeNull(); + }); + + it('anchors review timers only after the reviewer actually starts review', () => { + const assignedOnly: TeamTaskWithKanban = { + ...baseTask, + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'alice', + historyEvents: [ + { + id: 'evt-1', + type: 'review_requested', + from: 'none', + to: 'review', + reviewer: 'alice', + timestamp: '2026-05-07T09:30:00.000Z', + }, + ], + }; + + expect( + deriveReviewActivityTimerAnchor(assignedOnly, { + teamName: 'alpha', + memberName: 'alice', + }) + ).toBeNull(); + + const started: TeamTaskWithKanban = { + ...assignedOnly, + historyEvents: [ + ...(assignedOnly.historyEvents ?? []), + { + id: 'evt-2', + type: 'review_started', + from: 'review', + to: 'review', + actor: 'alice', + timestamp: '2026-05-07T09:35:00.000Z', + }, + ], + }; + + expect( + deriveReviewActivityTimerAnchor(started, { + teamName: 'alpha', + memberName: 'alice', + })?.startedAt + ).toBe('2026-05-07T09:35:00.000Z'); + }); + + it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => { + const timerId = createMemberActivityTimerId({ + teamName: 'alpha', + memberName: 'bob', + phase: 'work', + taskId: 'task-1', + startedAt: '2026-05-07T09:00:00.000Z', + }); + const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z'); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:01:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:02:00.000Z'), + }) + ).toBe(120_000); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: false, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:02:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: false, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:05:00.000Z'), + }) + ).toBe(120_000); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:05:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:06:00.000Z'), + }) + ).toBe(180_000); + }); + + it('caps elapsed time across unobserved runtime run transitions', () => { + const timerId = createMemberActivityTimerId({ + teamName: 'alpha', + memberName: 'bob', + phase: 'work', + taskId: 'task-1', + startedAt: '2026-05-07T09:00:00.000Z', + }); + const startedAtMs = Date.parse('2026-05-07T09:00:00.000Z'); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-1', + nowMs: Date.parse('2026-05-07T09:01:00.000Z'), + }); + + syncMemberActivityTimer({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-2', + nowMs: Date.parse('2026-05-07T10:00:00.000Z'), + }); + + expect( + readMemberActivityTimerElapsed({ + timerId, + startedAtMs, + baseElapsedMs: 0, + running: true, + runId: 'run-2', + nowMs: Date.parse('2026-05-07T10:00:00.000Z'), + }) + ).toBe(65_000); + }); + + it('formats seconds, minutes, and hours compactly', () => { + expect(formatMemberActivityElapsed(9_000)).toBe('9s'); + expect(formatMemberActivityElapsed(65_000)).toBe('1m 05s'); + expect(formatMemberActivityElapsed(3_780_000)).toBe('1h 03m'); + }); +}); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 7c0e1ea5..7395d715 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -8,6 +8,7 @@ import { getMemberRuntimeAdvisoryTitle, getMemberRuntimeAdvisoryTone, isOpenCodeRelaunchActionable, + shouldDisplayMemberCurrentTask, } from '@renderer/utils/memberHelpers'; import type { ResolvedTeamMember } from '@shared/types'; @@ -27,6 +28,73 @@ const member: ResolvedTeamMember = { }; describe('memberHelpers spawn-aware presence', () => { + it('does not display current task labels for offline or terminal launch states', () => { + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: false, + }) + ).toBe(false); + + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'offline', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: false, + }) + ).toBe(false); + + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'error', + spawnLaunchState: 'failed_to_start', + }) + ).toBe(false); + }); + + it('does not display current task labels for runtime entries without a live agent runtime', () => { + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + providerId: 'opencode', + livenessKind: 'stale_metadata', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }) + ).toBe(false); + }); + + it('keeps current task labels for confirmed online members', () => { + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: true, + runtimeEntry: { + memberName: 'alice', + alive: true, + restartable: true, + providerId: 'gemini', + livenessKind: 'confirmed_bootstrap', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }) + ).toBe(true); + }); + it('shows process-online teammates as online with a green dot', () => { expect( getSpawnAwarePresenceLabel( From 2e05941cbafc29060a1ecb6f5701585f28e408b9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 17:27:45 +0300 Subject: [PATCH 04/83] test(team): align launch dialog expectations --- .../team/dialogs/CodexReconnectPrompt.tsx | 4 +- .../team/dialogs/projectPathOptions.ts | 13 +++++-- .../team/dialogs/LaunchTeamDialog.test.ts | 1 + .../renderer/store/teamChangeThrottle.test.ts | 38 +++---------------- .../utils/teamModelAvailability.test.ts | 1 + 5 files changed, 19 insertions(+), 38 deletions(-) diff --git a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx index 5ce041c9..9efaccae 100644 --- a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx +++ b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx @@ -34,9 +34,9 @@ export function shouldShowCodexReconnectPrompt({ (provider) => provider.providerId === 'codex' ); const codexConnection = codexProvider?.connection?.codex; - const loginStatus = codexConnection?.login.status; + const loginStatus = codexConnection?.login?.status; const loginPending = loginStatus === 'starting' || loginStatus === 'pending'; - if (loginPending && codexConnection?.login.authUrl) { + if (loginPending && codexConnection?.login?.authUrl) { return true; } diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts index 435d228a..b3dba417 100644 --- a/src/renderer/components/team/dialogs/projectPathOptions.ts +++ b/src/renderer/components/team/dialogs/projectPathOptions.ts @@ -14,14 +14,19 @@ export interface ProjectPathOptionMeta { } function toProjectOption(project: ProjectPathProject): ComboboxOption { - return { + const option: ComboboxOption = { value: project.path, label: project.name, description: project.path, - meta: { - discoverySource: project.discoverySource, - } satisfies ProjectPathOptionMeta, }; + + if (project.discoverySource !== undefined) { + option.meta = { + discoverySource: project.discoverySource, + } satisfies ProjectPathOptionMeta; + } + + return option; } /** diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index b3b5a005..10b545bf 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -40,6 +40,7 @@ vi.mock('@renderer/api', () => ({ createdAt: 1, }, ]), + getDashboardRecentProjects: vi.fn(async () => ({ projects: [] })), teams: { getSavedRequest: vi.fn(async () => null), replaceMembers: vi.fn(async () => {}), diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 1f5e05f9..162d2270 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -1280,7 +1280,7 @@ describe('team change throttling', () => { expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); }); - it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => { + it('keeps background polling disabled for unknown in-progress tasks', async () => { const invalidateTaskChangePresence = vi.fn(); const checkTaskHasChanges = vi.fn(async () => undefined); @@ -1321,24 +1321,11 @@ describe('team change throttling', () => { checkTaskHasChanges, } as never); - await vi.advanceTimersByTimeAsync(10_000); - expect(checkTaskHasChanges).toHaveBeenNthCalledWith( - 1, - 'my-team', - 'task-1', - expect.objectContaining({ status: 'in_progress', owner: 'alice' }) - ); - - await vi.advanceTimersByTimeAsync(10_000); - expect(checkTaskHasChanges).toHaveBeenNthCalledWith( - 2, - 'my-team', - 'task-2', - expect.objectContaining({ status: 'in_progress', owner: 'alice' }) - ); + await vi.advanceTimersByTimeAsync(20_000); + expect(checkTaskHasChanges).not.toHaveBeenCalled(); }); - it('polls visible non-selected graph teams from cached team data', async () => { + it('keeps background polling disabled for visible non-selected graph teams', async () => { const invalidateTaskChangePresence = vi.fn(); const checkTaskHasChanges = vi.fn(async () => undefined); @@ -1400,21 +1387,8 @@ describe('team change throttling', () => { checkTaskHasChanges, } as never); - await vi.advanceTimersByTimeAsync(10_000); - expect(checkTaskHasChanges).toHaveBeenNthCalledWith( - 1, - 'my-team', - 'task-1', - expect.objectContaining({ status: 'in_progress', owner: 'alice' }) - ); - - await vi.advanceTimersByTimeAsync(10_000); - expect(checkTaskHasChanges).toHaveBeenNthCalledWith( - 2, - 'my-team', - 'task-2', - expect.objectContaining({ status: 'in_progress', owner: 'alice' }) - ); + await vi.advanceTimersByTimeAsync(20_000); + expect(checkTaskHasChanges).not.toHaveBeenCalled(); }); it('per-team throttling: busy team does not block another visible team', async () => { diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 04249b71..cb7f3540 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -204,6 +204,7 @@ describe('teamModelAvailability', () => { expect(getTeamModelSelectionError('codex', 'gpt-5.4', providerStatus)).toBeNull(); expect(getAvailableTeamProviderModelOptions('codex', providerStatus)).toEqual([ { value: '', label: 'Default', badgeLabel: 'Default' }, + { value: 'gpt-5.5', label: '5.5', badgeLabel: '5.5' }, { value: 'gpt-5.4', label: '5.4', badgeLabel: '5.4' }, { value: 'gpt-5.4-mini', label: '5.4 Mini', badgeLabel: '5.4-mini' }, { value: 'gpt-5.3-codex', label: '5.3 Codex', badgeLabel: '5.3-codex' }, From b9f82f81627b900703b714bed97b79cb8f522284 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 17:52:03 +0300 Subject: [PATCH 05/83] fix(team): preserve log stream participant filter --- .../hooks/useGraphMemberLogPreviews.ts | 11 ++- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 74 +++++++++++----- .../use-cases/GetMemberLogPreviewsUseCase.ts | 9 +- .../GetMemberLogPreviewsUseCase.test.ts | 67 ++++++++++++++ .../memberLogPreviewExtractor.test.ts | 74 ++++++++++++++++ .../policies/memberLogPreviewExtractor.ts | 53 +++++++++++ .../renderer/ui/ExecutionLogStreamView.tsx | 8 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 1 + .../main/createCodexAccountFeature.test.ts | 3 +- .../GraphMemberLogPreviewHud.test.tsx | 88 ++++++++++++++++++- .../useGraphMemberLogPreviews.test.tsx | 84 ++++++++++++++++++ .../agent-graph/useGraphSimulation.test.ts | 9 ++ 12 files changed, 453 insertions(+), 28 deletions(-) diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts index 39049efc..3ed91b86 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -32,7 +32,7 @@ function buildRequestKey(input: { .sort((left, right) => left[0].localeCompare(right[0])); return JSON.stringify([ input.teamName, - input.memberNames.map(normalizeMemberName), + input.memberNames.map(normalizeMemberName).sort((left, right) => left.localeCompare(right)), laneEntries, input.maxItemsPerMember, input.textLimit, @@ -143,7 +143,14 @@ export function useGraphMemberLogPreviews(input: { } return result; }, [input.memberNames]); - const memberKey = useMemo(() => memberNames.map(normalizeMemberName).join('|'), [memberNames]); + const memberKey = useMemo( + () => + memberNames + .map(normalizeMemberName) + .sort((left, right) => left.localeCompare(right)) + .join('|'), + [memberNames] + ); const [previewsByMember, setPreviewsByMember] = useState( new Map() ); diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 9a298332..9f4e07fe 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -101,8 +101,13 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { return ; } -function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: boolean): string { +function resolveEmptyText( + preview: MemberLogPreviewMember | undefined, + loading: boolean, + error: string | null +): string { if (loading && !preview) return 'Loading logs'; + if (error && !preview) return 'Logs unavailable'; if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) { return 'Unsupported provider'; } @@ -111,12 +116,45 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: function compactDisplayTitle(item: MemberLogPreviewItem): string { const title = item.title.trim(); + if (title.toLowerCase() === 'tool result') { + return title; + } if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) { return title.slice(0, -' result'.length).trim() || title; } return title; } +function trimRepeatedTitlePrefix(preview: string, title: string): string { + const normalizedPreview = preview.toLowerCase(); + const normalizedTitle = title.toLowerCase(); + if (normalizedPreview.startsWith(`${normalizedTitle} - `)) { + return preview.slice(title.length + 3).trim(); + } + if (normalizedPreview.startsWith(`${normalizedTitle}: `)) { + return preview.slice(title.length + 2).trim(); + } + if (normalizedPreview.startsWith(`${normalizedTitle} `)) { + return preview.slice(title.length + 1).trim(); + } + return preview; +} + +function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string { + const preview = item.preview?.trim(); + if (preview) { + const compact = trimRepeatedTitlePrefix(preview, displayTitle); + return compact || preview; + } + if (item.kind === 'tool_result') { + return item.tone === 'error' ? 'No error output' : 'No output'; + } + if (item.kind === 'tool_use') { + return 'No input'; + } + return item.sourceLabel || 'Log event'; +} + function setShellHidden(shell: HTMLDivElement): void { shell.style.opacity = '0'; shell.style.pointerEvents = 'none'; @@ -155,7 +193,7 @@ export const GraphMemberLogPreviewHud = ({ }), [nodes] ); - const { previewsByMember, loading } = useGraphMemberLogPreviews({ + const { previewsByMember, loading, error } = useGraphMemberLogPreviews({ teamName, memberNames: visibleMemberNames, laneIdsByMember, @@ -367,10 +405,10 @@ export const GraphMemberLogPreviewHud = ({ (memberName: string, item: MemberLogPreviewItem) => { const relativeTime = formatRelativeTime(item.timestamp); const displayTitle = compactDisplayTitle(item); - const previewText = item.preview || item.sourceLabel || 'Log event'; + const previewText = compactPreviewText(item, displayTitle); const titleText = relativeTime - ? `${item.title} ${relativeTime} ${previewText}` - : `${item.title} ${previewText}`; + ? `${displayTitle} ${relativeTime} ${previewText}` + : `${displayTitle} ${previewText}`; const isHighlighted = highlightedItemIds.has(item.id); return ( @@ -392,23 +430,15 @@ export const GraphMemberLogPreviewHud = ({ > {itemIcon(item)} - - - {displayTitle} - - {relativeTime ? ( - - {relativeTime} - - ) : null} + + {displayTitle} - + {relativeTime ? ( + + {relativeTime} + + ) : null} + {previewText} @@ -467,7 +497,7 @@ export const GraphMemberLogPreviewHud = ({ className="flex h-14 min-h-14 items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60" onClick={() => openLogs(memberName)} > - {resolveEmptyText(preview, loading)} + {resolveEmptyText(preview, loading, error)} )} {preview && preview.overflowCount > 0 ? ( diff --git a/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts b/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts index 92d8fea2..a167584f 100644 --- a/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts +++ b/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts @@ -74,9 +74,16 @@ function stableInputKey(input: { textLimit: number; forceRefresh?: boolean; }): string { + const memberKeys = input.members + .map((member) => [normalizeMemberName(member.memberName), member.laneId ?? ''] as const) + .sort((left, right) => { + const byName = left[0].localeCompare(right[0]); + if (byName !== 0) return byName; + return left[1].localeCompare(right[1]); + }); return JSON.stringify([ input.teamName, - input.members.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? '']), + memberKeys, input.maxItems, input.textLimit, input.forceRefresh === true, diff --git a/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts index 6d70f931..58c3140f 100644 --- a/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts +++ b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts @@ -5,6 +5,7 @@ import { GetMemberLogPreviewsUseCase } from '../GetMemberLogPreviewsUseCase'; import type { MemberLogPreviewSource, MemberLogPreviewSourceInput, + MemberLogPreviewSourceResult, } from '../../ports/MemberLogPreviewSource'; function source( @@ -16,6 +17,38 @@ function source( return { provider, loadPreview }; } +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function textResult(memberName: string): MemberLogPreviewSourceResult { + return { + provider: 'claude_transcript', + status: 'included', + items: [ + { + id: `item:${memberName}`, + kind: 'text', + provider: 'claude_transcript', + timestamp: '2026-04-01T12:00:00.000Z', + title: 'Assistant', + preview: memberName, + tone: 'neutral', + }, + ], + warnings: [], + truncated: false, + overflowCount: 0, + }; +} + describe('GetMemberLogPreviewsUseCase', () => { it('dedupes members, clamps options, and merges source coverage per member', async () => { const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({ @@ -95,4 +128,38 @@ describe('GetMemberLogPreviewsUseCase', () => { expect(loadPreview).toHaveBeenCalledTimes(1); expect(first.members[0]?.warnings[0]?.code).toBe('codex_member_wide_not_supported'); }); + + it('dedupes in-flight batch requests for the same member set in different order', async () => { + const pendingByMember = new Map< + string, + ReturnType> + >(); + const loadPreview = vi.fn((input: MemberLogPreviewSourceInput) => { + const deferred = createDeferred(); + pendingByMember.set(input.memberName, deferred); + return deferred.promise; + }); + const useCase = new GetMemberLogPreviewsUseCase({ + sources: [source('claude_transcript', loadPreview)], + clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') }, + logger: { warn: vi.fn(), error: vi.fn() }, + }); + + const firstPromise = useCase.execute({ + teamName: 'alpha-team', + memberNames: ['alice', 'bob'], + }); + const secondPromise = useCase.execute({ + teamName: 'alpha-team', + memberNames: ['bob', 'alice'], + }); + + expect(loadPreview).toHaveBeenCalledTimes(2); + pendingByMember.get('alice')?.resolve(textResult('alice')); + pendingByMember.get('bob')?.resolve(textResult('bob')); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + expect(second).toBe(first); + expect(first.members.map((member) => member.memberName)).toEqual(['alice', 'bob']); + }); }); diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index a8bc4e24..b59f9a8e 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -571,6 +571,80 @@ Reply to this comment using MCP tool task_add_comment. expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response'); }); + it('formats direct task list arrays without leaking raw array fields', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 220, + messages: [ + message({ + uuid: 'list-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-list', + name: 'mcp__agent-teams__task_list', + input: { teamName: 'demo' }, + }, + ], + }), + message({ + uuid: 'list-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-list', + content: JSON.stringify([ + { + id: '4499fbe5-1fee-42a5-8584-851fbfc4adcd', + displayId: '4499fbe5', + subject: 'Fix contact form route', + status: 'todo', + owner: 'bob', + }, + { + id: '0276a054-1111-4222-8333-444444444444', + displayId: '0276a054', + title: 'High-confidence bug triage', + status: 'in_progress', + owner: 'alice', + }, + { + id: '8a9e766b-1111-4222-8333-444444444444', + displayId: '8a9e766b', + title: 'Follow-up split', + status: 'done', + owner: 'tom', + }, + { + id: '898a6a3e-1111-4222-8333-444444444444', + displayId: '898a6a3e', + title: 'Regression research', + status: 'done', + owner: 'team-lead', + }, + ]), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Task list', + preview: + '4 tasks - #4499fbe5: Fix contact form route, status todo, owner bob; #0276a054: High-confidence bug triage, status in_progress, owner alice; #8a9e766b: Follow-up split, status done, owner tom; +1 more', + }); + expect(result.items).toHaveLength(1); + expect(result.items[0]?.preview).not.toContain('displayId'); + expect(result.items[0]?.preview).not.toContain('[{'); + }); + it('formats common board and cross-team tool previews compactly', () => { const result = extractMemberLogPreviewItems({ provider: 'opencode_runtime', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index fad0ef24..dd89cf51 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -465,7 +465,56 @@ function countArrayField(payload: Record, keys: readonly string return null; } +function formatTaskCollectionItem(task: Record): string | null { + const taskRef = taskRefFromPayload(task); + const taskSummary = shortTaskSummary(task); + if (taskRef && taskSummary) return `${taskRef}: ${taskSummary}`; + if (taskRef) return taskRef; + return taskSummary; +} + +function formatTaskCollectionArrayPayload( + items: readonly unknown[], + canonicalToolNameValue: string | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if (canonical !== 'task_list' && canonical !== 'task_briefing') { + return null; + } + + const tasks = items + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)); + if (tasks.length === 0) { + return { + title: canonical === 'task_briefing' ? 'Task briefing' : 'Task list', + text: '0 tasks', + }; + } + + const taskSummaries = tasks.slice(0, 3).map(formatTaskCollectionItem).filter(Boolean); + const remainingTaskCount = Math.max(0, tasks.length - taskSummaries.length); + const moreText = remainingTaskCount > 0 ? `; +${remainingTaskCount} more` : ''; + const countText = `${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'}`; + return { + title: canonical === 'task_briefing' ? 'Task briefing' : 'Task list', + text: + taskSummaries.length > 0 + ? `${countText} - ${taskSummaries.join('; ')}${moreText}` + : countText, + }; +} + function formatTaskCollectionPayload(payload: Record): KnownPayloadPreview | null { + for (const key of ['tasks', 'items', 'actionable'] as const) { + const value = payload[key]; + if (!Array.isArray(value)) continue; + const collection = formatTaskCollectionArrayPayload(value, 'task_list'); + if (collection) { + return collection; + } + } + const taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']); const summary = stringField(payload, 'summary') ?? @@ -935,6 +984,10 @@ function previewUnknownValue( if (textBlocks) { return previewUnknownValue(textBlocks, limit, priorityKeys, toolContext); } + const knownCollection = formatTaskCollectionArrayPayload(value, toolContext?.canonicalName); + if (knownCollection) { + return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title }; + } const parts = value .slice(0, 3) .map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview) diff --git a/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx index 164503fe..bbf90130 100644 --- a/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx +++ b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; @@ -238,6 +238,7 @@ export function ExecutionLogStreamView({ getSegmentMetaLabel, }: Readonly>): React.JSX.Element { const [selectedParticipantKey, setSelectedParticipantKey] = useState('all'); + const appliedSelectionResetKeyRef = useRef(null); const participants = stream?.participants ?? []; const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]); const participantVisuals = useMemo( @@ -248,8 +249,13 @@ export function ExecutionLogStreamView({ useEffect(() => { if (!stream) { setSelectedParticipantKey('all'); + appliedSelectionResetKeyRef.current = null; return; } + if (appliedSelectionResetKeyRef.current === selectionResetKey) { + return; + } + appliedSelectionResetKeyRef.current = selectionResetKey; setSelectedParticipantKey(stream.defaultFilter); }, [selectionResetKey, stream]); diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 3c04faae..93375cb3 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -21,6 +21,7 @@ import { } from '@features/codex-runtime-profile/renderer'; import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Dialog, diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 3e2d3a44..e6926f33 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -41,7 +41,7 @@ const { error: null as string | null, startedAt: null as string | null, authUrl: null as string | null, - }, + } as CodexLoginStateDto, }, loginStateListeners: new Set<() => void>(), loginSettledListeners: new Set<() => void>(), @@ -242,6 +242,7 @@ describe('createCodexAccountFeature', () => { status: 'idle', error: null, startedAt: null, + authUrl: null, }; loginStateListeners.clear(); loginSettledListeners.clear(); diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index a438bf52..1a911c8d 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -5,8 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud'; import type { GraphNode } from '@claude-teams/agent-graph'; +import type { MemberLogPreviewMember } from '@features/member-log-stream/contracts/dto'; -const basePreviewsByMember = new Map([ +const basePreviewsByMember = new Map([ [ 'team-lead', { @@ -329,4 +330,89 @@ describe('GraphMemberLogPreviewHud', () => { root.unmount(); }); }); + + it('keeps compact event text readable without repeating the title prefix', async () => { + mockedPreviewsByMember = new Map([ + [ + 'alice', + { + memberName: 'alice', + items: [ + { + id: 'message-sent-preview', + kind: 'tool_result', + provider: 'claude_transcript', + timestamp: '2026-04-03T00:01:00.000Z', + title: 'Message sent', + preview: 'Message sent to team-lead - #abc done', + tone: 'success', + }, + { + id: 'generic-tool-result-preview', + kind: 'tool_result', + provider: 'claude_transcript', + timestamp: '2026-04-03T00:00:50.000Z', + title: 'Tool result', + preview: 'stored', + tone: 'success', + }, + ], + coverage: [{ provider: 'claude_transcript', status: 'included' }], + warnings: [], + truncated: false, + overflowCount: 0, + generatedAt: '2026-04-03T00:01:00.000Z', + }, + ], + ]); + const node: GraphNode = { + id: 'member:alpha-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + await Promise.resolve(); + }); + + const messageRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('#abc done') + ); + expect(messageRow?.textContent).toContain('Message sent'); + expect(messageRow?.textContent).toContain('to team-lead - #abc done'); + expect(messageRow?.textContent).not.toContain('Message sentMessage sent'); + expect(messageRow?.textContent).not.toContain('Message sent now Message sent'); + + const genericResultRow = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('stored') + ); + expect(genericResultRow?.textContent).toContain('Tool result'); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx index 66aefaeb..4bb05590 100644 --- a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx +++ b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx @@ -57,6 +57,31 @@ function response(memberName: string, generatedAt: string): MemberLogPreviewResp }; } +function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse { + return { + generatedAt, + members: memberNames.map((memberName) => ({ + memberName, + items: [ + { + id: `${memberName}:${generatedAt}`, + kind: 'text', + provider: 'claude_transcript', + timestamp: generatedAt, + title: 'Assistant', + preview: memberName, + tone: 'neutral', + }, + ], + coverage: [{ provider: 'claude_transcript', status: 'included' }], + warnings: [], + truncated: false, + overflowCount: 0, + generatedAt, + })), + }; +} + const HookProbe = ({ teamName, memberNames, @@ -250,6 +275,65 @@ describe('useGraphMemberLogPreviews', () => { }); }); + it('does not duplicate preview requests when the same visible members are reordered', async () => { + const firstLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice', 'bob'], + expect.any(Object) + ); + + await act(async () => { + root.render( + undefined} /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + firstLoad.resolve(batchResponse(['alice', 'bob'], '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + + await act(async () => { + root.render( + undefined} /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + it('reloads visible members on log-source events with force refresh', async () => { let teamChangeListener: | ((event: unknown, data: { teamName: string; type: string }) => void) diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index a921e312..d9a16524 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -316,6 +316,9 @@ describe('stable slot layout planner', () => { }); expect(snapshot).not.toBeNull(); + if (!snapshot) { + throw new Error('Expected stable slot layout snapshot'); + } expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); for (const frame of snapshot.memberSlotFrames) { @@ -429,6 +432,9 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); expect(frame).toBeDefined(); expect(footprint).toBeDefined(); + if (!snapshot || !frame || !footprint) { + throw new Error('Expected stable slot frame and footprint'); + } const legacyHorizontalExtent = snapshot.runtimeCentralExclusion.right; const legacyVerticalExtent = Math.abs(snapshot.runtimeCentralExclusion.top); @@ -990,6 +996,9 @@ describe('stable slot layout planner', () => { }); expect(snapshot).not.toBeNull(); + if (!snapshot) { + throw new Error('Expected stable slot layout snapshot'); + } const targetFrame = snapshot.memberSlotFrames[1]; expect( From 30a6e36976490b055085e816adfc234434642e43 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 18:07:00 +0300 Subject: [PATCH 06/83] fix(team): stabilize graph preview validation --- .../agent-graph/src/ports/GraphConfigPort.ts | 1 + packages/agent-graph/src/ui/GraphControls.tsx | 9 +++ packages/agent-graph/src/ui/GraphView.tsx | 5 +- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 10 +-- .../renderer/ui/TeamGraphOverlay.tsx | 2 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 2 +- .../memberLogPreviewExtractor.test.ts | 56 ++++++++++++++++ .../policies/memberLogPreviewExtractor.ts | 18 ++++- .../agent-graph/GraphControls.test.ts | 65 +++++++++++++++++++ .../GraphMemberLogPreviewHud.test.tsx | 2 + .../features/agent-graph/GraphView.test.ts | 24 ++++++- .../agent-graph/useGraphSimulation.test.ts | 43 ++++++++++++ 12 files changed, 224 insertions(+), 13 deletions(-) diff --git a/packages/agent-graph/src/ports/GraphConfigPort.ts b/packages/agent-graph/src/ports/GraphConfigPort.ts index 8b5ae57b..c86ba950 100644 --- a/packages/agent-graph/src/ports/GraphConfigPort.ts +++ b/packages/agent-graph/src/ports/GraphConfigPort.ts @@ -33,6 +33,7 @@ export interface GraphConfigPort { // ─── Filters (show/hide node kinds) ──────────────────────────────────── showActivity?: boolean; + showLogs?: boolean; showTasks?: boolean; showProcesses?: boolean; showCompletedTasks?: boolean; diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index dbf36ccf..c06384dc 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -9,6 +9,7 @@ import { Activity, Columns3, Expand, + FileText, Settings2, Eye, EyeOff, @@ -29,6 +30,7 @@ import type { GraphLayoutMode } from '../ports/types'; export interface GraphFilterState { showActivity: boolean; + showLogs: boolean; showTasks: boolean; showProcesses: boolean; showEdges: boolean; @@ -269,6 +271,13 @@ export function GraphControls({ label="Activity" block /> + toggle('showLogs')} + icon={} + label="Logs" + block + /> toggle('showTasks')} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 5486d139..b9e2520c 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -124,6 +124,7 @@ export function GraphView({ const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ showActivity: config?.showActivity ?? true, + showLogs: config?.showLogs ?? config?.showActivity ?? true, showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, showEdges: true, @@ -138,10 +139,10 @@ export function GraphView({ ? { ...data.layout, showActivity: filters.showActivity, - showLogs: filters.showActivity, + showLogs: filters.showLogs, } : data.layout, - [data.layout, filters.showActivity] + [data.layout, filters.showActivity, filters.showLogs] ); // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 9f4e07fe..8171a72f 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -416,7 +416,7 @@ export const GraphMemberLogPreviewHud = ({ key={item.id} type="button" className={[ - 'block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500 hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]', + 'block h-16 min-h-16 w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500 hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]', isHighlighted ? 'border-sky-300/70 bg-[rgba(14,34,62,0.74)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]' : 'border-white/10 bg-[rgba(8,14,28,0.52)]', @@ -430,15 +430,15 @@ export const GraphMemberLogPreviewHud = ({ > {itemIcon(item)} - + {displayTitle} {relativeTime ? ( - + {relativeTime} ) : null} - + {previewText} @@ -494,7 +494,7 @@ export const GraphMemberLogPreviewHud = ({ ) : ( ); }, @@ -494,7 +519,7 @@ export const GraphMemberLogPreviewHud = ({ ) : ( ); -} +}; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 93375cb3..cd91af77 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -20,8 +20,9 @@ import { resolveCodexRuntimeSelection, } from '@features/codex-runtime-profile/renderer'; import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer'; -import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { api } from '@renderer/api'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; +import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -40,7 +41,6 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; -import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { useStore } from '@renderer/store'; import { AlertTriangle, Key, Link2, Loader2, Trash2 } from 'lucide-react'; diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 5382ef16..fc4a4d9d 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -7,11 +7,11 @@ import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { getTeamTaskWorkflowColumn, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; -import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { useShallow } from 'zustand/react/shallow'; import type { TeamTaskWithKanban } from '@shared/types'; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 592b399a..f92b998b 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -63,8 +63,8 @@ import { getKnownSlashCommand, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; -import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages'; import { AlertTriangle, Check, diff --git a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx index 9efaccae..4a5b502f 100644 --- a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx +++ b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx @@ -59,7 +59,7 @@ export function shouldShowCodexReconnectPrompt({ ); } -export function CodexReconnectPrompt({ +export const CodexReconnectPrompt = ({ authUrl, reconnectBusy, onReconnect, @@ -67,7 +67,7 @@ export function CodexReconnectPrompt({ authUrl: string | null; reconnectBusy: boolean; onReconnect: () => void; -}): React.JSX.Element { +}): React.JSX.Element => { return (
); -} +}; diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index ea9479ad..35363c6f 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -28,8 +28,8 @@ import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; -import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react'; import type { InlineChip } from '@renderer/types/inlineChip'; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index dd653a33..180d05a3 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -88,15 +88,15 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; -import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { CodexFastModeSelector } from './CodexFastModeSelector'; +import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { clearInheritedMemberModelsUnavailableForProvider, resolveProviderScopedMemberModel, } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; -import { ProjectPathSelector } from './ProjectPathSelector'; import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; +import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildReusableProviderPrepareModelResults, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 8fabcc54..3689d4f1 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -91,8 +91,8 @@ import { CronScheduleInput } from '../schedule/CronScheduleInput'; import { AdvancedCliSection } from './AdvancedCliSection'; import { AnthropicFastModeSelector } from './AnthropicFastModeSelector'; -import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { CodexFastModeSelector } from './CodexFastModeSelector'; +import { CodexReconnectPrompt, shouldShowCodexReconnectPrompt } from './CodexReconnectPrompt'; import { EffortLevelSelector } from './EffortLevelSelector'; import { resolveLaunchDialogPrefill } from './launchDialogPrefill'; import { @@ -100,8 +100,8 @@ import { resolveProviderScopedMemberModel, } from './memberModelScope'; import { OptionalSettingsSection } from './OptionalSettingsSection'; -import { ProjectPathSelector } from './ProjectPathSelector'; import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; +import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildReusableProviderPrepareModelResults, diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index da65e517..94faeeef 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -66,11 +66,11 @@ function getSourceLabel(source: DashboardRecentProjectSource): string { } } -function ProjectSourceBadge({ +const ProjectSourceBadge = ({ source, }: { source?: DashboardRecentProjectSource; -}): React.JSX.Element | null { +}): React.JSX.Element | null => { if (!source) { return null; } @@ -92,7 +92,7 @@ function ProjectSourceBadge({ ))}
); -} +}; export type CwdMode = 'project' | 'custom'; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 2e903d37..b88df1d5 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -54,16 +54,16 @@ import { } from '@renderer/utils/taskChangeRequest'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { - getTeamTaskWorkflowColumn, - isTeamTaskFinishedForDependency, - isTeamTaskNeedsFixActionable, -} from '@shared/utils/teamTaskState'; import { deriveTaskDisplayId, formatTaskDisplayLabel, taskMatchesRef, } from '@shared/utils/taskIdentity'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { format, formatDistanceToNow } from 'date-fns'; import { AlignLeft, diff --git a/src/renderer/components/team/dialogs/projectPathOptions.ts b/src/renderer/components/team/dialogs/projectPathOptions.ts index b3dba417..8dd5af03 100644 --- a/src/renderer/components/team/dialogs/projectPathOptions.ts +++ b/src/renderer/components/team/dialogs/projectPathOptions.ts @@ -1,8 +1,8 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; -import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { DashboardRecentProjectSource } from '@features/recent-projects/contracts'; +import type { ComboboxOption } from '@renderer/components/ui/combobox'; import type { Project } from '@shared/types'; export interface ProjectPathProject extends Project { diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 0377c1d0..1600058b 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -13,11 +13,11 @@ import { buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { isTeamTaskFinishedForDependency, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; -import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { ArrowLeftFromLine, ArrowRightFromLine, diff --git a/src/renderer/components/team/members/MemberTasksTab.tsx b/src/renderer/components/team/members/MemberTasksTab.tsx index 1ef96423..15d90066 100644 --- a/src/renderer/components/team/members/MemberTasksTab.tsx +++ b/src/renderer/components/team/members/MemberTasksTab.tsx @@ -7,11 +7,11 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; +import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { getTeamTaskWorkflowColumn, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; -import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index f2c067e8..852e7fda 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -5,11 +5,11 @@ import { REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, } from '@renderer/utils/memberHelpers'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { getTeamTaskWorkflowColumn, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; -import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 54caff24..60f9c618 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -779,7 +779,7 @@ function isQueuedOpenCodeLaunch( // Only label lanes as queued before runtime evidence appears. Once the // backend has any liveness signal, show the exact runtime state instead. - return runtimeEntry == null || runtimeEntry.livenessKind == null; + return runtimeEntry?.livenessKind == null; } function hasElapsedSinceIso( diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9a87c7e3..f8d23599 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1133,8 +1133,8 @@ export interface RetryFailedOpenCodeSecondaryLanesResult { attempted: string[]; confirmed: string[]; pending: string[]; - failed: Array<{ memberName: string; error: string }>; - skipped: Array<{ memberName: string; reason: string }>; + failed: { memberName: string; error: string }[]; + skipped: { memberName: string; reason: string }[]; } export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts index e33539f9..afc4418b 100644 --- a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts @@ -28,6 +28,7 @@ async function writeRollout( source?: string; timestamp?: string; branch?: string; + metadataPadding?: string; }, mtime: Date ): Promise { @@ -43,6 +44,9 @@ async function writeRollout( cwd: payload.cwd, source: payload.source ?? 'cli', git: payload.branch ? { branch: payload.branch } : undefined, + ...(payload.metadataPadding + ? { base_instructions: { text: payload.metadataPadding } } + : {}), }, })}\n${'x'.repeat(1024)}`, 'utf8' @@ -110,6 +114,40 @@ describe('CodexSessionFileRecentProjectsSourceAdapter', () => { expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha'); }); + it('loads Codex projects from large session metadata lines without parsing the full line', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue(null), + } as unknown as RecentProjectIdentityResolver; + const updatedAt = new Date('2026-04-14T12:00:00.000Z'); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-large.jsonl'), + { + cwd: '/Users/test/projects/large', + metadataPadding: 'x'.repeat(160_000), + }, + updatedAt + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + const result = await adapter.list(); + + expect(result.candidates).toEqual([ + expect.objectContaining({ + primaryPath: '/Users/test/projects/large', + sourceKind: 'codex', + }), + ]); + }); + it('deduplicates sessions by cwd and keeps the newest activity', async () => { const codexHome = path.join(tempDir, '.codex'); const logger = createLogger(); diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index fc08f322..738ca724 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -164,14 +164,16 @@ describe('GraphMemberLogPreviewHud', () => { expect(row).not.toBeUndefined(); expect(row?.querySelector('.float-left')).not.toBeNull(); expect(row?.querySelector('.line-clamp-3')).toBeNull(); - expect(row?.className).toContain('h-16'); - expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-4'); + expect(row?.className).toContain('h-[68px]'); + expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-[18px]'); expect(row?.textContent).toContain('pnpm test'); const errorRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('OpenCode tool failed') ); + expect(errorRow?.className).toContain('border-rose-400/35'); expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull(); + expect(errorRow?.querySelector('.text-rose-100')).not.toBeNull(); const resultRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Tests passed') @@ -203,6 +205,73 @@ describe('GraphMemberLogPreviewHud', () => { }); }); + it('caps long visible rows while preserving the full preview in the title', async () => { + const node: GraphNode = { + id: 'member:alpha-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, + }; + const longPreview = + 'to team-lead inbox - #68a3a8cc blocked by dependencies and needs a follow-up investigation before merge final-token'; + const alicePreview = basePreviewsByMember.get('alice')!; + mockedPreviewsByMember = new Map(basePreviewsByMember); + mockedPreviewsByMember.set('alice', { + ...alicePreview, + items: [ + { + id: 'preview-long', + kind: 'tool_use' as const, + provider: 'claude_transcript' as const, + timestamp: '2026-04-03T00:00:00.000Z', + title: 'Send message', + preview: longPreview, + tone: 'warning' as const, + }, + ], + overflowCount: 0, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + await Promise.resolve(); + }); + + const row = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Send message') + ); + + expect(row?.textContent).toContain('...'); + expect(row?.textContent).not.toContain('final-token'); + expect(row?.getAttribute('title')).toContain('final-token'); + + act(() => { + root.unmount(); + }); + }); + it('briefly highlights a newly appeared preview row', async () => { const node: GraphNode = { id: 'member:alpha-team:alice', From 0e080abefb58700143fe4b02d26682838c29b502 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 18:44:28 +0300 Subject: [PATCH 08/83] fix(logs): detect additional error payload fields --- .../memberLogPreviewExtractor.test.ts | 33 +++++++++++++++++++ .../policies/memberLogPreviewExtractor.ts | 8 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index 85de7ebd..a69147d1 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -425,6 +425,39 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items[0]?.preview).not.toContain('{"type"'); }); + it('marks structured isError payloads as errors and prefers stderr details', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'stderr-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-stderr', + content: { + isError: true, + stderr: 'Permission denied while writing app/index.tsx', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Tool error', + preview: 'Permission denied while writing app/index.tsx', + tone: 'error', + }); + }); + it('formats orphan comment result payloads without guessing add vs read semantics', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 2db993f7..84c3dc42 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -297,7 +297,12 @@ function unknownPayloadLooksLikeError(value: unknown): boolean { if (type === 'error' || type?.endsWith('_error')) { return true; } - if (record.ok === false || record.success === false) { + if ( + record.ok === false || + record.success === false || + record.isError === true || + record.is_error === true + ) { return true; } @@ -325,6 +330,7 @@ function payloadErrorMessage(payload: Record): string | null { const direct = stringField(payload, 'error') ?? stringField(payload, 'errorMessage') ?? + stringField(payload, 'stderr') ?? stringField(payload, 'message'); if (direct) { return direct; From cade7b4fdb379887bd81ef03b5f6414503396aab Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 19:04:37 +0300 Subject: [PATCH 09/83] fix(logs): refine runtime preview summaries --- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 2 +- .../memberLogPreviewExtractor.test.ts | 332 ++++++++++++++++++ .../policies/memberLogPreviewExtractor.ts | 222 +++++++++++- 3 files changed, 551 insertions(+), 5 deletions(-) diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 5042bce2..fccf53f5 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -28,7 +28,7 @@ import type { const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_HEIGHT = 292; const NEW_LOG_HIGHLIGHT_MS = 1_000; -const COMPACT_ROW_TEXT_LIMIT = 118; +const COMPACT_ROW_TEXT_LIMIT = 92; const COMPACT_ROW_MIN_PREVIEW_LIMIT = 48; interface StableRectLike { diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index a69147d1..10d7b344 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -458,6 +458,52 @@ Reply to this comment using MCP tool task_add_comment. }); }); + it('marks plain failed tool-result text as an error when runtime flags are missing', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'read-task-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-task-get', + name: 'agent-teams_task_get', + input: { + taskId: '211e430b-0901-4c9e-9296-2b6e2059a08f', + }, + }, + ], + }), + message({ + uuid: 'read-task-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-task-get', + content: + "Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f", + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Read task error', + preview: + "Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f", + tone: 'error', + }); + }); + it('formats orphan comment result payloads without guessing add vs read semantics', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', @@ -927,6 +973,292 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items).toHaveLength(2); }); + it('formats runtime housekeeping previews without leaking internal fields', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'briefing-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-briefing', + name: 'agent-teams_member_briefing', + input: { + teamName: 'relay-works-10', + memberName: 'jack', + runtimeProvider: 'opencode', + }, + }, + ], + }), + message({ + uuid: 'briefing-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-briefing', + content: + 'Member briefing for jack on team "relay-works-10" (relay-works-10). Role: developer. CRITICAL: hidden long briefing details.', + }, + ], + }), + message({ + uuid: 'checkin-call', + timestamp: '2026-04-01T10:02:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-checkin', + name: 'agent-teams_runtime_bootstrap_checkin', + input: { + teamName: 'relay-works-10', + runId: 'run-1', + memberName: 'jack', + runtimeSessionId: 'ses-1', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Runtime check-in', + preview: 'jack checked in', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_result', + title: 'Member briefing', + preview: 'Loaded briefing for jack', + }); + expect(JSON.stringify(result.items)).not.toContain('runtimeSessionId'); + expect(JSON.stringify(result.items)).not.toContain('CRITICAL'); + + const inputOnly = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'briefing-input-only', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-briefing-only', + name: 'agent-teams_member_briefing', + input: { + teamName: 'relay-works-10', + memberName: 'jack', + runtimeProvider: 'opencode', + }, + }, + ], + }), + ], + }); + + expect(inputOnly.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Member briefing', + preview: 'Loaded briefing for jack', + }); + + const failedBriefing = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'briefing-call-failed', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-briefing-failed', + name: 'agent-teams_member_briefing', + input: { + teamName: 'relay-works-10', + memberName: 'jack', + }, + }, + ], + }), + message({ + uuid: 'briefing-result-failed', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-briefing-failed', + content: "Tool 'member_briefing' execution failed: runtime session missing", + }, + ], + }), + ], + }); + + expect(failedBriefing.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Member briefing error', + preview: "Tool 'member_briefing' execution failed: runtime session missing", + tone: 'error', + }); + }); + + it('formats runtime ops, work sync and process previews without internal ids', () => { + const runtimeResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'heartbeat-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-heartbeat', + name: 'agent-teams_runtime_heartbeat', + input: { + runId: 'run-1', + teamName: 'relay-works-10', + memberName: 'jack', + runtimeSessionId: 'ses-1', + }, + }, + ], + }), + message({ + uuid: 'runtime-event-call', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-runtime-event', + name: 'agent-teams_runtime_task_event', + input: { + memberName: 'jack', + taskId: 'abc12345-0000-0000-0000-000000000000', + event: 'started', + }, + }, + ], + }), + ], + }); + + expect(runtimeResult.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Runtime task event', + preview: 'jack started #abc12345', + }); + expect(runtimeResult.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Runtime heartbeat', + preview: 'jack heartbeat', + }); + expect(JSON.stringify(runtimeResult.items)).not.toContain('runtimeSessionId'); + + const workSyncResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'work-sync-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-work-sync', + name: 'agent-teams_member_work_sync_report', + input: { + memberName: 'jack', + state: 'still_working', + taskIds: ['abc12345-0000-0000-0000-000000000000'], + reportToken: 'secret-token', + }, + }, + ], + }), + message({ + uuid: 'work-sync-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-work-sync', + content: 'ok', + }, + ], + }), + ], + }); + + expect(workSyncResult.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Work sync report', + preview: 'jack still_working #abc12345', + }); + expect(JSON.stringify(workSyncResult.items)).not.toContain('reportToken'); + + const processResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'process-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-process-list', + name: 'agent-teams_process_list', + input: { teamName: 'relay-works-10' }, + }, + ], + }), + message({ + uuid: 'process-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-process-list', + content: JSON.stringify([ + { pid: 123, label: 'vite dev', status: 'running' }, + { pid: 456, command: 'pnpm test', status: 'exited' }, + ]), + }, + ], + }), + ], + }); + + expect(processResult.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Process list', + preview: '2 processes - vite dev running; pnpm test exited', + }); + expect(processResult.items[0]?.preview).not.toContain('[{'); + }); + it('uses concrete names for generic runtime tool results', () => { const result = extractMemberLogPreviewItems({ provider: 'opencode_runtime', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 84c3dc42..754f43fb 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -469,6 +469,8 @@ function formatToolTitle(toolName: string): string { if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; if (canonical === 'cross_team_send') return 'Cross-team message'; if (canonical === 'runtime_deliver_message') return 'Runtime delivery'; + if (canonical === 'runtime_task_event') return 'Runtime task event'; + if (canonical === 'runtime_heartbeat') return 'Runtime heartbeat'; if (canonical === 'task_create' || canonical === 'task_create_from_message') return 'Create task'; if (canonical === 'task_complete') return 'Complete task'; if (canonical === 'task_add_comment') return 'Add comment'; @@ -491,6 +493,8 @@ function formatToolTitle(toolName: string): string { if (canonical === 'review_request_changes') return 'Request changes'; if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; if (canonical === 'member_briefing') return 'Member briefing'; + if (canonical === 'member_work_sync_status') return 'Work sync status'; + if (canonical === 'member_work_sync_report') return 'Work sync report'; if (canonical === 'task_add') return 'Add task'; if (canonical === 'task_update') return 'Update task'; if (canonical === 'task_delete') return 'Delete task'; @@ -524,7 +528,11 @@ function isToolUseSupersededBySuccessResult(toolName: string): boolean { canonical === 'cross_team_send' || canonical === 'runtime_deliver_message' || canonical === 'runtime_bootstrap_checkin' || + canonical === 'runtime_heartbeat' || + canonical === 'runtime_task_event' || canonical === 'member_briefing' || + canonical === 'member_work_sync_status' || + canonical === 'member_work_sync_report' || canonical.startsWith('task_') || canonical.startsWith('review_') ); @@ -686,6 +694,77 @@ function formatTaskCollectionPayload(payload: Record): KnownPay return summary ? { title: 'Task list', text: summary } : null; } +function formatProcessCollectionPayload( + payload: Record +): KnownPayloadPreview | null { + const rawProcesses = + (Array.isArray(payload.processes) ? payload.processes : null) ?? + (Array.isArray(payload.items) ? payload.items : null); + if (rawProcesses) { + const processes = rawProcesses + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)); + const processSummaries = processes + .slice(0, 3) + .map((process) => { + const label = + stringField(process, 'label') ?? + stringField(process, 'name') ?? + stringField(process, 'command') ?? + stringField(process, 'pid'); + const status = stringField(process, 'status'); + if (label && status) return `${label} ${status}`; + return label ?? status; + }) + .filter(Boolean); + const remainingCount = Math.max(0, processes.length - processSummaries.length); + const moreText = remainingCount > 0 ? `; +${remainingCount} more` : ''; + const countText = `${processes.length} ${processes.length === 1 ? 'process' : 'processes'}`; + return { + title: 'Process list', + text: + processSummaries.length > 0 + ? `${countText} - ${processSummaries.join('; ')}${moreText}` + : countText, + }; + } + + const processCount = countArrayField(payload, ['processes', 'items']); + if (processCount != null) { + return { title: 'Process list', text: `${processCount} processes` }; + } + return null; +} + +function formatProcessCollectionArrayPayload(items: readonly unknown[]): KnownPayloadPreview { + const processes = items + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)); + const processSummaries = processes + .slice(0, 3) + .map((process) => { + const label = + stringField(process, 'label') ?? + stringField(process, 'name') ?? + stringField(process, 'command') ?? + stringifyPrimitive(process.pid); + const status = stringField(process, 'status'); + if (label && status) return `${label} ${status}`; + return label || status; + }) + .filter(Boolean); + const remainingCount = Math.max(0, processes.length - processSummaries.length); + const moreText = remainingCount > 0 ? `; +${remainingCount} more` : ''; + const countText = `${processes.length} ${processes.length === 1 ? 'process' : 'processes'}`; + return { + title: 'Process list', + text: + processSummaries.length > 0 + ? `${countText} - ${processSummaries.join('; ')}${moreText}` + : countText, + }; +} + function formatRelationshipPayload( payload: Record, fallbackInput?: Record | null @@ -766,6 +845,19 @@ function formatTaskToolPayload( } if (taskRef) return { title: 'Task created', text: `Created ${taskRef}` }; } + if (canonical === 'task_add') { + if (taskRef && taskSummary) return { title: 'Task added', text: `${taskRef}: ${taskSummary}` }; + if (taskRef) return { title: 'Task added', text: `Added ${taskRef}` }; + } + if (canonical === 'task_update') { + if (taskRef && status) return { title: 'Task updated', text: `${taskRef} -> ${status}` }; + if (taskRef && taskSummary) + return { title: 'Task updated', text: `${taskRef}: ${taskSummary}` }; + if (taskRef) return { title: 'Task updated', text: `Updated ${taskRef}` }; + } + if (canonical === 'task_delete') { + return taskRef ? { title: 'Task deleted', text: `Deleted ${taskRef}` } : null; + } if (canonical === 'task_list' || canonical === 'task_briefing') { const collectionText = formatTaskCollectionPayload(payload); if (collectionText) { @@ -856,17 +948,38 @@ function formatRuntimePayload( fallbackInput?: Record | null ): KnownPayloadPreview | null { const canonical = canonicalToolNameValue ?? ''; + const memberName = + stringField(payload, 'memberName') ?? + stringField(payload, 'fromMemberName') ?? + stringField(fallbackInput ?? undefined, 'memberName') ?? + stringField(fallbackInput ?? undefined, 'fromMemberName'); if (canonical === 'runtime_bootstrap_checkin') { - const memberName = - stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); return { title: 'Runtime check-in', text: memberName ? `${memberName} checked in` : 'Runtime checked in', }; } + if (canonical === 'runtime_heartbeat') { + return { + title: 'Runtime heartbeat', + text: memberName ? `${memberName} heartbeat` : 'Runtime heartbeat', + }; + } + if (canonical === 'runtime_task_event') { + const taskRef = taskRefFromPayload(payload, fallbackInput); + const event = + stringField(payload, 'event') ?? + stringField(payload, 'kind') ?? + stringField(fallbackInput ?? undefined, 'event') ?? + stringField(fallbackInput ?? undefined, 'kind'); + const actor = memberName ? `${memberName} ` : ''; + if (taskRef && event) { + return { title: 'Runtime task event', text: `${actor}${event} ${taskRef}` }; + } + if (taskRef) return { title: 'Runtime task event', text: `${actor}${taskRef}` }; + if (event) return { title: 'Runtime task event', text: `${actor}${event}` }; + } if (canonical === 'member_briefing') { - const memberName = - stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); return { title: 'Member briefing', text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', @@ -875,6 +988,45 @@ function formatRuntimePayload( return null; } +function formatWorkSyncPayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if (canonical !== 'member_work_sync_status' && canonical !== 'member_work_sync_report') { + return null; + } + + const state = + stringField(payload, 'state') ?? + stringField(payload, 'status') ?? + stringField(fallbackInput ?? undefined, 'state') ?? + stringField(fallbackInput ?? undefined, 'status'); + const memberName = + stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); + const rawTaskIds = Array.isArray(payload.taskIds) + ? payload.taskIds + : Array.isArray(fallbackInput?.taskIds) + ? fallbackInput.taskIds + : []; + const taskRefs = [ + ...new Set( + rawTaskIds + .map((taskId) => (typeof taskId === 'string' ? formatTaskRef(taskId) : null)) + .filter((taskRef): taskRef is string => Boolean(taskRef)) + ), + ].slice(0, 3); + const taskText = taskRefs.length > 0 ? ` ${taskRefs.join(', ')}` : ''; + const memberText = memberName ? `${memberName} ` : ''; + const stateText = state ? `${state}${taskText}` : `updated${taskText}`; + + return { + title: canonical === 'member_work_sync_status' ? 'Work sync status' : 'Work sync report', + text: `${memberText}${stateText}`.trim(), + }; +} + function formatErrorPayload(payload: Record): KnownPayloadPreview | null { if (unknownPayloadLooksLikeError(payload)) { return { title: 'Tool error', text: payloadErrorMessage(payload) ?? 'Tool reported failure' }; @@ -973,6 +1125,19 @@ function formatPlainToolResultStatus( if (!toolContext) { return null; } + if (toolContext.canonicalName === 'member_briefing') { + const memberMatch = /^member briefing for\s+([^\s]+)\s+on team\b/i.exec( + compactWhitespace(value) + ); + const memberName = + memberMatch?.[1] ?? + stringField(asRecord(toolContext.input), 'memberName') ?? + stringField(asRecord(toolContext.input), 'member'); + return { + title: 'Member briefing', + text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', + }; + } const normalized = compactWhitespace(value).toLowerCase(); if (!['ok', 'done', 'success', 'comment added', 'message sent'].includes(normalized)) { return null; @@ -986,12 +1151,35 @@ function formatPlainToolResultStatus( const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null; return text ? { title: 'Cross-team message', text } : null; } + if ( + toolContext.canonicalName === 'member_work_sync_status' || + toolContext.canonicalName === 'member_work_sync_report' + ) { + return formatWorkSyncPayload({}, toolContext.canonicalName, fallbackInput); + } return ( formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) ); } +function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null { + const compact = compactWhitespace(value); + if (!compact) { + return null; + } + + const looksLikeError = + /\bexecution failed\b/i.test(compact) || + /\bfailed without output\b/i.test(compact) || + /\btool\b[^.]{0,80}\bfailed\b/i.test(compact) || + /\btask not found\b/i.test(compact) || + /\bpermission denied\b/i.test(compact) || + /\b(error|exception|traceback)\s*:/i.test(compact); + + return looksLikeError ? { ...truncatePreview(compact, limit), title: 'Tool error' } : null; +} + function formatTaskToolInputPayload( canonicalToolNameValue: string, payload: Record @@ -1076,6 +1264,16 @@ function formatKnownPayloadPreview( if (runtimeText) { return runtimeText; } + const workSyncText = formatWorkSyncPayload(payload, canonical, fallbackInput); + if (workSyncText) { + return workSyncText; + } + if (canonical === 'process_list') { + const processText = formatProcessCollectionPayload(payload); + if (processText) { + return processText; + } + } if (canonical === 'cross_team_send') { const crossTeamText = formatCrossTeamPayload(payload); if (crossTeamText) { @@ -1113,6 +1311,10 @@ function previewUnknownValue( if (known) { return { ...truncatePreview(known.text, limit), title: known.title }; } + const plainError = formatPlainToolErrorText(value, limit); + if (plainError) { + return plainError; + } const plainStatus = formatPlainToolResultStatus(value, toolContext); if (plainStatus) { return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; @@ -1138,6 +1340,10 @@ function previewUnknownValue( if (knownCollection) { return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title }; } + if (toolContext?.canonicalName === 'process_list') { + const processCollection = formatProcessCollectionArrayPayload(value); + return { ...truncatePreview(processCollection.text, limit), title: processCollection.title }; + } const parts = value .slice(0, 3) .map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview) @@ -1192,6 +1398,14 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): } const payload = recordFromUnknown(value); if (payload) { + const runtimeFormatted = formatRuntimePayload(payload, canonical, payload); + if (runtimeFormatted) { + return truncatePreview(runtimeFormatted.text, limit); + } + const workSyncFormatted = formatWorkSyncPayload(payload, canonical, payload); + if (workSyncFormatted) { + return truncatePreview(workSyncFormatted.text, limit); + } const taskFormatted = formatTaskToolInputPayload(canonical, payload); if (taskFormatted) { return truncatePreview(taskFormatted, limit); From 8bc460721f7ca46edab7b7cdbb2f7ddc66680192 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 19:25:34 +0300 Subject: [PATCH 10/83] fix(logs): summarize operational tool previews --- .../memberLogPreviewExtractor.test.ts | 143 +++++++++ .../policies/memberLogPreviewExtractor.ts | 298 +++++++++++++++++- 2 files changed, 427 insertions(+), 14 deletions(-) diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index 10d7b344..7501fd9a 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -1257,6 +1257,149 @@ Reply to this comment using MCP tool task_add_comment. preview: '2 processes - vite dev running; pnpm test exited', }); expect(processResult.items[0]?.preview).not.toContain('[{'); + + const taskUpdateResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'task-update-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-task-update', + name: 'agent-teams_task_update', + input: { + taskId: 'abc12345-0000-0000-0000-000000000000', + status: 'in_progress', + }, + }, + ], + }), + message({ + uuid: 'task-update-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-task-update', + content: { + taskId: 'abc12345-0000-0000-0000-000000000000', + status: 'in_progress', + }, + }, + ], + }), + ], + }); + + expect(taskUpdateResult.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Task updated', + preview: '#abc12345 -> in_progress', + }); + + const remainingOperationalTools = [ + { + toolName: 'agent-teams_lead_briefing', + input: { teamName: 'relay-works-10' }, + result: 'Lead briefing for team relay-works-10. CRITICAL: hidden rules', + expectedTitle: 'Lead briefing', + expectedPreview: 'Loaded lead briefing for relay-works-10', + }, + { + toolName: 'agent-teams_runtime_deliver_message', + input: { to: 'bob', text: 'Follow-up ready', runtimeSessionId: 'ses-secret' }, + result: 'ok', + expectedTitle: 'Runtime delivery', + expectedPreview: 'Delivered to bob - Follow-up ready', + }, + { + toolName: 'agent-teams_cross_team_list_targets', + input: { teamName: 'relay-works-10' }, + result: JSON.stringify([{ teamName: 'qa-team' }, { name: 'design-team' }]), + expectedTitle: 'Cross-team targets', + expectedPreview: '2 teams - qa-team; design-team', + }, + { + toolName: 'agent-teams_cross_team_get_outbox', + input: { teamName: 'relay-works-10' }, + result: { + messages: [{ toTeam: 'qa-team', summary: 'Need smoke-test help' }], + }, + expectedTitle: 'Cross-team outbox', + expectedPreview: '1 message - to qa-team: Need smoke-test help', + }, + { + toolName: 'agent-teams_process_register', + input: { label: 'vite dev', command: 'pnpm dev' }, + result: { process: { label: 'vite dev', status: 'running' } }, + expectedTitle: 'Process registered', + expectedPreview: 'Registered vite dev running', + }, + { + toolName: 'agent-teams_process_stop', + input: { label: 'vite dev' }, + result: 'ok', + expectedTitle: 'Process stopped', + expectedPreview: 'Stopped vite dev', + }, + { + toolName: 'agent-teams_process_unregister', + input: { pid: 123 }, + result: 'ok', + expectedTitle: 'Process unregistered', + expectedPreview: 'Unregistered 123', + }, + ] as const; + + for (const [index, tool] of remainingOperationalTools.entries()) { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: `remaining-tool-call-${index}`, + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: `tool-remaining-${index}`, + name: tool.toolName, + input: tool.input, + }, + ], + }), + message({ + uuid: `remaining-tool-result-${index}`, + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: `tool-remaining-${index}`, + content: tool.result, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: tool.expectedTitle, + preview: tool.expectedPreview, + }); + expect(`${result.items[0]?.title ?? ''} ${result.items[0]?.preview ?? ''}`).not.toMatch( + /CRITICAL|runtimeSessionId|agent_teams_|process_register|cross_team_/i + ); + } }); it('uses concrete names for generic runtime tool results', () => { diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 754f43fb..1f6e3381 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -136,20 +136,24 @@ function timestampIso(value: Date | string): string { function stripAngleTags(value: string): string { let result = ''; let insideTag = false; - for (const char of value) { - if (char === '<') { - insideTag = true; - result += ' '; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (!insideTag && char === '<') { + const next = value[index + 1] ?? ''; + if (/[A-Za-z/!]/.test(next)) { + insideTag = true; + result += ' '; + continue; + } + } + if (insideTag) { + if (char === '>') { + insideTag = false; + result += ' '; + } continue; } - if (char === '>') { - insideTag = false; - result += ' '; - continue; - } - if (!insideTag) { - result += char; - } + result += char; } return result; } @@ -468,6 +472,8 @@ function formatToolTitle(toolName: string): string { const canonical = canonicalToolName(toolName); if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; if (canonical === 'cross_team_send') return 'Cross-team message'; + if (canonical === 'cross_team_list_targets') return 'List teams'; + if (canonical === 'cross_team_get_outbox') return 'Cross-team outbox'; if (canonical === 'runtime_deliver_message') return 'Runtime delivery'; if (canonical === 'runtime_task_event') return 'Runtime task event'; if (canonical === 'runtime_heartbeat') return 'Runtime heartbeat'; @@ -492,6 +498,7 @@ function formatToolTitle(toolName: string): string { if (canonical === 'review_approve') return 'Approve review'; if (canonical === 'review_request_changes') return 'Request changes'; if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; + if (canonical === 'lead_briefing') return 'Lead briefing'; if (canonical === 'member_briefing') return 'Member briefing'; if (canonical === 'member_work_sync_status') return 'Work sync status'; if (canonical === 'member_work_sync_report') return 'Work sync report'; @@ -499,6 +506,9 @@ function formatToolTitle(toolName: string): string { if (canonical === 'task_update') return 'Update task'; if (canonical === 'task_delete') return 'Delete task'; if (canonical === 'process_list') return 'List processes'; + if (canonical === 'process_register') return 'Register process'; + if (canonical === 'process_stop') return 'Stop process'; + if (canonical === 'process_unregister') return 'Unregister process'; return humanizeFallbackToolName(toolName); } @@ -525,14 +535,16 @@ function isToolUseSupersededBySuccessResult(toolName: string): boolean { return ( canonical === 'sendmessage' || canonical === 'message_send' || - canonical === 'cross_team_send' || + canonical.startsWith('cross_team_') || canonical === 'runtime_deliver_message' || canonical === 'runtime_bootstrap_checkin' || canonical === 'runtime_heartbeat' || canonical === 'runtime_task_event' || + canonical === 'lead_briefing' || canonical === 'member_briefing' || canonical === 'member_work_sync_status' || canonical === 'member_work_sync_report' || + canonical.startsWith('process_') || canonical.startsWith('task_') || canonical.startsWith('review_') ); @@ -765,6 +777,172 @@ function formatProcessCollectionArrayPayload(items: readonly unknown[]): KnownPa }; } +function processLabelFromPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const process = asRecord(payload.process) ?? asRecord(fallbackInput?.process) ?? undefined; + return ( + [ + stringField(payload, 'label'), + stringField(payload, 'name'), + stringField(payload, 'command'), + stringField(payload, 'processId'), + stringField(payload, 'id'), + stringifyPrimitive(payload.pid), + stringField(process, 'label'), + stringField(process, 'name'), + stringField(process, 'command'), + stringField(process, 'processId'), + stringField(process, 'id'), + stringifyPrimitive(process?.pid), + stringField(fallbackInput ?? undefined, 'label'), + stringField(fallbackInput ?? undefined, 'name'), + stringField(fallbackInput ?? undefined, 'command'), + stringField(fallbackInput ?? undefined, 'processId'), + stringField(fallbackInput ?? undefined, 'id'), + stringifyPrimitive(fallbackInput?.pid), + ].find((value) => typeof value === 'string' && value.trim().length > 0) ?? null + ); +} + +function formatProcessLifecyclePayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if ( + canonical !== 'process_register' && + canonical !== 'process_stop' && + canonical !== 'process_unregister' + ) { + return null; + } + + const label = processLabelFromPayload(payload, fallbackInput); + const status = + stringField(payload, 'status') ?? + stringField(asRecord(payload.process), 'status') ?? + stringField(fallbackInput ?? undefined, 'status'); + + if (canonical === 'process_register') { + const suffix = status && label ? ` ${status}` : ''; + return { + title: 'Process registered', + text: label ? `Registered ${label}${suffix}` : 'Registered process', + }; + } + if (canonical === 'process_stop') { + return { + title: 'Process stopped', + text: label ? `Stopped ${label}` : 'Stopped process', + }; + } + return { + title: 'Process unregistered', + text: label ? `Unregistered ${label}` : 'Unregistered process', + }; +} + +function formatCrossTeamTargetItem(item: Record): string | null { + return ( + stringField(item, 'teamName') ?? + stringField(item, 'name') ?? + stringField(item, 'id') ?? + stringField(item, 'slug') + ); +} + +function formatCrossTeamOutboxItem(item: Record): string | null { + const target = + stringField(item, 'toTeam') ?? + stringField(item, 'targetTeam') ?? + stringField(item, 'teamName') ?? + stringField(item, 'target'); + const summary = + stringField(item, 'summary') ?? + stringField(item, 'text') ?? + stringField(item, 'message') ?? + stringField(item, 'content'); + if (target && summary) return `to ${target}: ${summary}`; + return target ?? summary; +} + +function formatCrossTeamCollectionArrayPayload( + items: readonly unknown[], + canonicalToolNameValue: string | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + const title = + canonical === 'cross_team_list_targets' + ? 'Cross-team targets' + : canonical === 'cross_team_get_outbox' + ? 'Cross-team outbox' + : null; + if (!title) return null; + + const records = items + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)); + const summaries = records + .slice(0, 3) + .map((item) => + canonical === 'cross_team_list_targets' + ? formatCrossTeamTargetItem(item) + : formatCrossTeamOutboxItem(item) + ) + .filter(Boolean); + const remainingCount = Math.max(0, records.length - summaries.length); + const moreText = remainingCount > 0 ? `; +${remainingCount} more` : ''; + + if (canonical === 'cross_team_list_targets') { + const countText = `${records.length} ${records.length === 1 ? 'team' : 'teams'}`; + return { + title, + text: summaries.length > 0 ? `${countText} - ${summaries.join('; ')}${moreText}` : countText, + }; + } + + const countText = `${records.length} ${records.length === 1 ? 'message' : 'messages'}`; + return { + title, + text: summaries.length > 0 ? `${countText} - ${summaries.join('; ')}${moreText}` : countText, + }; +} + +function formatCrossTeamCollectionPayload( + payload: Record, + canonicalToolNameValue: string | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if (canonical !== 'cross_team_list_targets' && canonical !== 'cross_team_get_outbox') { + return null; + } + + const rawItems = + (Array.isArray(payload.targets) ? payload.targets : null) ?? + (Array.isArray(payload.teams) ? payload.teams : null) ?? + (Array.isArray(payload.messages) ? payload.messages : null) ?? + (Array.isArray(payload.outbox) ? payload.outbox : null) ?? + (Array.isArray(payload.items) ? payload.items : null); + if (rawItems) { + return formatCrossTeamCollectionArrayPayload(rawItems, canonical); + } + + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'message') ?? + stringField(payload, 'text'); + if (summary) { + return { + title: canonical === 'cross_team_list_targets' ? 'Cross-team targets' : 'Cross-team outbox', + text: summary, + }; + } + return null; +} + function formatRelationshipPayload( payload: Record, fallbackInput?: Record | null @@ -959,6 +1137,26 @@ function formatRuntimePayload( text: memberName ? `${memberName} checked in` : 'Runtime checked in', }; } + if (canonical === 'runtime_deliver_message') { + const target = + stringField(payload, 'to') ?? + stringField(payload, 'target') ?? + stringField(fallbackInput ?? undefined, 'to') ?? + stringField(fallbackInput ?? undefined, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'message') ?? + stringField(payload, 'text') ?? + stringField(fallbackInput ?? undefined, 'summary') ?? + stringField(fallbackInput ?? undefined, 'message') ?? + stringField(fallbackInput ?? undefined, 'text'); + if (target && summary) { + return { title: 'Runtime delivery', text: `Delivered to ${target} - ${summary}` }; + } + if (target) return { title: 'Runtime delivery', text: `Delivered to ${target}` }; + if (summary) return { title: 'Runtime delivery', text: summary }; + return { title: 'Runtime delivery', text: 'Delivered runtime message' }; + } if (canonical === 'runtime_heartbeat') { return { title: 'Runtime heartbeat', @@ -985,6 +1183,14 @@ function formatRuntimePayload( text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', }; } + if (canonical === 'lead_briefing') { + const teamName = + stringField(payload, 'teamName') ?? stringField(fallbackInput ?? undefined, 'teamName'); + return { + title: 'Lead briefing', + text: teamName ? `Loaded lead briefing for ${teamName}` : 'Loaded lead briefing', + }; + } return null; } @@ -1125,14 +1331,25 @@ function formatPlainToolResultStatus( if (!toolContext) { return null; } - if (toolContext.canonicalName === 'member_briefing') { + if ( + toolContext.canonicalName === 'member_briefing' || + toolContext.canonicalName === 'lead_briefing' + ) { const memberMatch = /^member briefing for\s+([^\s]+)\s+on team\b/i.exec( compactWhitespace(value) ); + const teamMatch = /^lead briefing for team\s+([^\s.]+)/i.exec(compactWhitespace(value)); const memberName = memberMatch?.[1] ?? stringField(asRecord(toolContext.input), 'memberName') ?? stringField(asRecord(toolContext.input), 'member'); + if (toolContext.canonicalName === 'lead_briefing') { + const teamName = teamMatch?.[1] ?? stringField(asRecord(toolContext.input), 'teamName'); + return { + title: 'Lead briefing', + text: teamName ? `Loaded lead briefing for ${teamName}` : 'Loaded lead briefing', + }; + } return { title: 'Member briefing', text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', @@ -1151,12 +1368,30 @@ function formatPlainToolResultStatus( const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null; return text ? { title: 'Cross-team message', text } : null; } + if (toolContext.canonicalName === 'cross_team_list_targets') { + const teamName = stringField(fallbackInput ?? undefined, 'teamName'); + return { + title: 'Cross-team targets', + text: teamName ? `Listed teams for ${teamName}` : 'Listed cross-team targets', + }; + } + if (toolContext.canonicalName === 'cross_team_get_outbox') { + const teamName = stringField(fallbackInput ?? undefined, 'teamName'); + return { + title: 'Cross-team outbox', + text: teamName ? `Loaded outbox for ${teamName}` : 'Loaded cross-team outbox', + }; + } if ( toolContext.canonicalName === 'member_work_sync_status' || toolContext.canonicalName === 'member_work_sync_report' ) { return formatWorkSyncPayload({}, toolContext.canonicalName, fallbackInput); } + const processText = formatProcessLifecyclePayload({}, toolContext.canonicalName, fallbackInput); + if (processText) { + return processText; + } return ( formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) @@ -1268,6 +1503,14 @@ function formatKnownPayloadPreview( if (workSyncText) { return workSyncText; } + const processLifecycleText = formatProcessLifecyclePayload(payload, canonical, fallbackInput); + if (processLifecycleText) { + return processLifecycleText; + } + const crossTeamCollectionText = formatCrossTeamCollectionPayload(payload, canonical); + if (crossTeamCollectionText) { + return crossTeamCollectionText; + } if (canonical === 'process_list') { const processText = formatProcessCollectionPayload(payload); if (processText) { @@ -1340,6 +1583,16 @@ function previewUnknownValue( if (knownCollection) { return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title }; } + const crossTeamCollection = formatCrossTeamCollectionArrayPayload( + value, + toolContext?.canonicalName ?? null + ); + if (crossTeamCollection) { + return { + ...truncatePreview(crossTeamCollection.text, limit), + title: crossTeamCollection.title, + }; + } if (toolContext?.canonicalName === 'process_list') { const processCollection = formatProcessCollectionArrayPayload(value); return { ...truncatePreview(processCollection.text, limit), title: processCollection.title }; @@ -1396,6 +1649,19 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): return truncatePreview(formatted, limit); } } + if (canonical === 'cross_team_list_targets' || canonical === 'cross_team_get_outbox') { + const payload = recordFromUnknown(value); + const teamName = payload ? stringField(payload, 'teamName') : null; + const text = + canonical === 'cross_team_list_targets' + ? teamName + ? `for ${teamName}` + : 'List cross-team targets' + : teamName + ? `for ${teamName}` + : 'Read cross-team outbox'; + return truncatePreview(text, limit); + } const payload = recordFromUnknown(value); if (payload) { const runtimeFormatted = formatRuntimePayload(payload, canonical, payload); @@ -1406,6 +1672,10 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): if (workSyncFormatted) { return truncatePreview(workSyncFormatted.text, limit); } + const processFormatted = formatProcessLifecyclePayload(payload, canonical, payload); + if (processFormatted) { + return truncatePreview(processFormatted.text, limit); + } const taskFormatted = formatTaskToolInputPayload(canonical, payload); if (taskFormatted) { return truncatePreview(taskFormatted, limit); From e777610baae292f0660a6df28cc232fd15ec9daf Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 19:32:27 +0300 Subject: [PATCH 11/83] test(sync): allow slower windows reconciliation --- .../member-work-sync/main/createMemberWorkSyncFeature.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 80b3813d..f74d8760 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -180,7 +180,7 @@ async function seedBlockingShadowCollectingMetrics(input: { } async function waitForAssertion(assertion: () => Promise | void): Promise { - const deadline = Date.now() + 2_000; + const deadline = Date.now() + 5_000; let lastError: unknown; while (Date.now() < deadline) { try { From 7c39fbd8e72594c12a1d7edbd9dbe9469341ae38 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 19:54:31 +0300 Subject: [PATCH 12/83] fix(logs): refine graph preview readability --- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 22 +- .../memberLogPreviewExtractor.test.ts | 203 +++++++++++++++++- .../policies/memberLogPreviewExtractor.ts | 157 ++++++-------- 3 files changed, 280 insertions(+), 102 deletions(-) diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index fccf53f5..a0bd4002 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -28,8 +28,8 @@ import type { const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_HEIGHT = 292; const NEW_LOG_HIGHLIGHT_MS = 1_000; -const COMPACT_ROW_TEXT_LIMIT = 92; -const COMPACT_ROW_MIN_PREVIEW_LIMIT = 48; +const COMPACT_ROW_TEXT_LIMIT = 76; +const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40; interface StableRectLike { left: number; @@ -434,18 +434,18 @@ export const GraphMemberLogPreviewHud = ({ ? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30' : 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]'; const iconClassName = isError - ? 'float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10' - : 'float-left mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5'; - const headerClassName = 'inline-flex h-5 items-center align-top'; + ? 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10' + : 'float-left mr-2 mt-px inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5'; + const headerClassName = 'inline align-baseline'; const titleClassName = isError - ? 'text-[11px] font-medium leading-[18px] text-rose-100' - : 'text-[11px] font-medium leading-[18px] text-slate-200'; + ? 'align-baseline text-[11px] font-medium leading-[18px] text-rose-100' + : 'align-baseline text-[11px] font-medium leading-[18px] text-slate-200'; const timeClassName = isError - ? 'ml-1 text-[9px] font-normal leading-[18px] text-rose-300/70' - : 'ml-1 text-[9px] font-normal leading-[18px] text-slate-500'; + ? 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-rose-300/70' + : 'ml-1 align-baseline text-[9px] font-normal leading-[18px] text-slate-500'; const previewClassName = isError - ? 'ml-1 break-words align-top text-[10px] leading-[18px] text-rose-100/85' - : 'ml-1 break-words align-top text-[10px] leading-[18px] text-slate-300/85'; + ? 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-rose-100/85' + : 'ml-1 break-words align-baseline text-[10px] leading-[18px] text-slate-300/85'; return ( ) : null}
{hasDuplicates ? (

From 8d06ee81c25fe48e47aa1c0c2b241b1b043c9b41 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 20:58:40 +0300 Subject: [PATCH 15/83] fix(team): stabilize launch previews and codex reconnect --- .../hooks/useGraphMemberLogPreviews.ts | 15 +- .../CodexLoginSessionManager.ts | 3 - .../memberLogPreviewExtractor.test.ts | 145 +++++++++++++++++- .../policies/memberLogPreviewExtractor.ts | 87 ++++++++++- .../services/team/TeamProvisioningService.ts | 7 +- .../components/dashboard/CliStatusBanner.tsx | 13 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 2 +- .../team/dialogs/CodexReconnectPrompt.tsx | 15 +- .../team/dialogs/CreateTeamDialog.tsx | 11 +- .../team/dialogs/LaunchTeamDialog.tsx | 11 +- .../team/TeamProvisioningService.test.ts | 50 ++++++ .../useGraphMemberLogPreviews.test.tsx | 72 +++++++++ 12 files changed, 382 insertions(+), 49 deletions(-) diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts index 7eddba2d..8630f328 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -184,6 +184,7 @@ export function useGraphMemberLogPreviews(input: { const cacheRef = useRef(new Map()); const previewsByMemberRef = useRef(previewsByMember); const inFlightRef = useRef(new Map>>()); + const activeRequestKeyByMemberRef = useRef(new Map()); const reloadTimerRef = useRef | null>(null); const teamNameRef = useRef(input.teamName); @@ -196,6 +197,7 @@ export function useGraphMemberLogPreviews(input: { teamNameRef.current = input.teamName; cacheRef.current.clear(); inFlightRef.current.clear(); + activeRequestKeyByMemberRef.current.clear(); setPreviewsByMember(new Map()); } if (!enabled || memberNames.length === 0) { @@ -261,6 +263,9 @@ export function useGraphMemberLogPreviews(input: { forceRefresh: options?.forceRefresh, }); const requestTeamName = input.teamName; + for (const memberName of membersToRequest) { + activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey); + } if (!options?.background && hasMissingPreview) { setLoading(true); @@ -310,7 +315,15 @@ export function useGraphMemberLogPreviews(input: { if (teamNameRef.current !== requestTeamName) { return; } - setPreviewsByMember((current) => mergeMemberPreviews(current, members.values())); + const currentMembers = Array.from(members.values()).filter((member) => { + return ( + activeRequestKeyByMemberRef.current.get(normalizeMemberName(member.memberName)) === + requestKey + ); + }); + if (currentMembers.length > 0) { + setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers)); + } setError(null); } catch (loadError) { if (teamNameRef.current !== requestTeamName) { diff --git a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts index 81551872..a5284c6a 100644 --- a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts +++ b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts @@ -4,7 +4,6 @@ import { type CodexAppServerLoginAccountResponse, type CodexAppServerSession, } from '@main/services/infrastructure/codexAppServer'; -import { shell } from 'electron'; import type { CodexLoginStateDto } from '@features/codex-account/contracts'; import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer'; @@ -139,8 +138,6 @@ export class CodexLoginSessionManager { startedAt: this.state.startedAt, authUrl: authUrl.toString(), }); - - await shell.openExternal(authUrl.toString()); } catch (error) { const wasAbandonedDuringStart = this.pendingStartToken !== startToken && diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index e5c0ae5d..89479bca 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -537,8 +537,7 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items[0]).toMatchObject({ kind: 'tool_result', title: 'Read task error', - preview: - "Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f", + preview: "Tool 'task_get' execution failed: Task not found: 211e430b", tone: 'error', }); }); @@ -638,6 +637,39 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items).toHaveLength(1); }); + it('does not repeat generic comment titles in compact previews', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: JSON.stringify({ + comment: { + text: 'Focused checks passed.', + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment', + preview: 'Focused checks passed.', + }); + }); + it('distinguishes read-comment results from add-comment results', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', @@ -1694,6 +1726,115 @@ Reply to this comment using MCP tool task_add_comment. }); }); + it('cleans tagged file tool output and shortens absolute paths for compact rows', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'read-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-read', + name: 'Read', + input: { + file_path: '/Users/belief/dev/projects/demo/app/page.tsx', + }, + }, + ], + }), + message({ + uuid: 'read-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-read', + content: `/Users/belief/dev/projects/demo/app/page.tsx +file + +1: export default function Page() { +2: return null; +3: } + +(End of file - total 3 lines) +`, + }, + ], + }), + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Read result', + preview: 'demo/app/page.tsx - export default function Page() { return null; }', + }); + expect(result.items[0]?.preview).not.toContain('/Users/belief'); + expect(result.items[0]?.preview).not.toContain(''); + expect(result.items[0]?.preview).not.toContain('1:'); + }); + + it('cleans tagged directory tool output without repeating absolute paths', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'read-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-read', + name: 'Read', + input: { + file_path: '/Users/belief/dev/projects/demo', + }, + }, + ], + }), + message({ + uuid: 'read-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-read', + content: `/Users/belief/dev/projects/demo +directory + +app/ +package.json +README.md + +(3 entries) +`, + }, + ], + }), + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Read result', + preview: 'demo - directory app/ package.json README.md', + }); + expect(result.items[0]?.preview).not.toContain('/Users/belief'); + expect(result.items[0]?.preview).not.toContain('(3 entries)'); + }); + it('does not label arbitrary message fields as sent messages', () => { const result = extractMemberLogPreviewItems({ provider: 'opencode_runtime', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 92c62611..f24c8a09 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -191,8 +191,15 @@ function parseJsonLikeString(value: string): unknown { } } +function shortenLongIdsForPreview(value: string): string { + return value.replace( + /\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + '$1' + ); +} + function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } { - const compact = compactWhitespace(removeHiddenInstructionBlocks(value)); + const compact = shortenLongIdsForPreview(compactWhitespace(removeHiddenInstructionBlocks(value))); if (compact.length <= limit) { return { preview: compact, truncated: false }; } @@ -533,6 +540,30 @@ function formatShellResultContext(toolContext: ToolUseContext | undefined): stri return stringField(input, 'description') ?? stringField(input, 'command'); } +function shortenPathForPreview(value: string): string { + const compact = compactWhitespace(value); + if (!compact) { + return ''; + } + const normalized = compact.replace(/\\/g, '/'); + if (!normalized.startsWith('/') && normalized.length <= 56) { + return normalized; + } + const parts = normalized.split('/').filter(Boolean); + if (parts.length <= 3) { + return normalized.startsWith('/') ? parts.join('/') : normalized; + } + const projectsIndex = parts.lastIndexOf('projects'); + if (projectsIndex >= 0 && projectsIndex < parts.length - 1) { + const projectRelative = parts.slice(projectsIndex + 1); + if (projectRelative.length <= 4) { + return projectRelative.join('/'); + } + } + const tail = parts.slice(-3); + return tail[0] === 'projects' ? parts.slice(-2).join('/') : tail.join('/'); +} + function addContextToSuccessResultPreview( preview: ValuePreview, context: string | null, @@ -580,15 +611,16 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s stringField(input, 'filePath') ?? stringField(input, 'path') ?? stringField(input, 'cwd'); + const compactPath = path ? shortenPathForPreview(path) : null; if (toolContext.canonicalName === 'grep') { const query = stringField(input, 'query') ?? stringField(input, 'pattern'); - if (query && path) return `${query} in ${path}`; - return query ?? path; + if (query && compactPath) return `${query} in ${compactPath}`; + return query ?? compactPath; } if (toolContext.canonicalName === 'glob') { const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob'); - if (pattern && path) return `${pattern} in ${path}`; - return pattern ?? path; + if (pattern && compactPath) return `${pattern} in ${compactPath}`; + return pattern ?? compactPath; } if ( toolContext.canonicalName === 'read' || @@ -596,7 +628,7 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s toolContext.canonicalName === 'edit' || toolContext.canonicalName === 'ls' ) { - return path; + return compactPath; } return null; } @@ -733,7 +765,7 @@ function formatTaskCommentPayload( if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`; if (author) return `Comment by ${author}: ${commentText}`; if (taskRef) return `Comment on ${taskRef}: ${commentText}`; - return `Comment: ${commentText}`; + return commentText; } function countArrayField(payload: Record, keys: readonly string[]): number | null { @@ -1485,6 +1517,34 @@ function formatPlainToolResultStatus( ); } +function taggedSection(value: string, tag: string): string | null { + const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(value); + return match?.[1]?.trim() || null; +} + +function stripTaggedFileLineNumbers(value: string): string { + return value + .replace(/\(End of file - total \d+ lines?\)/gi, ' ') + .replace(/\(\d+ entries\)/gi, ' ') + .split(/\r?\n/) + .map((line) => line.replace(/^\s*\d+:\s*/, '').trim()) + .filter(Boolean) + .join(' '); +} + +function formatTaggedFileToolResult(value: string): string | null { + const content = taggedSection(value, 'content') ?? taggedSection(value, 'entries'); + if (!content) { + return null; + } + const type = taggedSection(value, 'type')?.toLowerCase(); + const body = compactWhitespace(stripTaggedFileLineNumbers(content)); + if (!body) { + return null; + } + return type === 'directory' ? `directory ${body}` : body; +} + function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null { const compact = compactWhitespace(removeHiddenInstructionBlocks(value)); if (!compact) { @@ -1649,6 +1709,10 @@ function previewUnknownValue( if (plainStatus) { return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; } + const taggedFileResult = formatTaggedFileToolResult(value); + if (taggedFileResult) { + return truncatePreview(taggedFileResult, limit); + } const parsed = parseJsonLikeString(value); if (parsed != null) { return previewUnknownValue(parsed, limit, priorityKeys, toolContext); @@ -1749,6 +1813,15 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): : 'Read cross-team outbox'; return truncatePreview(text, limit); } + const fileToolContext = formatFileToolResultContext({ + id: '', + name: toolName, + canonicalName: canonical, + input: value, + }); + if (fileToolContext) { + return truncatePreview(fileToolContext, limit); + } const payload = recordFromUnknown(value); if (payload) { const runtimeFormatted = formatRuntimePayload(payload, canonical, payload); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 79ca7d04..34b76553 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -16230,6 +16230,7 @@ export class TeamProvisioningService { run.child.stderr?.removeAllListeners('data'); run.child.removeAllListeners('error'); run.child.removeAllListeners('exit'); + run.child.removeAllListeners('close'); killTeamProcess(run.child); run.child = null; } @@ -16469,7 +16470,7 @@ export class TeamProvisioningService { this.cleanupRun(run); }); - child.once('exit', (code) => { + child.once('close', (code) => { void this.handleProcessExit(run, code); }); } @@ -17083,7 +17084,7 @@ export class TeamProvisioningService { this.cleanupRun(run); }); - child.once('exit', (code) => { + child.once('close', (code) => { void this.handleProcessExit(run, code); }); @@ -18381,7 +18382,7 @@ export class TeamProvisioningService { this.cleanupRun(run); }); - child.once('exit', (code) => { + child.once('close', (code) => { void this.handleProcessExit(run, code); }); diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 76e3d431..93e9d036 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -908,7 +908,7 @@ const InstalledBanner = ({ color: '#fbbf24', }} > - {codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'} + {codexLoginAuthUrl ? 'Open login' : 'Generate link'} ) : null} @@ -1153,16 +1153,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const handleCodexDashboardLogin = useCallback(() => { void (async () => { - const success = await codexAccount.startChatgptLogin(); - if (success) { - await refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - } + await codexAccount.startChatgptLogin(); })(); - }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + }, [codexAccount]); const recheckAuthState = useCallback(() => { setIsVerifyingAuth(true); diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index cd91af77..e8c72d7f 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -1439,7 +1439,7 @@ export const ProviderRuntimeSettingsDialog = ({ onClick={() => void handleCodexStartLogin()} > - {codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'} + {codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'} )} diff --git a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx index 4a5b502f..56842c96 100644 --- a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx +++ b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; +import { api } from '@renderer/api'; import { LogIn } from 'lucide-react'; import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList'; @@ -78,13 +79,19 @@ export const CodexReconnectPrompt = ({ >

- Codex found the local ChatGPT account, but this session is stale. Reconnect ChatGPT, then - finish login in the browser and retry this dialog. + Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT, + then finish login in the browser and retry this dialog.

diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 373e1640..4c876893 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -713,16 +713,9 @@ export const CreateTeamDialog = ({ const handleCodexReconnect = useCallback(() => { void (async () => { - const success = await codexAccount.startChatgptLogin(); - if (success) { - await refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - } + await codexAccount.startChatgptLogin(); })(); - }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + }, [codexAccount]); useEffect(() => { if (!open || !canCreate || !launchTeam) { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 121cd604..e243557c 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -590,16 +590,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const handleCodexReconnect = React.useCallback(() => { void (async () => { - const success = await codexAccount.startChatgptLogin(); - if (success) { - await refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - } + await codexAccount.startChatgptLogin(); })(); - }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + }, [codexAccount]); // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e9315099..653ddc23 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -12356,6 +12356,56 @@ describe('TeamProvisioningService', () => { await svc.cancelProvisioning(runId); }); + it('waits for child close before handling launch process exit so stream-json can drain', async () => { + allowConsoleLogs(); + const teamName = 'launch-close-drains-stdout-team'; + const leadSessionId = 'lead-session-close-drain'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + const child = createRunningChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + const handleProcessExit = vi + .spyOn(svc as any, 'handleProcessExit') + .mockResolvedValue(undefined); + + const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); + + child.emit('exit', 0); + await Promise.resolve(); + expect(handleProcessExit).not.toHaveBeenCalled(); + + child.emit('close', 0); + await vi.waitFor(() => expect(handleProcessExit).toHaveBeenCalledTimes(1)); + expect(handleProcessExit.mock.calls[0]?.[1]).toBe(0); + + await svc.cancelProvisioning(runId); + }); + it('clears stale team-scoped transient state before starting a new launch run', async () => { allowConsoleLogs(); vi.useFakeTimers(); diff --git a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx index f7008511..7d8b3735 100644 --- a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx +++ b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx @@ -337,6 +337,78 @@ describe('useGraphMemberLogPreviews', () => { }); }); + it('ignores stale responses when the same member receives a newer lane request', async () => { + const oldLaneLoad = createDeferred(); + const newLaneLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockReturnValueOnce(oldLaneLoad.promise) + .mockReturnValueOnce(newLaneLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + newLaneLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:01:00.000Z' + ); + + await act(async () => { + oldLaneLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:01:00.000Z' + ); + + act(() => { + root.unmount(); + }); + }); + it('reloads visible members on log-source events with force refresh', async () => { let teamChangeListener: | ((event: unknown, data: { teamName: string; type: string }) => void) From 5730ddc7af71c6713b3574a27c99c46b6509f2ad Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 21:18:39 +0300 Subject: [PATCH 16/83] fix(team): harden codex login and runtime previews --- README.md | 13 + docs/team-management/README.md | 1 + docs/team-management/debugging-agent-teams.md | 28 +++ .../hooks/useGraphMemberLogPreviews.ts | 2 +- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 34 ++- src/features/codex-account/contracts/dto.ts | 1 + .../composition/createCodexAccountFeature.ts | 1 + .../CodexLoginSessionManager.ts | 22 +- .../infrastructure/codexAppServer/protocol.ts | 9 + .../services/team/TeamProvisioningService.ts | 146 +++++++---- src/main/services/team/runtimeTeammateMode.ts | 42 ++-- .../components/dashboard/CliStatusBanner.tsx | 12 +- .../components/dashboard/TmuxStatusBanner.tsx | 8 +- .../runtime/CodexLoginLinkCopyButton.tsx | 41 +++- .../runtime/ProviderRuntimeSettingsDialog.tsx | 10 +- .../team/dialogs/CodexReconnectPrompt.tsx | 17 +- .../team/dialogs/CreateTeamDialog.tsx | 1 + .../team/dialogs/LaunchTeamDialog.tsx | 1 + .../main/CodexLoginSessionManager.test.ts | 10 +- .../team/TeamProvisioningService.test.ts | 128 ++++++++++ .../services/team/runtimeTeammateMode.test.ts | 61 +++++ .../cli/CliStatusVisibility.test.ts | 2 +- .../ProviderRuntimeSettingsDialog.test.ts | 2 +- .../GraphMemberLogPreviewHud.test.tsx | 231 ++++++++++++++++++ .../useGraphMemberLogPreviews.test.tsx | 15 +- 25 files changed, 735 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index d771585a..f3e1989f 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,19 @@ pnpm dev The app auto-discovers Claude Code projects from `~/.claude/`. +### Debug teammate runtimes + +Development launches use the app-managed process backend for teammates by default. To inspect +teammates in `tmux` panes while debugging, start the desktop app with: + +```bash +CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev +``` + +The same override is available per launch from custom CLI args with +`--teammate-mode tmux`. Use this as an operator/debug mode; the default process backend provides +stronger app-owned lifecycle, diagnostics, and cleanup for normal team launches. + ### Build for distribution ```bash diff --git a/docs/team-management/README.md b/docs/team-management/README.md index 587c378b..3a8d06fb 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -22,6 +22,7 @@ | [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API | | [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) | | [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync | +| [debugging-agent-teams.md](./debugging-agent-teams.md) | Runtime debugging runbook, включая `CLAUDE_TEAM_TEAMMATE_MODE=tmux` для pane-backed teammate debug | ## Ключевые решения diff --git a/docs/team-management/debugging-agent-teams.md b/docs/team-management/debugging-agent-teams.md index a6baac42..cf48676f 100644 --- a/docs/team-management/debugging-agent-teams.md +++ b/docs/team-management/debugging-agent-teams.md @@ -52,6 +52,34 @@ Primary launch and OpenCode secondary lanes are different paths. When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member. +## Teammate Runtime Debug Mode + +Desktop launches use the app-managed process backend by default. That is the supported default for +normal app launches because the app owns the process lifecycle, runtime logs, cleanup, and bootstrap +evidence. + +For local debugging, force pane-backed teammates through `tmux`: + +```bash +CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev +``` + +For a single launch from the UI, add this to custom CLI args: + +```bash +--teammate-mode tmux +``` + +Expected behavior: +- `tmux` mode should remove `CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES` from the launch env. +- The desktop app should pass `--teammate-mode tmux` to the runtime CLI. +- The orchestrator should report `backend_type: "tmux"` and `tmux_pane_id` like `%1`. +- If `tmux` is unavailable, the launch dialog should block explicit tmux mode with a tmux readiness message. + +Use this mode to inspect interactive CLI behavior, terminal prompts, and pane output. Do not treat it +as equivalent to the process backend for recovery semantics; persisted pane IDs can help discovery, +but app restart does not make old panes a fully app-owned runtime again. + ## Member State Meanings Common `launch-state.json` cases: diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts index 8630f328..65be098e 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -382,7 +382,7 @@ export function useGraphMemberLogPreviews(input: { return; } if (event.type === 'task-log-change') { - scheduleReload(false); + scheduleReload(true); } }); diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index a0bd4002..76fd117b 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -62,6 +62,10 @@ function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } +function buildRenderedItemKey(memberName: string, itemId: string): string { + return `${normalizeMemberName(memberName)}:${itemId}`; +} + function formatRelativeTime(timestamp: string): string { const parsed = Date.parse(timestamp); if (!Number.isFinite(parsed)) return ''; @@ -145,7 +149,11 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string { function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string { const preview = item.preview?.trim(); if (preview) { - const compact = trimRepeatedTitlePrefix(preview, displayTitle); + const rawTitle = item.title.trim(); + const compact = trimRepeatedTitlePrefix( + trimRepeatedTitlePrefix(preview, rawTitle), + displayTitle + ); return compact || preview; } if (item.kind === 'tool_result') { @@ -244,45 +252,45 @@ export const GraphMemberLogPreviewHud = ({ useEffect(() => { if (!enabled) return; - const newItemIds: string[] = []; + const newItemKeys: string[] = []; for (const [memberKey, preview] of previewsByMember) { const currentIds = new Set(preview.items.map((item) => item.id)); const knownIds = knownItemIdsByMemberRef.current.get(memberKey); if (knownIds) { for (const itemId of currentIds) { if (!knownIds.has(itemId)) { - newItemIds.push(itemId); + newItemKeys.push(buildRenderedItemKey(memberKey, itemId)); } } } knownItemIdsByMemberRef.current.set(memberKey, currentIds); } - if (newItemIds.length === 0) return; + if (newItemKeys.length === 0) return; setHighlightedItemIds((current) => { const next = new Set(current); - for (const itemId of newItemIds) { - next.add(itemId); + for (const itemKey of newItemKeys) { + next.add(itemKey); } return next; }); - for (const itemId of newItemIds) { - const existingTimer = highlightTimersRef.current.get(itemId); + for (const itemKey of newItemKeys) { + const existingTimer = highlightTimersRef.current.get(itemKey); if (existingTimer) { clearTimeout(existingTimer); } const timer = setTimeout(() => { - highlightTimersRef.current.delete(itemId); + highlightTimersRef.current.delete(itemKey); setHighlightedItemIds((current) => { - if (!current.has(itemId)) return current; + if (!current.has(itemKey)) return current; const next = new Set(current); - next.delete(itemId); + next.delete(itemKey); return next; }); }, NEW_LOG_HIGHLIGHT_MS); - highlightTimersRef.current.set(itemId, timer); + highlightTimersRef.current.set(itemKey, timer); } }, [enabled, previewsByMember]); @@ -424,7 +432,7 @@ export const GraphMemberLogPreviewHud = ({ const titleText = relativeTime ? `${displayTitle} ${relativeTime} ${fullPreviewText}` : `${displayTitle} ${fullPreviewText}`; - const isHighlighted = highlightedItemIds.has(item.id); + const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id)); const isError = item.tone === 'error'; const rowStateClassName = isHighlighted ? isError diff --git a/src/features/codex-account/contracts/dto.ts b/src/features/codex-account/contracts/dto.ts index 7e8b8361..abbb17be 100644 --- a/src/features/codex-account/contracts/dto.ts +++ b/src/features/codex-account/contracts/dto.ts @@ -63,6 +63,7 @@ export interface CodexLoginStateDto { error: string | null; startedAt: string | null; authUrl?: string | null; + userCode?: string | null; } export interface CodexRuntimeContextDto { diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 05cb12a3..a7aa9d63 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -693,6 +693,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { error: loginState.status === 'failed' ? loginState.error : null, startedAt: null, authUrl: null, + userCode: null, }; } diff --git a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts index a5284c6a..fc86ca10 100644 --- a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts +++ b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts @@ -26,6 +26,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }; private pendingStartToken: symbol | null = null; private activeSession: { @@ -72,6 +73,7 @@ export class CodexLoginSessionManager { error: null, startedAt: new Date().toISOString(), authUrl: null, + userCode: null, }); try { @@ -89,7 +91,7 @@ export class CodexLoginSessionManager { const response = await session.request( 'account/login/start', - { type: 'chatgpt' }, + { type: 'chatgptDeviceCode' }, LOGIN_REQUEST_TIMEOUT_MS ); @@ -98,15 +100,19 @@ export class CodexLoginSessionManager { return; } - if (response.type !== 'chatgpt') { + if (response.type !== 'chatgptDeviceCode') { throw new Error('Codex app-server returned an unexpected login response type'); } - const authUrl = new URL(response.authUrl); + const authUrl = new URL(response.verificationUrl); if (authUrl.protocol !== 'https:') { throw new Error('Codex app-server returned a non-https auth URL'); } + if (!response.userCode.trim()) { + throw new Error('Codex app-server returned an empty ChatGPT login code'); + } + const disposeNotificationListener = session.onNotification((method, params) => { if (method !== 'account/login/completed') { return; @@ -137,6 +143,7 @@ export class CodexLoginSessionManager { error: null, startedAt: this.state.startedAt, authUrl: authUrl.toString(), + userCode: response.userCode, }); } catch (error) { const wasAbandonedDuringStart = @@ -159,6 +166,7 @@ export class CodexLoginSessionManager { error: error instanceof Error ? error.message : String(error), startedAt: this.state.startedAt, authUrl: this.state.authUrl, + userCode: this.state.userCode, }); throw error; } @@ -172,6 +180,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }); this.emitSettled(); return; @@ -183,6 +192,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }); return; } @@ -211,6 +221,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }); this.emitSettled(); } @@ -226,6 +237,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }); return; } @@ -240,6 +252,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }); } @@ -262,6 +275,7 @@ export class CodexLoginSessionManager { error: null, startedAt: null, authUrl: null, + userCode: null, }); } else { this.setState({ @@ -269,6 +283,7 @@ export class CodexLoginSessionManager { error: notification.error ?? 'ChatGPT login failed.', startedAt: this.state.startedAt, authUrl: this.state.authUrl, + userCode: this.state.userCode, }); } @@ -290,6 +305,7 @@ export class CodexLoginSessionManager { error: errorMessage, startedAt: this.state.startedAt, authUrl: this.state.authUrl, + userCode: this.state.userCode, }); this.emitSettled(); } diff --git a/src/main/services/infrastructure/codexAppServer/protocol.ts b/src/main/services/infrastructure/codexAppServer/protocol.ts index e6bb3739..eb41f37e 100644 --- a/src/main/services/infrastructure/codexAppServer/protocol.ts +++ b/src/main/services/infrastructure/codexAppServer/protocol.ts @@ -43,6 +43,9 @@ export type CodexAppServerLoginAccountParams = | { type: 'chatgpt'; } + | { + type: 'chatgptDeviceCode'; + } | { type: 'chatgptAuthTokens'; accessToken: string; @@ -57,6 +60,12 @@ export type CodexAppServerLoginAccountResponse = loginId: string; authUrl: string; } + | { + type: 'chatgptDeviceCode'; + loginId: string; + verificationUrl: string; + userCode: string; + } | { type: 'chatgptAuthTokens' }; export type CodexAppServerLogoutAccountResponse = Record; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 34b76553..b204c5bd 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -16496,54 +16496,10 @@ export class TeamProvisioningService { stdoutLineBuf += text; const lines = stdoutLineBuf.split('\n'); stdoutLineBuf = lines.pop() ?? ''; - run.stdoutParserCarry = stdoutLineBuf; - const trimmedCarry = stdoutLineBuf.trim(); - if (!trimmedCarry) { - run.stdoutParserCarryIsCompleteJson = false; - run.stdoutParserCarryLooksLikeClaudeJson = false; - } else { - try { - JSON.parse(trimmedCarry); - run.stdoutParserCarryIsCompleteJson = true; - } catch { - run.stdoutParserCarryIsCompleteJson = false; - } - run.stdoutParserCarryLooksLikeClaudeJson = looksLikeClaudeStdoutJsonFragment(trimmedCarry); - } + this.updateStdoutParserCarry(run, stdoutLineBuf); for (const line of lines) { const trimmed = line.trim(); - if (!trimmed) continue; - try { - const msg = JSON.parse(trimmed) as Record; - // Only reset stall timer on messages that represent actual API progress - // (assistant response or result). System messages like retry attempts - // (type=system, subtype=attempt) are informational — the CLI is still - // waiting for the API and the user should see the stall warning. - const msgType = msg.type; - if (msgType === 'assistant' || msgType === 'result') { - run.lastStdoutReceivedAt = Date.now(); - if (run.stallWarningIndex != null) { - const removedIndex = run.stallWarningIndex; - run.provisioningOutputParts.splice(removedIndex, 1); - this.shiftProvisioningOutputIndexesAfterRemoval(run, removedIndex); - run.stallWarningIndex = null; - if (run.preStallMessage != null) { - run.progress.message = run.preStallMessage; - run.preStallMessage = null; - delete run.progress.messageSeverity; - } - } - } - this.handleStreamJsonMessage(run, msg); - } catch { - // Not valid JSON — check for auth failure in raw text output - this.handleAuthFailureInOutput(run, trimmed, 'stdout'); - if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) { - // Show warning but do NOT kill — the SDK may be retrying internally (e.g. 429 model_cooldown). - // If all retries fail, result.subtype="error" will catch it and kill then. - this.emitApiErrorWarning(run, trimmed); - } - } + this.handleStdoutParserLine(run, trimmed); } const currentTs = Date.now(); @@ -16554,6 +16510,76 @@ export class TeamProvisioningService { }); } + private updateStdoutParserCarry(run: ProvisioningRun, carry: string): void { + run.stdoutParserCarry = carry; + const trimmedCarry = carry.trim(); + if (!trimmedCarry) { + run.stdoutParserCarryIsCompleteJson = false; + run.stdoutParserCarryLooksLikeClaudeJson = false; + return; + } + + try { + JSON.parse(trimmedCarry); + run.stdoutParserCarryIsCompleteJson = true; + } catch { + run.stdoutParserCarryIsCompleteJson = false; + } + run.stdoutParserCarryLooksLikeClaudeJson = looksLikeClaudeStdoutJsonFragment(trimmedCarry); + } + + private flushStdoutParserCarry(run: ProvisioningRun): void { + const trimmed = run.stdoutParserCarry.trim(); + if (!trimmed || !run.stdoutParserCarryIsCompleteJson) { + return; + } + + this.handleStdoutParserLine(run, trimmed); + this.updateStdoutParserCarry(run, ''); + } + + private handleStdoutParserLine(run: ProvisioningRun, trimmed: string): void { + if (!trimmed) { + return; + } + + try { + const msg = JSON.parse(trimmed) as Record; + this.handleParsedStdoutJsonMessage(run, msg); + } catch { + // Not valid JSON - check for auth failure in raw text output. + this.handleAuthFailureInOutput(run, trimmed, 'stdout'); + if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed, 'stdout')) { + // Show warning but do not kill - the SDK may be retrying internally (e.g. 429 model_cooldown). + // If all retries fail, result.subtype="error" will catch it and kill then. + this.emitApiErrorWarning(run, trimmed); + } + } + } + + private handleParsedStdoutJsonMessage(run: ProvisioningRun, msg: Record): void { + // Only reset stall timer on messages that represent actual API progress + // (assistant response or result). System messages like retry attempts + // (type=system, subtype=attempt) are informational - the CLI is still + // waiting for the API and the user should see the stall warning. + const msgType = msg.type; + if (msgType === 'assistant' || msgType === 'result') { + run.lastStdoutReceivedAt = Date.now(); + if (run.stallWarningIndex != null) { + const removedIndex = run.stallWarningIndex; + run.provisioningOutputParts.splice(removedIndex, 1); + this.shiftProvisioningOutputIndexesAfterRemoval(run, removedIndex); + run.stallWarningIndex = null; + if (run.preStallMessage != null) { + run.progress.message = run.preStallMessage; + run.preStallMessage = null; + delete run.progress.messageSeverity; + } + } + } + this.handleStreamJsonMessage(run, msg); + } + /** Attaches the stderr handler with auth failure detection. */ private attachStderrHandler(run: ProvisioningRun): void { const child = run.child; @@ -20364,7 +20390,7 @@ export class TeamProvisioningService { private markUnconfirmedBootstrapMembersFailed( run: ProvisioningRun, reason: string, - options?: { cleanupRequested?: boolean } + options?: { cleanupRequested?: boolean; preserveExistingFailure?: boolean } ): void { const failedAt = nowIso(); const baseReason = reason.trim() || 'Deterministic bootstrap failed before teammate check-in.'; @@ -20373,6 +20399,15 @@ export class TeamProvisioningService { if (prev.bootstrapConfirmed || prev.skippedForLaunch) { continue; } + const hasExistingFailure = + prev.status === 'error' || + prev.launchState === 'failed_to_start' || + prev.hardFailure === true || + Boolean(prev.error) || + Boolean(prev.hardFailureReason); + if (options?.preserveExistingFailure && hasExistingFailure) { + continue; + } const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process'; const hardFailureReason = runtimeWasAlive @@ -28315,11 +28350,16 @@ export class TeamProvisioningService { } if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) { - this.markUnconfirmedBootstrapMembersFailed( - run, - 'Launch ended before teammate bootstrap completed.', - { cleanupRequested: true } - ); + const cleanupReason = + typeof run.progress.error === 'string' && run.progress.error.trim() + ? run.progress.error.trim() + : run.progress.state === 'failed' && run.progress.message.trim() + ? run.progress.message.trim() + : 'Launch ended before teammate bootstrap completed.'; + this.markUnconfirmedBootstrapMembersFailed(run, cleanupReason, { + cleanupRequested: true, + preserveExistingFailure: true, + }); void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); @@ -28615,6 +28655,8 @@ export class TeamProvisioningService { return; } + this.flushStdoutParserCarry(run); + // IMPORTANT: stopStallWatchdog MUST be AFTER authRetryInProgress guard above! // During respawn, the old process exit fires but run.stallCheckHandle already // points to the NEW process's watchdog. Stopping it here would kill the wrong timer. diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index a61af14e..84c0b183 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -6,11 +6,13 @@ interface DesktopTeammateModeDecision { forceProcessTeammates: boolean; } +type DesktopTeammateMode = 'auto' | 'tmux' | 'in-process'; + +const DESKTOP_TEAMMATE_MODE_ENV = 'CLAUDE_TEAM_TEAMMATE_MODE'; + let tmuxAvailablePromise: Promise | null = null; -function getExplicitTeammateMode( - rawExtraCliArgs: string | undefined -): 'auto' | 'tmux' | 'in-process' | null { +function getExplicitTeammateMode(rawExtraCliArgs: string | undefined): DesktopTeammateMode | null { const tokens = parseCliArgs(rawExtraCliArgs); for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; @@ -34,6 +36,17 @@ function getExplicitTeammateMode( return null; } +function normalizeDesktopTeammateMode(value: string | undefined): DesktopTeammateMode | null { + const normalized = value?.trim().toLowerCase(); + return normalized === 'auto' || normalized === 'tmux' || normalized === 'in-process' + ? normalized + : null; +} + +function getEnvTeammateMode(env: NodeJS.ProcessEnv): DesktopTeammateMode | null { + return normalizeDesktopTeammateMode(env[DESKTOP_TEAMMATE_MODE_ENV]); +} + async function isTmuxAvailable(): Promise { if (!tmuxAvailablePromise) { tmuxAvailablePromise = isTmuxRuntimeReadyForCurrentPlatform() @@ -48,24 +61,25 @@ async function isTmuxAvailable(): Promise { } export async function resolveDesktopTeammateModeDecision( - rawExtraCliArgs: string | undefined + rawExtraCliArgs: string | undefined, + env: NodeJS.ProcessEnv = process.env ): Promise { - const explicitMode = getExplicitTeammateMode(rawExtraCliArgs); - if (explicitMode === 'tmux') { + const requestedMode = getExplicitTeammateMode(rawExtraCliArgs) ?? getEnvTeammateMode(env); + if (requestedMode === 'tmux') { + return { + injectedTeammateMode: 'tmux', + forceProcessTeammates: false, + }; + } + + if (requestedMode === 'auto') { return { injectedTeammateMode: null, forceProcessTeammates: true, }; } - if (explicitMode === 'auto') { - return { - injectedTeammateMode: null, - forceProcessTeammates: true, - }; - } - - if (explicitMode === 'in-process') { + if (requestedMode === 'in-process') { return { injectedTeammateMode: null, forceProcessTeammates: false, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 93e9d036..312f8ec7 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -19,7 +19,10 @@ import { import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; -import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; +import { + CodexLoginLinkCopyButton, + CodexLoginUserCodeBadge, +} from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { formatProviderStatusText, getProviderConnectionModeSummary, @@ -103,7 +106,9 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null { } if (codex.login.status === 'starting' || codex.login.status === 'pending') { - return codex.login.authUrl ? 'Finish ChatGPT login in the browser.' : null; + return codex.login.authUrl + ? 'Finish ChatGPT login in the browser. Enter the shown code if prompted.' + : null; } const usageHint = codex.localActiveChatgptAccountPresent @@ -718,6 +723,7 @@ const InstalledBanner = ({ provider.connection?.codex?.login.status !== 'starting' && provider.connection?.codex?.login.status !== 'pending'; const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null; + const codexLoginUserCode = provider.connection?.codex?.login.userCode ?? null; const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl); const disconnectAction = getProviderDisconnectAction(provider); const providerLoading = cliProviderStatusLoading[provider.providerId] === true; @@ -888,9 +894,11 @@ const InstalledBanner = ({ <> + ); }; + +export const CodexLoginUserCodeBadge = ({ + userCode, +}: { + userCode?: string | null; +}): React.JSX.Element | null => { + if (!userCode) { + return null; + } + + return ( + + Code {userCode} + + ); +}; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index e8c72d7f..3be99a6e 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -22,7 +22,10 @@ import { import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer'; import { api } from '@renderer/api'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; -import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; +import { + CodexLoginLinkCopyButton, + CodexLoginUserCodeBadge, +} from '@renderer/components/runtime/CodexLoginLinkCopyButton'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -718,6 +721,7 @@ export const ProviderRuntimeSettingsDialog = ({ const codexLoginPending = codexConnection?.login.status === 'starting' || codexConnection?.login.status === 'pending'; const codexLoginAuthUrl = codexConnection?.login.authUrl ?? null; + const codexLoginUserCode = codexConnection?.login.userCode ?? null; const configurableAuthModes = selectedProvider?.connection?.configurableAuthModes ?? []; const configuredAuthMode: CliProviderAuthMode | undefined = selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined; @@ -1395,8 +1399,10 @@ export const ProviderRuntimeSettingsDialog = ({ <> + {codexLoginAuthUrl ? ( + ) : null} + + ) : null}