diff --git a/docs/extensions/plugin-kit-ai-integration-plan.md b/docs/extensions/plugin-kit-ai-integration-plan.md index 780677f9..2023ced8 100644 --- a/docs/extensions/plugin-kit-ai-integration-plan.md +++ b/docs/extensions/plugin-kit-ai-integration-plan.md @@ -18,6 +18,36 @@ The integration must support two different truths at the same time: Those are different objects and must remain different in UI, state, and actions. +## How To Use This Plan + +This document is intentionally long because it combines: + +- product model +- backend contract spec +- rollout spec +- app integration rules + +Use it in this order: + +1. read `One-Page Summary` +2. read `Phase 0 Decision Checkpoints` +3. read `Current backend blockers that shape the plan` +4. for backend work: + - read `Recommended Backend Basis By Surface` + - read `Managed Lifecycle Model` + - read `JSON Contract Style` + - read `Recommended Contract Drafts` +5. for app work: + - read `Entry Derivation and Conflict Resolution` + - read `UI and Entry Model in claude_team` + - read `claude_team Changes Required` +6. before shipping: + - read `Rollout Phases` + - read `PR Exit Criteria` + - read `No-Go Conditions` + +If any implementation decision contradicts the earlier sections, the earlier sections win. + ## One-Page Summary ### What we are building @@ -43,6 +73,93 @@ Those are different objects and must remain different in UI, state, and actions. - universal plugins are the main storefront - native installed Claude/Codex plugins stay visible and are labeled honestly - install/update/remove/repair results stay target-granular +- mutation results do not lie about partial progress: + - applied + - rolled back + - degraded state persisted + +### Phase 1 can and cannot promise + +Phase 1 can safely promise: + +- truthful mixed rendering of universal and native external entries +- truthful managed lifecycle status for installed universal entries +- explicit degraded / rolled-back mutation outcomes +- explicit adopted-target update semantics when backend provides them + +Phase 1 must **not** promise: + +- parallel managed installs of the same integration across scopes or workspaces +- fake `local` scope parity +- destructive actions for native external entries +- instant local-only previews for every lifecycle action +- shared universal card metadata that silently reflects one target override +- invisible policy-driven target adoption during update +- target-scoped `update` or `remove` UX until the public backend command surface exposes that capability consistently + +### Quick truth map + +| User question | Backend surface | +|---|---| +| What universal plugins exist? | `catalog` | +| What native plugins already exist outside managed state? | `discover` | +| What universal plugins are managed right now? | `list` | +| Which managed targets need attention or repair? | `doctor` | +| How fresh is the managed lifecycle truth? | lifecycle grouped metadata such as `last_checked_at` and `last_updated_at` | +| Can I install/update/remove/repair this universal plugin? | lifecycle `plan/result` contracts | +| What detail text and README should the storefront show? | `catalog` detail path | +| What detail explains current target drift or activation? | lifecycle target-detail payload | + +### Command quick reference + +| Command surface | Can be networked in preview? | Can mutate native state? | Phase-1 app expectation | +|---|---|---|---| +| `catalog` | no | no | fast storefront truth | +| `discover` | no | no | fast native-installed truth | +| `list` | no | no | fast managed ownership truth | +| `doctor` | no | no | fast health + recovery truth | +| `add` dry-run | yes | no | review can show source-checking state | +| `update` dry-run | yes | no | review can show source-checking state and adopted-target work; phase-1 public command surface is integration-wide, not target-filtered | +| `remove` dry-run | no | no | cheap review, no source resolution required; phase-1 public command surface is integration-wide, not target-filtered | +| `repair` dry-run | no | no | cheap review, no source resolution required | +| apply mutations | yes | yes | explicit progress + explicit outcome class | + +### Lifecycle action families from current code + +Current `integrationctl` already has several different action families. + +They are not interchangeable and the app should not flatten them into one generic “plugin action” concept. + +| Action family | Current action ids | Key behavior from code | Phase-1 app status | +|---|---|---|---| +| add new managed integration | `add` | resolves requested source, plans/installs one or more targets | in scope | +| mutate existing managed integration | `update_version` | resolves current source, may also adopt newly supported targets | in scope | +| remove managed targets | `remove_orphaned_target` | dry-run can stay local, apply may resolve source and mutate state | in scope | +| repair managed drift | `repair_drift` | dry-run can stay local, apply may resolve source and persist degraded state on failure | in scope | +| toggle managed target | `enable_target`, `disable_target` | single-target toggle lane, distinct summary and apply path | out of scope for phase 1 | + +### Phase-1 action subset + +Phase 1 plugin lifecycle in `claude_team` should expose only: + +- `add` +- `update_version` +- `remove_orphaned_target` +- `repair_drift` + +Phase 1 should not expose: + +- `enable_target` +- `disable_target` +- `sync` +- target-scoped `update` or `remove` controls unless backend command contracts add them explicitly + +Why: + +- they are lower-value than core lifecycle +- they have distinct semantics that would widen the UI surface +- they are better added only after mixed-entry rendering and primary lifecycle flows are proven +- current public command surface is not yet symmetric for target-filtered existing mutations ### Safe delivery order @@ -53,12 +170,87 @@ Those are different objects and must remain different in UI, state, and actions. 5. add universal lifecycle actions in `claude_team` 6. consider optional native convenience flows only later +### Current backend blockers that shape the plan + +These come from current code and are the main reason the rollout has to stay phased: + +- managed state is effectively keyed by `integration_id`, not by a richer record key +- project-sensitive service construction still depends on implicit `cwd` +- current `integrations` manage commands still do not accept explicit `--workspace-root` +- current `update_version` planning may also produce adopted-target work based on manifest drift and policy +- current `update_version` dry-run already resolves current source and may therefore clone/fetch remote source before review is shown +- dry-run planning can look clean and still fail later on apply because same-`integration_id` conflict is enforced only under state lock +- current public CLI command surface is asymmetric for existing-target filtering: + - `repair` exposes `--target` + - `enable/disable` can require `--target` + - `update/remove` currently do not expose `--target`, even though the underlying usecase model already has a target field +- current lifecycle `Report.Targets` are too flat for app use and lose integration-level grouping +- current lifecycle `TargetReport` also drops some adapter-level detail that the app will need for truthful detail views +- current lifecycle `TargetReport` currently drops concrete detail such as: + - target warnings + - owned native objects + - observed native objects + - settings files + - config precedence context + - paths touched + - commands +- current lifecycle report also drops managed freshness fields such as `last_checked_at` and `last_updated_at` + +### Execution blueprint + +| Step | Repo | Main packages | What changes | Why this step exists | +|---|---|---|---|---| +| 0 | `plugin-kit-ai` | `integrationctl`, CLI command layer | freeze state identity, conflict timing, app-mode workspace semantics | prevents bad contracts from being versioned | +| 1 | `plugin-kit-ai` | CLI JSON output layer | add versioned lifecycle JSON envelopes | gives app a stable machine-readable seam | +| 2 | `plugin-kit-ai` | lifecycle usecase/domain | add managed grouping and target-detail fidelity | makes lifecycle usable for app cards/detail views | +| 3 | `plugin-kit-ai` | service construction + request context | add explicit `workspace_root` handling | removes hidden `cwd` coupling | +| 4 | `plugin-kit-ai` | authored inspection/catalog projection | add universal catalog JSON | provides storefront truth | +| 5 | `plugin-kit-ai` | new discovery usecase + adapters | add native discovery JSON | provides native installed truth | +| 6 | `claude_team` | main services + renderer normalized model | read-only mixed plugin page | validates catalog + discover + list + doctor interplay | +| 7 | `claude_team` | lifecycle actions + store refresh | universal install/update/remove/repair | completes managed plugin flow | + +### Backend readiness gate before any `claude_team` plugin-kit PR + +`claude_team` should not start real plugin-kit-backed plugin rendering or lifecycle work until the backend can already guarantee all of the following. + +This gate is intentionally stricter than “some JSON exists”. + +| Required backend guarantee | Why the app needs it | Current code reality | +|---|---|---| +| explicit `workspace_root` request context for project-sensitive commands | prevents plan/apply from using hidden `cwd` semantics | missing at CLI manage-command layer | +| stable `managed_entry_key` for grouped lifecycle entries | prevents app cache keys from collapsing to raw `integration_id` | missing as a public grouped contract field | +| grouped lifecycle JSON with `requested_source_ref`, `resolved_source_ref`, `policy.scope`, and `workspace_root` | lets app render ownership truth without row reconstruction heuristics | current report is target-row oriented | +| lifecycle freshness fields such as `last_checked_at` and `last_updated_at` | prevents app from implying live remote verification when it only has stored-state truth | stored in state, not projected publicly | +| structured target-detail fidelity | lets detail panes explain drift, blocking, and owned objects without renderer-side probing | current target report drops key adapter detail | +| structured top-level `doctor` recovery warnings | keeps degraded/interrupted recovery truth first-class | warnings exist in backend but not yet in app-facing JSON | +| explicit target `action_class` and mutation `outcome` in plan/result | keeps `update` vs `adopt_new_target` and `applied` vs `rolled_back` vs `degraded` distinct | backend semantics exist but are not yet pinned in an app contract | +| explicit public policy for target-filtered existing mutations | prevents renderer from inventing target-scoped `update/remove` UX that current CLI surface does not actually support | usecase model has a target field, public command surface is still asymmetric | + +Recommended rule: + +- until this gate is green, `claude_team` may build only isolated types and fixtures behind a feature flag +- it must not ship real plugin-kit-backed mixed rendering or lifecycle actions + +### Top implementation anti-patterns + +These are the fastest ways to create bugs in this migration: + +- using authored target names as installability truth +- using raw `integration_id` as the only managed entry key in the app +- reusing current adapter `Inspect` as discovery backend +- inferring project context from Electron process cwd +- merging native external entries into universal entries by display name +- letting target-specific metadata override shared universal card identity +- treating process exit as the only JSON command outcome signal +- reconstructing managed entry groups from flat target rows in renderer code + ### Non-negotiable no-go items - no auto-merge by display name - no silent `local -> project` downgrade - no destructive actions on native external entries unless backend explicitly declares them safe - no app-side inference where backend truth is missing +- no accidental `enable/disable` app surface in phase 1 just because backend already has those actions ## Glossary @@ -150,6 +342,58 @@ This migration is done only when all of the following are true: If any of these is false, the migration is still in progress. +## Phase 0 Decision Checkpoints + +Before app integration starts, these questions must already have explicit answers in backend contracts or documented policy: + +1. **Managed state identity** + - default phase-1 answer: single-record-per-integration +2. **Conflict timing for `add`** + - default phase-1 answer: conflict is surfaced during planning/preflight, not only after confirm +3. **Workspace semantics** + - default phase-1 answer: project-sensitive app mode never depends on implicit `cwd` +4. **Planning context** + - default phase-1 answer: project-sensitive planning uses explicit workspace context, not hidden service-wide defaults +5. **Capability projection** + - default phase-1 answer: installability comes from projected backend capabilities, not from authored target names alone +6. **Catalog truth** + - default phase-1 answer: storefront metadata comes from the richer authored inspection path, not the narrow lifecycle loader +7. **Discovery truth** + - default phase-1 answer: `discover` is read-only, scanner-oriented, and overlap-aware +8. **Target detail fidelity** + - default phase-1 answer: app-facing lifecycle JSON keeps target warnings, object ownership, and blocking status instead of dropping them into prose or internal-only fields +9. **Adopted-target update semantics** + - default phase-1 answer: update plans expose newly adopted targets explicitly instead of burying them as generic update rows or warnings +10. **Doctor warning fidelity** + - default phase-1 answer: `doctor` warnings are treated as structured recovery guidance, not decorative text +11. **Managed freshness semantics** + - default phase-1 answer: grouped lifecycle payloads expose stored-state freshness timestamps and the app does not imply live remote verification unless a source-resolving action actually ran + +If any of these stays fuzzy, the implementation will drift into app-side heuristics. + +## Hard Defaults For Low-Confidence Seams + +These defaults should be used unless a later ADR deliberately changes them. + +| Seam | Default | +|---|---| +| managed state identity | one managed record per `integration_id` in phase 1 | +| install-intent conflict timing | surface during planning/preflight, not only after confirm | +| project context | explicit `workspace_root`, never implicit `cwd` | +| lifecycle grouping key | `managed_entry_key`, not reconstructed from rows | +| discovery backend | separate scanner surface, not current adapter `Inspect` | +| native-to-universal matching | advisory only unless evidence is exact | +| Codex ambiguous state | downgrade to `observed_degraded` | +| target-specific metadata | detail-only enhancement, never shared card identity | +| mutation outcome | keep `applied`, `rolled_back`, and `degraded` distinct | +| adopted targets during update | show as explicit `adopt_new_target` work, not generic update noise | +| doctor warnings | surface as recovery guidance, not ignorable banner copy | +| plan/review latency | assume `update` preview may resolve remote source; do not design UX as instant/local-only | +| action surface breadth | keep `enable/disable` out of phase 1 | +| lifecycle freshness | show stored-state timestamps, do not imply live remote verification unless a source-resolving action just ran | +| unsupported fields | omit or degrade, never synthesize in renderer | +| parallel managed installs for same integration | unsupported until backend state identity is upgraded | + ## What Is Already True in plugin-kit-ai This plan should build on real current code, not on an imagined backend. @@ -176,7 +420,9 @@ This plan should build on real current code, not on an imagined backend. - there is no public `catalog` surface yet - there is no public `discover` surface yet - current managed `Report.Targets` do not carry enough integration-level context for the app +- current managed `TargetReport` also drops adapter-level detail such as target warnings, owned-object context, settings files, and precedence context - current service composition still depends on `os.Getwd()` for workspace semantics +- current CLI manage commands do not yet carry explicit `workspace_root` request context ### Important current model split @@ -229,6 +475,10 @@ These are backend facts the plan must respect. - supports native remove: yes - supports scopes: `user`, `project` - does not currently advertise `local` +- currently advertises supported source kinds: + - `local_path` + - `github_repo_path` + - `git_url` - requires reload after install ### Codex adapter @@ -238,6 +488,10 @@ These are backend facts the plan must respect. - supports native remove: no - supports scopes: `user`, `project` - does not currently advertise `local` +- currently advertises supported source kinds: + - `local_path` + - `github_repo_path` + - `git_url` - requires restart and a new thread - current inspect logic distinguishes: - fully installed @@ -552,6 +806,18 @@ Notably, it does **not** include: - `codex-runtime` - `cursor-workspace` +Important nuance from current code: + +- target adapters expose `Capabilities()` +- but current add planning does not use adapter capabilities as one central authoritative gate before all plan work +- current planning first resolves manifest deliveries and then inspects/plans target-specific installs + +Practical consequence: + +- the app must not infer real installability from authored targets alone +- the backend contract should project lifecycle-manageable truth explicitly +- if capability-based limits such as supported source kinds or scopes matter, they should come from backend projection, not renderer heuristics + ### 3. App-primary action targets What `claude_team` should expose as first-class install lanes in this rollout. @@ -591,6 +857,25 @@ This keeps the system honest: - backend actionability stays explicit - app UI stays focused +### Capability projection rule + +For app-facing plugin installability, the contract should project at least: + +- manageable targets +- supported scopes by target +- supported source kinds by target when relevant +- target-level lifecycle capabilities that materially affect UX such as: + - update support + - remove support + - repair support + - restart / reload / new-thread requirements + +Recommended rule: + +- the app should render from this projected capability layer +- not from authored target names +- and not by trying to call low-level capability methods itself + ## Product Model ### Universal plugins @@ -973,6 +1258,26 @@ But current adapters derive effective native roots differently: This means the same raw workspace path can lead to different effective native roots depending on target semantics. +There is one more critical current reality: + +- current `integrationctl.newService()` still derives: + - current workspace root from `os.Getwd()` + - repo-root-oriented files such as workspace lock and evidence paths from discovered repo root + - default adapter project roots from that same cwd + +That is acceptable for a human CLI launched from the intended repo. +It is not a safe default for a bundled desktop app. + +There is also a more subtle planning seam: + +- current add planning calls adapter `Inspect(...)` with: + - `IntegrationID` + - `Scope` +- but no explicit `workspace_root` field in `InspectInput` +- project context therefore reaches adapters indirectly through service construction and adapter defaults, not through an explicit per-request planning field + +That may be acceptable for the current CLI composition, but it is too implicit for app integration. + ### Why this matters If the app assumes one global meaning for `workspace_root`, it can easily: @@ -1005,6 +1310,39 @@ Phase-1 minimum: - missing project `workspace_root` must remain a hard backend error - target-specific effective root may be additive if not ready immediately +### Required service-construction rule for app mode + +For app integration, `plugin-kit-ai` should not rely on implicit process cwd semantics. + +Recommended rule: + +- add an explicit app/CLI service-construction path that accepts: + - `workspace_root` + - optional repo-root-oriented paths only where they are still needed +- project-sensitive commands should use that explicit workspace input +- packaged app execution must not depend on what directory the Electron process happened to launch from + +Recommended phase-1 default: + +- keep repo-root-oriented flows such as workspace lock and `sync` out of app mode +- require explicit `workspace_root` for project-scoped lifecycle and discovery commands +- treat missing explicit workspace context as usage error, not as permission to fall back to `os.Getwd()` + +### Planning-context rule + +For app integration, project-sensitive planning must also receive explicit context. + +Recommended rule: + +- either service construction per request must bind explicit workspace context before planning +- or planning interfaces must grow explicit workspace-root context + +What must not happen: + +- “plan” uses one implicit project context +- “apply” uses a different explicit project context +- and the app presents them as if they were the same decision + ### Recommended app rule - UI selection should talk in terms of the chosen project/workspace @@ -1218,6 +1556,29 @@ Current code already proves this path exists: - `codex-package` generation merges base manifest metadata with optional `targets/codex-package/package.yaml` - validation checks the generated Codex package metadata against that merged expectation +Current code also proves the override boundary is intentionally narrow. + +For `codex-package`, the effective metadata overlay is currently designed for: + +- `author` +- `homepage` +- `repository` +- `license` +- `keywords` + +It is **not** the same thing as “target can override any storefront field”. + +Recommended rule: + +- phase 1 should treat only these currently-proven metadata fields as safe `codex-package` effective overrides +- the app should not assume per-target overrides for: + - `name` + - `version` + - `description` + - entry identity + - universal card title + - universal card summary + ### Recommended catalog rule The catalog contract should preserve both layers explicitly: @@ -1243,6 +1604,10 @@ At minimum, per-target effective metadata may include: - list cards should use shared metadata - provider-specific effective metadata should appear only in target detail sections or provider-specific support details - the app must not silently replace universal card metadata with one target's override +- the app must not let `codex-package` override: + - universal `display_name` + - universal `description` + - universal search identity Why: @@ -1394,6 +1759,8 @@ Important rule: - `discover` should keep this richer observed-state truth - the app should not collapse everything into plain `installed/not installed` +- the app should not promote `observed_active` from cache evidence alone +- when evidence is missing or contradictory, downgrade to `observed_degraded` ### Codex evidence mapping table @@ -1414,6 +1781,7 @@ Until discovery evidence is richer, prefer these defaults: - if evidence is ambiguous, downgrade to `observed_degraded` - do not claim `observed_active` from config evidence alone +- do not claim `observed_active` from cache evidence alone when marketplace entry or plugin root is missing - do not infer exact universal matching from marketplace entry name alone - do not infer safe removal from discovered Codex paths alone - do not suppress a discovered Codex entry unless managed-overlap evidence includes owned objects or stable lifecycle evidence @@ -1520,6 +1888,48 @@ The app needs lifecycle JSON to include integration-level context such as: - policy scope - workspace root +### Important current state-identity constraint + +Current managed state logic is narrower than it may look at first glance. + +From current `integrationctl` code: + +- `StateFile.Installations` is an array of `InstallationRecord` +- but `findInstallation`, `upsertInstallation`, and `removeInstallation` all key records only by `IntegrationID` +- existing plan and mutation flows also load records by integration name only + +Practical consequence: + +- current backend behavior effectively supports only one managed installation record per `integration_id` +- it does **not** yet describe a first-class model where the same integration can safely exist as parallel managed records for different scopes or workspaces +- current add flow also checks this conflict only at apply time after loading locked state, not during the earlier dry-run planning path + +This is a critical contract seam, not an implementation detail. + +### Install-intent conflict timing + +Current behavior is stricter than it first appears, but also later than the app would ideally want: + +- `add --dry-run` can still produce a plan without surfacing “integration already exists in state” +- `applyAdd(...)` then acquires the state lock, loads state, and fails with: + - `ErrStateConflict` + - `integration already exists in state: ` + +Why this matters: + +- the app can otherwise show a plausible install plan and only fail after the user confirms the mutation +- that is acceptable for a CLI, but it is weak UX for a structured desktop integration + +Recommended phase-0 rule: + +- either backend planning surfaces existing-state conflicts explicitly +- or the app must perform an authoritative managed-state preflight before presenting install as cleanly applyable + +Recommended default: + +- prefer surfacing the conflict in backend planning/contract, not only at apply time +- if that is not ready yet, app UI must at least treat same-`integration_id` managed presence as a preflight blocker + ### Why the current raw lifecycle report is not enough for app integration Today the raw `integrationctl` lifecycle query shape is still too flat for the plugin page. @@ -1548,6 +1958,13 @@ What it does **not** preserve at the same level: - `workspace_root` - a stable grouping boundary between one integration and another +And for planning/apply UX it also drops important target-plan semantics that do exist one layer below in `ports.AdapterPlan`, such as: + +- explicit `blocking` +- plan `summary` +- `paths_touched` +- `commands` + That matters because the app needs to render cards and detail views at the integration-entry level, not as an ungrouped stream of target facts. Recommended rule: @@ -1555,6 +1972,51 @@ Recommended rule: - `plugin-kit-ai` should keep its current internal normalized lifecycle model - but the app-facing JSON contract must expose managed entries grouped by integration - `claude_team` must not try to reconstruct integration grouping from flat target rows by heuristics +- app-facing plan/result contracts must also preserve enough per-target semantics to tell: + - whether the action is actually applyable + - why it is blocked + - what manual steps are advisory vs blocking + +### Current report also drops target-detail fidelity the app will care about + +Today there is another mismatch between the internal adapter layer and the public lifecycle report. + +Current adapter-level structs already carry richer target detail: + +- `InspectResult` + - `Warnings` + - `OwnedNativeObjects` + - `ObservedNativeObjects` + - `SettingsFiles` + - `ConfigPrecedenceContext` +- `ApplyResult` + - `Warnings` + - `OwnedNativeObjects` + - `AdapterMetadata` +- `AdapterPlan` + - `Blocking` + - `Summary` + - `PathsTouched` + - `Commands` + +Current `TargetReport` keeps only a subset of that. + +Practical consequence: + +- the current lifecycle report is enough for a terminal summary +- it is not yet a strong enough truth surface for a desktop detail view +- if phase 1 exposes only the current flat report shape, `claude_team` will eventually be forced to guess or hide important state + +Recommended rule: + +- app-facing lifecycle JSON should include a structured target-detail block +- it does not need to expose every low-level adapter internal +- but it must preserve at minimum: + - target warnings + - owned native objects + - blocking vs advisory status + - settings or config files when they are part of the activation story + - enough context to explain precedence or override issues truthfully ### Required grouped lifecycle identifiers @@ -1567,6 +2029,8 @@ For app-facing lifecycle JSON, each managed entry should include at minimum: - `resolved_version` - `policy.scope` - `workspace_root` +- `last_checked_at` +- `last_updated_at` Recommended rule: @@ -1576,6 +2040,33 @@ Recommended rule: Without this, the frontend will eventually drift into reconstructing groups from target arrays, which is fragile and unnecessary. +### Freshness semantics from current code + +Current managed lifecycle state is persisted with freshness timestamps. + +From current code: + +- successful `add` persists: + - `last_checked_at` + - `last_updated_at` +- successful `update/remove/repair/toggle` also update those timestamps +- degraded persistence paths also update stored record timestamps +- current `list` and `doctor` reports read state and journal, but they do not currently project these timestamps into public report rows + +Practical consequence: + +- current lifecycle truth is primarily stored-state truth +- it is not the same thing as “this source was remotely revalidated just now” +- without explicit timestamps, the app can easily overstate freshness + +Recommended rule: + +- grouped lifecycle payloads should expose at least: + - `last_checked_at` + - `last_updated_at` +- the app should use those fields for freshness copy and stale-state heuristics +- the app should not imply remote source freshness unless a source-resolving lifecycle action actually ran + ### Conservative phase-1 grouped lifecycle rule Until the backend exposes a more formal record identifier, phase 1 should still require: @@ -1587,6 +2078,108 @@ Until the backend exposes a more formal record identifier, phase 1 should still The app must treat missing grouping keys as a compatibility problem, not as an invitation to reconstruct them heuristically. +### Managed multiplicity rule + +Phase 1 must choose one of these backend truths explicitly and reflect it in the contract: + +1. **single-record-per-integration** + - one `integration_id` can have only one managed record at a time + - conflicting install intents must fail explicitly +2. **multi-record-per-integration** + - the backend introduces a real record key beyond `integration_id` + - lifecycle and state mutations become record-key aware + +Recommended phase-1 default: + +- keep the current single-record-per-integration model explicit +- do **not** let the app imply parallel managed `user` and `project` installs of the same universal plugin unless backend state identity is upgraded first +- if the user attempts a conflicting install intent, backend should return a structured conflict instead of silently replacing the existing record + +### Mutation outcome fidelity from current code + +Current mutation paths already distinguish more than plain success vs failure. + +From current code: + +- `add` + - may finish `committed` + - may finish `rolled_back` + - may finish `degraded` if rollback was incomplete and degraded state was persisted +- `update` + - may finish `committed` + - may finish `degraded` +- `remove` + - may finish `committed` + - may finish `rolled_back` + - may finish `degraded` +- `repair` + - may finish `committed` + - may finish `degraded` + +Practical consequence: + +- app-facing mutation contracts should not flatten all non-success paths into one generic `failed` +- the app needs to know whether native changes were rolled back cleanly or whether degraded managed state was persisted + +Recommended rule: + +- payload-level mutation outcome should distinguish at least: + - `applied` + - `rolled_back` + - `degraded` + - `failed` +- target-level results should remain visible inside that higher-level operation outcome + +### Update-time adopted target semantics from current code + +Current `update_version` planning already has a second behavior beyond “update existing targets”. + +From current code: + +- `update_version` resolves the next manifest/source +- if the next manifest exposes deliveries for targets not currently present in the managed record +- planning may also produce adopted-target work +- that adopted work is policy-sensitive: + - if `adopt_new_targets=manual`, planning emits warnings instead of auto-adopt work + - if `adopt_new_targets=auto`, planning may produce target plans with action class `adopt_new_target` + +Practical consequence: + +- an update plan is not always only “update current targets” +- it may also include “new target becomes managed as part of update policy” + +Recommended rule: + +- app-facing lifecycle plan/result payloads should preserve whether a target is: + - ordinary update work + - adopted new target work +- the app should render adopted targets explicitly in review/results instead of burying them inside generic update output + +Conservative phase-1 default: + +- if adopted-target semantics are not explicitly present in payloads, the app should not silently assume there are none +- update UX should stay conservative until backend carries that signal clearly + +### Adopted-target apply path from current code + +Current apply logic makes this even more important. + +From current code: + +- ordinary `update_version` target work uses `ApplyUpdate` +- adopted target work uses `ApplyInstall` + +Practical consequence: + +- adopted target work is not just cosmetic plan labeling +- it is a materially different mutation path + +Recommended rule: + +- plan and result payloads must preserve target `action_class` +- `action_class` must survive from preview to final result +- the app must not treat every target in an update result as if it came from the same mutation path + ### Critical CLI semantic to freeze Current `integrations` mutating commands default to `--dry-run=true`. @@ -1692,6 +2285,8 @@ For machine-readable integrations surfaces, outcome should be explicit in the pa - recommended values: - `planned` - `applied` + - `rolled_back` + - `degraded` - `partial_success` - `failed` @@ -1704,6 +2299,160 @@ The exact enum may still evolve, but the contract must keep payload-level outcom - `plugin-kit-ai/integrations-catalog` - `plugin-kit-ai/integrations-discovery` +### Summary-string rule + +Current backend summary strings are useful for humans, but they are not strong enough to be canonical machine truth. + +Examples from current code: + +- `Updated integration "demo".` +- `Removed managed targets from integration "demo".` +- `Repaired managed targets for integration "demo".` + +Those are helpful, but they do not by themselves preserve: + +- adopted-target work +- degraded vs rolled-back outcome +- target-level action classes +- target-level manual steps or restrictions + +Recommended rule: + +- app contracts may keep human-readable `summary` +- but the app must never infer machine semantics from `summary` alone +- target rows and explicit payload fields always win + +### Minimum contract by command + +| Command | Minimum request context | Minimum payload truth | +|---|---|---| +| `catalog` | optional requested targets | universal entries, target projection, freshness | +| `discover` | requested targets, requested workspace root when relevant | native entries, observed state, manageability, match metadata | +| `list` | optional requested targets | grouped managed entries, `managed_entry_key`, source refs, scope, workspace, freshness timestamps | +| `doctor` | optional requested targets | everything from `list` plus top-level recovery warnings and target manual steps | +| `add` plan/apply | source ref, targets, scope, workspace root when relevant | grouped target plans/results, blocking, action class, mutation outcome | +| `update` plan/apply | integration id, workspace root when relevant; optional target filter only after the public command contract adds it | grouped target plans/results, adopted-target semantics, mutation outcome | +| `remove` plan/apply | integration id, workspace root when relevant; optional target filter only after the public command contract adds it | grouped target plans/results, mutation outcome | +| `repair` plan/apply | integration id, optional target filter, workspace root when relevant | grouped target plans/results, repair guidance, mutation outcome | + +### Plan fidelity rule + +For app integration, a machine-readable dry-run plan is not useful unless it preserves the distinction between: + +- applyable plan with advisory manual steps +- blocked plan with required manual intervention + +Recommended rule: + +- app-facing plan payloads should expose target-level fields such as: + - `blocking` + - `summary` + - `manual_steps` + - optional `paths_touched` + - optional `commands` +- if backend wants to omit some operational detail from the public app contract, it may omit `commands` +- but it must not omit whether the plan is blocked + +Without this rule, the app can only discover some blocking cases after the user already tries to apply the mutation. + +### Target detail fidelity rule + +For app integration, lifecycle JSON is not good enough if it flattens all interesting target detail into: + +- one summary string +- a few booleans +- or process-exit semantics + +Recommended rule: + +- app-facing lifecycle entry payloads should expose a structured target-detail section, either inline or under a nested field such as `target_detail` +- that section should be allowed to include fields such as: + - `warnings` + - `owned_native_objects` + - `observed_native_objects` + - `settings_files` + - `config_precedence_context` + - `adapter_metadata` only when the value is intentionally public and stable + +Conservative phase-1 default: + +- `warnings` +- `owned_native_objects` +- `settings_files` +- `blocking` +- `manual_steps` + +must be preserved + +Current-code note: + +- raw `domain.TargetReport` already keeps useful basics such as: + - `action_class` + - `manual_steps` + - lifecycle booleans and restrictions +- but it still drops higher-fidelity adapter truth the app will need for honest detail panes, including: + - target warnings + - owned-object and observed-object context + - settings-file context + - config precedence context + - paths touched + - command detail + +Why: + +- this is enough for truthful card/detail UX +- it avoids forcing the app to inspect native filesystem state itself +- it keeps app-side remediation copy grounded in backend truth + +### Action-class persistence rule + +For app integration, target-level `action_class` is part of the contract, not decorative text. + +Why: + +- current backend already distinguishes different mutation kinds at target level +- during `update`, some targets may be ordinary updates while others are `adopt_new_target` +- those may even go through different apply paths + +Recommended rule: + +- app-facing plan payloads must expose target `action_class` +- app-facing result payloads must also expose target `action_class` +- the app must not reconstruct it from summary prose + +Conservative phase-1 default: + +- if a mutating result payload loses target `action_class`, treat that payload as reduced-fidelity and avoid pretending the result was semantically complete + +### Doctor warning fidelity rule + +Current `doctor` output is not only a list of target states. + +From current code: + +- `doctor` top-level warnings already include open journal / operation recovery guidance +- examples: + - previously degraded operation guidance + - interrupted `in_progress` operation guidance + - failed-before-commit guidance +- target-level manual steps are also derived from: + - degraded state + - auth pending state + - activation restrictions + +Recommended rule: + +- app-facing `doctor` JSON must preserve: + - top-level recovery warnings + - target-level manual steps + - activation and restriction-derived guidance +- the app must not treat top-level `doctor` warnings as incidental text that can be hidden without replacement + +Why: + +- these warnings already encode recovery truth from journal state +- hiding them would make desktop UX less informative than the existing backend + ## Recommended Contract Drafts These drafts are intentionally close to the current `integrationctl` domain model. @@ -1734,6 +2483,8 @@ They should extend the existing normalized result shape, not invent a second unr }, "resolved_version": "0.1.0", "workspace_root": "/repo", + "last_checked_at": "2026-04-18T12:00:00Z", + "last_updated_at": "2026-04-18T12:00:00Z", "policy": { "scope": "project", "auto_update": true, @@ -1746,7 +2497,56 @@ They should extend the existing normalized result shape, not invent a second unr "capability_surface": ["mcp"], "state": "installed", "activation_state": "reload_pending", - "source_access_state": "ok" + "source_access_state": "ok", + "target_detail": { + "warnings": [ + "reload required before the plugin becomes active in existing Claude sessions" + ], + "owned_native_objects": [ + { + "kind": "config_file", + "path": "/repo/.claude/settings.json" + } + ], + "settings_files": [ + "/repo/.claude/settings.json" + ] + } + } + ] + } + ] +} +``` + +### Doctor report with recovery warnings + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "doctor", + "requested_targets": [], + "warning_count": 2, + "warnings": [ + "Operation op-degraded for context7 ended degraded - run plugin-kit-ai integrations repair context7.", + "Operation op-in-progress for context7 is still marked in_progress - inspect the journal and rerun repair if the process was interrupted." + ], + "summary": "Doctor: 1 installation(s), 2 open operation journal(s), 1 degraded target(s), 0 activation-pending target(s), 0 auth-pending target(s).", + "managed_entries": [ + { + "managed_entry_key": "project:/repo:context7", + "integration_id": "context7", + "policy": { + "scope": "project" + }, + "targets": [ + { + "target_id": "claude", + "state": "degraded", + "manual_steps": [ + "run plugin-kit-ai integrations repair context7" + ] } ] } @@ -1839,6 +2639,98 @@ They should extend the existing normalized result shape, not invent a second unr } ``` +### Dry-run lifecycle plan with blocking target + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "plan_add", + "requested_integration_id": "context7", + "requested_targets": ["claude"], + "requested_scope": "project", + "requested_workspace_root": "/repo", + "ok": true, + "warning_count": 0, + "warnings": [], + "summary": "Dry-run plan for integration \"context7\" at version 0.1.0.", + "managed_entries": [ + { + "managed_entry_key": "planned:context7", + "integration_id": "context7", + "policy": { + "scope": "project" + }, + "targets": [ + { + "target_id": "claude", + "action_class": "install_target", + "blocking": true, + "summary": "Install Claude plugin through a managed local marketplace", + "manual_steps": [ + "managed policy blocks adding this marketplace", + "ask an administrator to update the allowlist or seed configuration" + ], + "target_detail": { + "warnings": [ + "this target is currently blocked by managed policy" + ], + "settings_files": [ + "/repo/.claude/settings.json" + ] + }, + "paths_touched": [ + "/Users/example/.plugin-kit-ai/materialized/claude/context7", + "/repo/.claude/settings.json" + ] + } + ] + } + ] +} +``` + +### Dry-run update with adopted target + +```json +{ + "format": "plugin-kit-ai/integrations-report", + "schema_version": 1, + "report_kind": "plan_update", + "requested_integration_id": "context7", + "requested_targets": ["claude", "codex"], + "requested_scope": "user", + "ok": true, + "warning_count": 0, + "warnings": [], + "summary": "Dry-run update plan for \"context7\".", + "managed_entries": [ + { + "managed_entry_key": "user::context7", + "integration_id": "context7", + "policy": { + "scope": "user", + "adopt_new_targets": "auto" + }, + "targets": [ + { + "target_id": "claude", + "action_class": "update_version", + "blocking": false, + "summary": "Update managed Claude target" + }, + { + "target_id": "codex", + "action_class": "adopt_new_target", + "blocking": false, + "summary": "Adopt newly supported target codex" + } + ] + } + ] +} +``` + ### Mutating lifecycle result ```json @@ -1857,6 +2749,7 @@ They should extend the existing normalized result shape, not invent a second unr "operation_id": "add-context7-...", "summary": "Managed targets processed for integration \"context7\".", "integration_id": "context7", + "managed_entry_key": "project:/repo:context7", "targets": [ { "target_id": "claude", @@ -1879,6 +2772,53 @@ They should extend the existing normalized result shape, not invent a second unr } ``` +### Mutating lifecycle result with degraded persistence + +```json +{ + "format": "plugin-kit-ai/integrations-result", + "schema_version": 1, + "requested_integration_id": "context7", + "requested_targets": ["claude"], + "requested_scope": "project", + "requested_workspace_root": "/repo", + "ok": false, + "warning_count": 1, + "warnings": [ + "apply failed after partial progress; degraded state was persisted" + ], + "outcome": "degraded", + "report": { + "operation_id": "add-context7-...", + "summary": "Managed targets processed for integration \"context7\".", + "integration_id": "context7", + "managed_entry_key": "project:/repo:context7", + "targets": [ + { + "target_id": "claude", + "action_class": "install_target", + "state": "degraded", + "activation_state": "reload_pending", + "manual_steps": [ + "run repair before trusting this installation" + ], + "target_detail": { + "warnings": [ + "rollback could not fully restore native state" + ], + "owned_native_objects": [ + { + "kind": "config_file", + "path": "/repo/.claude/settings.json" + } + ] + } + } + ] + } +} +``` + ## Entry Derivation and Conflict Resolution This is the most important renderer rule-set in the whole integration. @@ -1922,8 +2862,8 @@ Each backend surface owns a different truth: Recommended deterministic algorithm: -1. Load `list` and build a managed map keyed by `integration_id`. -2. Overlay `doctor` onto that managed map by `integration_id + target_id`. +1. Load `list` and build a managed map keyed by `managed_entry_key`. +2. Overlay `doctor` onto that managed map by `managed_entry_key + target_id`. 3. Load `catalog` and build a universal catalog map keyed by `integration_id`. 4. For every managed entry: - create one `universal_installed` entry @@ -2013,6 +2953,10 @@ Recommended renderer ownership matrix: - `entry_kind` - derived by the app from surface class, never from heuristics +- `entry_id` + - `managed_entry_key` for `universal_installed` + - stable catalog `integration_id` for `universal_available` + - stable discovery identity for `native_external_installed` - `integration_id` - `catalog` or `list` - never guessed from discovery display name alone @@ -2159,6 +3103,36 @@ Expected result: - no stronger action unlock - no `exact` badge or stronger “same plugin” copy +### Example 7 - conflicting managed install intent for the same integration + +Situation: + +- backend still uses single-record-per-integration state identity +- `list` already contains managed `context7` in `user` scope +- user now requests managed `context7` in `project` scope for `/repo` + +Expected result: + +- backend returns structured conflict or explicit unsupported-state error +- app explains that the same integration is already present in managed state under a different install context +- app does not pretend both managed installs now coexist +- app does not silently overwrite the existing entry in UI optimism + +### Example 8 - update plans newly adopted target support + +Situation: + +- managed `context7` currently exists only for `claude` +- next manifest now also supports `codex` +- managed policy says `adopt_new_targets=auto` + +Expected result: + +- update review shows ordinary update work for existing targets +- update review also shows explicit `adopt_new_target` work for `codex` +- app does not bury this inside generic update prose +- if policy were `manual`, app would instead surface a warning, not pretend adoption will happen + ## Command Semantics Matrix The app integration should treat command classes differently. @@ -2189,6 +3163,47 @@ The app integration should treat command classes differently. - scope where relevant - workspace root where relevant - post-mutation refresh in the app must use the origin operation context, not global last-view state +- for `add`, state-conflict semantics must be explicit: + - either surfaced in planning/preflight + - or represented as a known late-apply conflict the app blocks before confirm + +### Source-resolution and preview cost rule + +Current backend behavior is not symmetric across lifecycle previews. + +From current code: + +- `add` + - plan resolves requested source + - may clone/fetch remote source +- `update_version` + - dry-run plan resolves current stored source + - may clone/fetch remote source before review is shown +- `remove_orphaned_target` + - dry-run plan does **not** resolve source + - apply does resolve source +- `repair_drift` + - dry-run plan does **not** resolve source + - apply does resolve source + +Recommended rule: + +- app UX should not assume every preview is cheap or local-only +- update review in particular should be allowed a slower “checking source / comparing manifest” state +- timeout policy should distinguish: + - cheap state-only views like `list` and `doctor` + - potentially source-resolving lifecycle previews like `add` and `update` + +### Mutation outcome matrix + +| Payload outcome | Meaning | UI expectation | +|---|---|---| +| `planned` | dry-run only, no mutation applied | show review state, not success | +| `applied` | mutation committed | show success with target details | +| `rolled_back` | mutation failed after progress but native/state effects were rolled back | show failure, but do not imply degraded managed state remains | +| `degraded` | mutation failed and degraded managed state was persisted | show failure with high-visibility repair guidance | +| `partial_success` | some requested targets succeeded while others did not | keep target-granular result rendering | +| `failed` | no structured recovery class beyond failure | show failure with backend reason | ### Payload vs process-failure rule @@ -2250,8 +3265,16 @@ Universal detail must show: - category only when curated metadata exists - target support - scope support +- managed freshness when installed: + - last checked + - last updated - README/detail content - lifecycle actions where supported +- lifecycle target detail from backend truth: + - warnings + - owned native objects when relevant + - settings/config files when relevant + - blocking or activation guidance without app-side inference Native external detail must show: @@ -2302,41 +3325,66 @@ Why: ### Must-have for phase 0 -1. `integrations --format json` around the current normalized lifecycle model - 🎯 10 🛡️ 10 🧠 4 +1. `integrations --format json` around the current normalized lifecycle model + 🎯 10 🛡️ 10 🧠 4 Approximate change size: `150-300` lines -2. integration-level fields in managed lifecycle JSON - Needed because current `Report.Targets` do not identify which integration a target belongs to. - 🎯 10 🛡️ 10 🧠 5 +2. integration-level fields in managed lifecycle JSON + Needed because current `Report.Targets` do not identify which integration a target belongs to. + 🎯 10 🛡️ 10 🧠 5 Approximate change size: `120-240` lines -3. `integrations catalog --format json` - 🎯 10 🛡️ 9 🧠 5 - Approximate change size: `180-350` lines - -4. `integrations discover --format json` - 🎯 10 🛡️ 10 🧠 6 - Approximate change size: `220-450` lines - -5. `--workspace-root` - 🎯 9 🛡️ 10 🧠 4 +3. explicit managed-state identity policy + Needed because current state upsert/find/remove is keyed only by `integration_id`. + 🎯 10 🛡️ 10 🧠 5 Approximate change size: `80-180` lines -6. capability and scope metadata in catalog/discovery - 🎯 9 🛡️ 9 🧠 4 - Approximate change size: `80-160` lines - -7. stable detail path or detail endpoint - 🎯 8 🛡️ 8 🧠 4 +4. explicit conflict-timing policy for `add` + Needed because current dry-run planning can succeed while apply later fails with `integration already exists in state`. + 🎯 10 🛡️ 9 🧠 4 Approximate change size: `60-140` lines -8. discovery trust/manageability metadata - 🎯 8 🛡️ 9 🧠 5 +5. plan blocking fidelity in app-facing JSON + Needed because current `AdapterPlan` knows `Blocking`, but current target report projection does not preserve it. + 🎯 10 🛡️ 10 🧠 4 + Approximate change size: `60-140` lines + +6. target-detail fidelity in app-facing lifecycle JSON + Needed because current adapter layer already knows warnings, owned objects, and settings context, but current `TargetReport` drops them. + 🎯 10 🛡️ 10 🧠 4 Approximate change size: `80-180` lines -9. provenance metadata - 🎯 8 🛡️ 9 🧠 4 +7. `integrations catalog --format json` + 🎯 10 🛡️ 9 🧠 5 + Approximate change size: `180-350` lines + +8. `integrations discover --format json` + 🎯 10 🛡️ 10 🧠 6 + Approximate change size: `220-450` lines + +9. explicit app-mode service construction decoupled from implicit cwd + Needed because current `newService()` still derives workspace/repo context from `os.Getwd()`. + 🎯 10 🛡️ 10 🧠 5 + Approximate change size: `100-220` lines + +10. `--workspace-root` and explicit request-context propagation across plan and apply + 🎯 9 🛡️ 10 🧠 4 + Approximate change size: `80-180` lines + +11. capability, scope, and source-kind projection in catalog/discovery + 🎯 9 🛡️ 9 🧠 4 + Approximate change size: `80-160` lines + +12. stable detail path or detail endpoint + 🎯 8 🛡️ 8 🧠 4 + Approximate change size: `60-140` lines + +13. discovery trust/manageability metadata + 🎯 8 🛡️ 9 🧠 5 + Approximate change size: `80-180` lines + +14. provenance metadata + 🎯 8 🛡️ 9 🧠 4 Approximate change size: `60-140` lines ### Explicitly not required for the first app rollout @@ -2379,6 +3427,25 @@ Recommended rule: - introduce a new normalized plugin-entry layer for the plugin-kit-backed flow - keep the legacy Claude-only model behind the feature flag until rollout is complete +### Migration-safe IPC and API boundary + +Current preload and IPC plugin APIs are shaped around the legacy Claude-marketplace model: + +- `getAll(projectPath?, forceRefresh?) -> EnrichedPlugin[]` +- legacy install and uninstall request shapes + +Recommended rule: + +- do not silently change the meaning of the legacy plugin IPC payloads during rollout +- introduce a plugin-kit-backed API boundary separately, or clearly version the payload shape +- keep legacy and plugin-kit flows selectable behind the feature flag until the mixed-entry model is proven + +Why: + +- this reduces rollout blast radius +- it keeps renderer tests and preload contracts easier to reason about +- it avoids “same method name, different product model” ambiguity + ### Required app-side model split Current `EnrichedPlugin` is shaped around one catalog plus installed counts: @@ -2437,6 +3504,17 @@ Recommended rule: - stale responses must never overwrite newer state - post-mutation refresh must use the origin operation context +Recommended keying rule: + +- `universal_installed` cache and mutation keys should use `managed_entry_key`, not only `integration_id` +- `integration_id` alone is not a safe future-proof cache key for managed lifecycle entries +- the app should treat payloads that omit `managed_entry_key` as legacy-incompatible for the plugin-kit-backed flow +- mutation UI state should also preserve backend outcome class: + - `applied` + - `rolled_back` + - `degraded` + instead of collapsing every non-success into one generic failure bucket + ### Feature flag Recommended app flag: @@ -2455,7 +3533,9 @@ Ship: - JSON envelopes - `catalog` - `discover` +- app-safe service construction - `--workspace-root` +- target-detail fidelity for warnings / owned objects / blocking state - schema docs - source/provenance metadata @@ -2464,7 +3544,10 @@ Acceptance: - contracts are versioned and testable - command classes are clearly read-only vs mutating - managed lifecycle JSON includes integration-level context +- managed-state identity policy is explicit and testable +- blocked vs applyable plans are distinguishable in machine-readable payloads - views are internally consistent across `catalog`, `discover`, `list`, and `doctor` +- project-sensitive app mode no longer depends on implicit `cwd` ### Phase 1 - read-only app integration @@ -2484,8 +3567,11 @@ Acceptance: - native external entries render truthfully - universal catalog renders truthfully +- target detail renders from backend truth, not renderer guesses +- target-specific effective metadata does not leak into shared universal card identity or summary - no misleading install button on native external entries - page remains useful when `catalog` or `discover` partially fail +- source-resolving previews such as `update` have truthful loading and timeout behavior - warm-load performance remains acceptable - plugin-kit-backed renderer state uses the new normalized entry model instead of overloading legacy `EnrichedPlugin` @@ -2526,6 +3612,20 @@ Acceptance: ## Recommended First PR Sequence +### PR 0 - freeze backend semantics that shape every later contract + +Ship: + +- explicit managed-state identity policy +- explicit conflict-timing policy for `add` +- explicit app-mode service-construction policy +- explicit planning-context policy for project-sensitive commands + +Must not do: + +- silently preserve current implicit behavior just because the CLI can tolerate it +- let the app contract depend on hidden `cwd` or late state-lock conflicts + ### PR 1 - JSON envelopes for existing lifecycle commands Ship: @@ -2544,10 +3644,12 @@ Must not do: Ship: - `integration_id` +- `managed_entry_key` - source refs - policy scope - workspace root - target grouping under managed entries +- target-detail fidelity for warnings / owned objects / settings context Must not do: @@ -2558,6 +3660,7 @@ Must not do: Ship: - `--workspace-root` +- app-safe service construction for project-sensitive flows - project-sensitive commands stop depending on implicit `cwd` Must not do: @@ -2618,6 +3721,9 @@ Required: - one golden fixture exists - one failure fixture exists - one compatibility test exists +- one fixture proves target-detail fidelity for warnings or owned-object context +- one fixture proves adopted-target update semantics or explicit adopted-target warning behavior +- one fixture proves grouped lifecycle freshness fields are projected ### Backend discovery PRs @@ -2626,9 +3732,18 @@ Required: - at least one Claude discovery fixture - at least one Codex discovery fixture - explicit observed-state coverage +- explicit Codex `prepared` and `degraded` fixture coverage - explicit manageability coverage - no destructive side effects in read-only commands +### Backend doctor/report PRs + +Required: + +- one fixture proves top-level `doctor` recovery warnings survive JSON projection +- one fixture proves target-level manual steps survive JSON projection +- interrupted/degraded operation guidance is not flattened away + ### App read-only integration PRs Required: @@ -2644,6 +3759,8 @@ Required: - explicit dry-run protection - target-level partial success rendering +- `rolled_back` vs `degraded` outcome rendering is distinct and truthful +- source-resolving preview states are handled explicitly, not as generic loading guesswork - stale-response protection - safe retry behavior @@ -2760,6 +3877,53 @@ Best resolution: - test them with golden fixtures - never let renderer heuristics rebuild one layer from another +### 9. Managed state identity and multiplicity + +🎯 7 🛡️ 10 🧠 7 + +Why this is hard: + +- current state operations are keyed only by `integration_id` +- that may be fine for the current CLI mental model, but it is not enough if the app later wants parallel managed records for the same integration across scopes or workspaces +- if this stays implicit, the app can easily promise install combinations the backend will silently overwrite or collapse + +Best resolution: + +- make the current single-record-per-integration rule explicit in phase 0, or upgrade backend state identity before the app promises multiplicity +- return structured conflicts instead of silent replacement for incompatible install intents + +### 10. Adopted-target update semantics + +🎯 7 🛡️ 9 🧠 6 + +Why this is hard: + +- current `update_version` planning may also create `adopt_new_target` work +- that behavior is policy-sensitive and depends on next manifest deliveries +- if the app hides this inside generic update review, users can approve broader changes than they realized + +Best resolution: + +- preserve adopted-target work as explicit target-level semantics in plan/result payloads +- show it distinctly in review and result UI +- if backend cannot expose it clearly yet, keep update UX conservative instead of pretending it is plain update-only work + +### 11. Managed freshness truth + +🎯 8 🛡️ 9 🧠 4 + +Why this is hard: + +- current managed state already tracks freshness timestamps +- but current public lifecycle report does not project them +- without them, desktop UI can accidentally present stored state as if it were freshly revalidated remote truth + +Best resolution: + +- expose `last_checked_at` and `last_updated_at` in grouped lifecycle JSON +- use explicit freshness copy in UI +- never imply remote verification unless a source-resolving action actually ran + ## E2E and Contract Testing ### plugin-kit-ai @@ -2882,6 +4046,17 @@ Keep separate fields for: - backend-manageable lifecycle support - app-primary action targets +### 12. Should phase 1 support multiple parallel managed installs of the same integration across scopes or workspaces? + +Recommended default: **No**. + +Keep the current single-record-per-integration model explicit until backend state identity is upgraded. + +That means: + +- same-`integration_id` managed installs should conflict +- the app should not promise parallel managed `user` + `project` presence for one universal plugin yet + ## Phase-1 Conservative Defaults These defaults are intentional. @@ -2899,6 +4074,7 @@ They should not be treated as missing polish or as accidental gaps. | stale catalog while managed installs exist | keep managed entry visible | `list` wins for managed existence | | stale discover while catalog works | keep catalog visible and warn | do not claim “no installed plugins” | | grouped lifecycle keys missing | treat as incompatible payload | do not reconstruct entry groups in app | +| same integration across multiple managed scopes/workspaces | do not promise it until backend identity model supports it | avoids silent state overwrite | ## Final Recommendation diff --git a/packages/agent-graph/src/canvas/draw-handoff-cards.ts b/packages/agent-graph/src/canvas/draw-handoff-cards.ts index 098e439f..a0e62860 100644 --- a/packages/agent-graph/src/canvas/draw-handoff-cards.ts +++ b/packages/agent-graph/src/canvas/draw-handoff-cards.ts @@ -3,7 +3,10 @@ import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants import type { CameraTransform } from '../hooks/useGraphCamera'; import { getHandoffAnchorTarget } from '../layout/launchAnchor'; import type { GraphNode } from '../ports/types'; -import type { TransientHandoffCard } from '../ui/transientHandoffs'; +import { + getTransientHandoffCardAlpha, + type TransientHandoffCard, +} from '../ui/transientHandoffs'; import { truncateText } from './draw-misc'; import { hexWithAlpha, measureTextCached } from './render-cache'; @@ -20,24 +23,24 @@ export function drawHandoffCards( const { cards, nodeMap, time, camera, viewport } = params; if (cards.length === 0) return; - const stackIndexByDestination = new Map(); + const stackIndexByAnchor = new Map(); let drawnCount = 0; for (const card of cards) { if (drawnCount >= HANDOFF_CARD.maxVisible) break; - const destinationNode = nodeMap.get(card.destinationNodeId); - if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue; + const anchorNode = nodeMap.get(card.anchorNodeId); + if (!anchorNode || anchorNode.x == null || anchorNode.y == null) continue; - const alpha = getCardAlpha(card, time); + const alpha = getTransientHandoffCardAlpha(card, time); if (alpha <= MIN_VISIBLE_OPACITY) continue; const previewLines = buildPreviewLines(ctx, card.preview); const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight; - const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0; - stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1); + const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; + stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); const position = getCardPosition({ - node: destinationNode, + node: anchorNode, camera, viewport, height, @@ -59,15 +62,6 @@ export function drawHandoffCards( } } -function getCardAlpha(card: TransientHandoffCard, time: number): number { - const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); - const fadeOutRemaining = card.expiresAt - time; - const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds - ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) - : 1; - return Math.max(0, Math.min(1, fadeIn * fadeOut)); -} - function getCardPosition(params: { node: GraphNode; camera: CameraTransform; diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 6eda9d4d..cd74c996 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -10,6 +10,8 @@ export { GraphView } from './ui/GraphView'; export type { GraphViewProps } from './ui/GraphView'; export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane'; +export { getTransientHandoffCardAlpha } from './ui/transientHandoffs'; +export type { TransientHandoffCard } from './ui/transientHandoffs'; // ─── Port Interfaces (for adapters in host project) ───────────────────────── export type { GraphDataPort } from './ports/GraphDataPort'; diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 070323ea..a0c31ab9 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({ ); const leadActivityRect = leadSlotFrame.activityColumnRect; const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0); - const leadCentralReservedBlock = leadSlotFrame.bounds; + const leadCentralReservedBlock = buildLeadCentralReservedBlock({ + leadCoreRect, + leadSlotFrame, + }); const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); const centralCollisionRects = buildCentralCollisionRects({ - leadCentralReservedBlock, + leadCoreRect, + leadSlotFrame, unassignedTaskRect, }); const runtimeCentralExclusion = padRect( @@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({ } function buildCentralCollisionRects(args: { - leadCentralReservedBlock: StableRect; + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; unassignedTaskRect: StableRect | null; }): StableRect[] { - const rects = [args.leadCentralReservedBlock]; + const rects = [ + args.leadCoreRect, + args.leadSlotFrame.processBandRect, + args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.kanbanBandRect, + ]; if (args.unassignedTaskRect) { rects.push(args.unassignedTaskRect); } return rects; } +function buildLeadCentralReservedBlock(args: { + leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; +}): StableRect { + return unionRects([ + args.leadCoreRect, + args.leadSlotFrame.processBandRect, + args.leadSlotFrame.activityColumnRect, + args.leadSlotFrame.kanbanBandRect, + ]); +} + function padCentralCollisionRects( rects: readonly StableRect[], padding: number @@ -648,6 +670,12 @@ function validateLeadSnapshotRects( if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) { + return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) { + return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' }; + } if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) { return { valid: false, @@ -660,9 +688,6 @@ function validateLeadSnapshotRects( reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', }; } - if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) { - return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' }; - } if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; } diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 516e3a5f..c4f13588 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -34,6 +34,7 @@ import { import { createTransientHandoffState, selectRenderableTransientHandoffCards, + type TransientHandoffCard, updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; @@ -70,6 +71,14 @@ export interface GraphCanvasHandle { draw: (state: GraphDrawState) => void; /** Get the canvas element for coordinate transforms */ getCanvas: () => HTMLCanvasElement | null; + /** Read current transient handoff cards for DOM HUD rendering */ + getTransientHandoffSnapshot: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: TransientHandoffCard[]; + time: number; + }; } export interface GraphCanvasProps { @@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef(funct const activeParticleEdgesCache = useRef(new Set()); const handoffStateRef = useRef(createTransientHandoffState()); const lastTeamNameRef = useRef(null); + const lastDrawTimeRef = useRef(0); // Imperative draw function — called from RAF, NOT from React render useImperativeHandle( @@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef(funct if (w === 0 || h === 0) return; try { + lastDrawTimeRef.current = state.time; if (lastTeamNameRef.current !== state.teamName) { handoffStateRef.current = createTransientHandoffState(); lastTeamNameRef.current = state.teamName; @@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef(funct focusNodeIds: state.focusNodeIds, focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds, } - ).filter( - (card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member' - ); + ).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member'); drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) @@ -419,6 +428,10 @@ export const GraphCanvas = forwardRef(funct } }, getCanvas: () => canvasRef.current, + getTransientHandoffSnapshot: (options) => ({ + cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options), + time: lastDrawTimeRef.current, + }), }), [showHexGrid, showStarField, bloomIntensity] ); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index c20ed598..c9f1d744 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { buildFocusState } from './buildFocusState'; +import type { TransientHandoffCard } from './transientHandoffs'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; @@ -73,11 +74,16 @@ export interface GraphViewProps { leadNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; getActivityWorldRect: (ownerNodeId: string) => StableRect | null; + getTransientHandoffSnapshot: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { cards: TransientHandoffCard[]; time: number }; getCameraZoom: () => number; worldToScreen: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; getViewportSize: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -250,6 +256,17 @@ export function GraphView({ (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), [] ); + const getTransientHandoffSnapshot = useCallback( + (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => + canvasHandle.current?.getTransientHandoffSnapshot(options) ?? { + cards: [], + time: 0, + }, + [] + ); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.x == null || node?.y == null) { @@ -946,11 +963,13 @@ export function GraphView({ {renderHud({ getLaunchAnchorScreenPlacement, getActivityWorldRect, + getTransientHandoffSnapshot, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, focusNodeIds: focusState.focusNodeIds, + focusEdgeIds: focusState.focusEdgeIds, })} ) : null} diff --git a/packages/agent-graph/src/ui/transientHandoffs.ts b/packages/agent-graph/src/ui/transientHandoffs.ts index 78465e0f..54dbff98 100644 --- a/packages/agent-graph/src/ui/transientHandoffs.ts +++ b/packages/agent-graph/src/ui/transientHandoffs.ts @@ -8,12 +8,16 @@ export interface TransientHandoffCard { edgeId: string; sourceNodeId: string; destinationNodeId: string; + anchorNodeId: string; + anchorKind: GraphNode['kind']; sourceLabel: string; destinationLabel: string; destinationKind: GraphNode['kind']; kind: HandoffParticleKind; color: string; preview?: string; + relatedTaskId?: string; + relatedTaskDisplayId?: string; count: number; activatedAt: number; updatedAt: number; @@ -70,6 +74,12 @@ export function updateTransientHandoffState( const sourceNode = nodeMap.get(sourceNodeId); const destinationNode = nodeMap.get(destinationNodeId); if (!sourceNode || !destinationNode) continue; + const anchorNode = + destinationNode.kind === 'lead' || destinationNode.kind === 'member' + ? destinationNode + : sourceNode.kind === 'lead' || sourceNode.kind === 'member' + ? sourceNode + : destinationNode; const previewText = normalizePreviewText(particle.preview ?? particle.label); if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) { @@ -86,12 +96,16 @@ export function updateTransientHandoffState( edgeId: edge.id, sourceNodeId, destinationNodeId, + anchorNodeId: anchorNode.id, + anchorKind: anchorNode.kind, sourceLabel: sourceNode.label, destinationLabel: destinationNode.label, destinationKind: destinationNode.kind, kind: particle.kind, color: particle.color, preview: previewText ?? existing?.preview, + relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0], + relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]), count: nextCount, activatedAt: existing?.activatedAt ?? time, updatedAt: time, @@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards( const focusEdgeIds = options?.focusEdgeIds ?? null; const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0; - const byDestination = new Map(); + const byAnchor = new Map(); for (const card of state.cardsByKey.values()) { if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue; - const destinationCards = byDestination.get(card.destinationNodeId); - if (destinationCards) { - destinationCards.push(card); + const anchorCards = byAnchor.get(card.anchorNodeId); + if (anchorCards) { + anchorCards.push(card); } else { - byDestination.set(card.destinationNodeId, [card]); + byAnchor.set(card.anchorNodeId, [card]); } } const selected: TransientHandoffCard[] = []; - for (const cards of byDestination.values()) { + for (const cards of byAnchor.values()) { cards.sort((a, b) => b.updatedAt - a.updatedAt); selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination)); } @@ -145,10 +159,21 @@ function isCardInFocus( return ( !!focusEdgeIds?.has(card.edgeId) || !!focusNodeIds?.has(card.sourceNodeId) || - !!focusNodeIds?.has(card.destinationNodeId) + !!focusNodeIds?.has(card.destinationNodeId) || + !!focusNodeIds?.has(card.anchorNodeId) ); } +export function getTransientHandoffCardAlpha(card: TransientHandoffCard, time: number): number { + const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds); + const fadeOutRemaining = card.expiresAt - time; + const fadeOut = + fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds + ? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds) + : 1; + return Math.max(0, Math.min(1, fadeIn * fadeOut)); +} + function normalizePreviewText(text: string | undefined): string | undefined { if (!text) return undefined; const normalized = text @@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined { function isLowSignalInboxPreview(preview: string | undefined): boolean { return preview === 'idle'; } + +function buildTaskDisplayId(taskId: string | undefined): string | undefined { + if (!taskId) { + return undefined; + } + return taskId.slice(0, 8); +} diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 39756a62..69b58351 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -181,7 +181,16 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias); + this.#buildTaskNodes( + nodes, + edges, + teamData, + teamName, + commentReadState, + memberNodeIdByAlias, + leadId, + leadName + ); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( @@ -560,7 +569,9 @@ export class TeamGraphAdapter { data: TeamGraphData, teamName: string, commentReadState?: Record, - memberNodeIdByAlias?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap, + leadId?: string, + leadName?: string ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -581,7 +592,12 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; + const ownerMemberId = + leadId && memberNodeIdByAlias + ? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias) + : task.owner + ? (memberNodeIdByAlias?.get(task.owner) ?? null) + : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -1237,6 +1253,25 @@ export class TeamGraphAdapter { return memberNodeIdByAlias.get(name) ?? leadId; } + static #resolveTaskOwnerId( + ownerName: string | null | undefined, + leadId: string, + leadName: string | undefined, + memberNodeIdByAlias: ReadonlyMap + ): string | null { + if (!ownerName?.trim()) { + return null; + } + const normalized = ownerName.trim().toLowerCase(); + if (normalized === 'user' || normalized === 'team-lead') { + return leadId; + } + if (normalized === leadName?.trim().toLowerCase()) { + return leadId; + } + return memberNodeIdByAlias.get(ownerName) ?? null; + } + /** Extract external team name from cross-team "from" field like "team-b.alice" */ static #extractExternalTeamName(from: string): string | null { const dotIdx = from.indexOf('.'); diff --git a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx new file mode 100644 index 00000000..e75f5696 --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx @@ -0,0 +1,96 @@ +import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; +import { + resolveMessageRenderProps, + type MessageContext, +} from '@renderer/components/team/activity/activityMessageContext'; + +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; +import type { InboxMessage } from '@shared/types'; + +interface GraphActivityCardProps { + message: InboxMessage; + teamName: string; + messageContext: MessageContext; + teamNames: string[]; + teamColorByName: ReadonlyMap; + isUnread?: boolean; + zebraShade?: boolean; + className?: string; + onClick?: () => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: ( + memberName: string, + options?: { + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } + ) => void; +} + +export const GraphActivityCard = ({ + message, + teamName, + messageContext, + teamNames, + teamColorByName, + isUnread = false, + zebraShade = false, + className, + onClick, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphActivityCardProps): React.JSX.Element => { + const renderProps = resolveMessageRenderProps(message, messageContext); + const interactive = Boolean(onClick); + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick?.(); + } + } + : undefined + } + onDragStart={(event) => { + event.preventDefault(); + }} + > + onOpenMemberProfile?.(memberName)} + onTaskIdClick={onOpenTaskDetail} + zebraShade={zebraShade} + teamNames={teamNames} + teamColorByName={teamColorByName} + /> +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index d79d245c..e2a5b82f 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,11 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; -import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; -import { - buildMessageContext, - resolveMessageRenderProps, -} from '@renderer/components/team/activity/activityMessageContext'; +import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; @@ -17,6 +13,7 @@ import { type InlineActivityEntry, } from '../../core/domain/buildInlineActivityEntries'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; +import { GraphActivityCard } from './GraphActivityCard'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; @@ -288,38 +285,10 @@ export const GraphActivityHud = ({ visibleLanes, ]); - const expandedItemsByKey = useMemo(() => { - const items = new Map(); - for (const lane of visibleLanes) { - for (const entry of lane.entries) { - const key = toMessageKey(entry.message); - items.set(key, { type: 'message', message: entry.message }); - } - } - return items; - }, [visibleLanes]); - - const handleExpandItem = useCallback( - (key: string) => { - const next = expandedItemsByKey.get(key); - if (next) { - setExpandedItem(next); - } - }, - [expandedItemsByKey] - ); - const handleMessageClick = useCallback((item: TimelineItem) => { setExpandedItem(item); }, []); - const handleMemberNameClick = useCallback( - (memberName: string) => { - onOpenMemberProfile?.(memberName); - }, - [onOpenMemberProfile] - ); - const handleMemberClick = useCallback( (member: ResolvedTeamMember) => { onOpenMemberProfile?.(member.name); @@ -453,10 +422,6 @@ export const GraphActivityHud = ({ ) : null} {lane.entries.map((entry, index) => { const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps( - entry.message, - messageContext - ); const timelineItem: TimelineItem = { type: 'message', message: entry.message, @@ -477,26 +442,17 @@ export const GraphActivityHud = ({ } }} > - handleMessageClick(timelineItem)} + onOpenTaskDetail={onOpenTaskDetail} + onOpenMemberProfile={onOpenMemberProfile} /> ); diff --git a/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx b/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx new file mode 100644 index 00000000..72406bbe --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphTransientHandoffHud.tsx @@ -0,0 +1,176 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { + ACTIVITY_LANE, + getTransientHandoffCardAlpha, + type TransientHandoffCard, +} from '@claude-teams/agent-graph'; +import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext'; +import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; + +import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; + +import { buildTransientHandoffMessage } from './buildTransientHandoffMessage'; +import { GraphActivityCard } from './GraphActivityCard'; + +interface GraphTransientHandoffHudProps { + teamName: string; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { cards: TransientHandoffCard[]; time: number }; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; + enabled?: boolean; +} + +const CARD_WIDTH = ACTIVITY_LANE.width; +const CARD_HEIGHT = 72; +const STACK_GAP = 10; + +export const GraphTransientHandoffHud = ({ + teamName, + getTransientHandoffSnapshot = () => ({ cards: [], time: 0 }), + getCameraZoom = () => 1, + worldToScreen, + getNodeWorldPosition = () => null, + focusNodeIds, + focusEdgeIds, + enabled = true, +}: GraphTransientHandoffHudProps): React.JSX.Element | null => { + const worldLayerRef = useRef(null); + const shellRefs = useRef(new Map()); + const signatureRef = useRef(''); + const [cards, setCards] = useState([]); + const { teamData, teams } = useGraphActivityContext(teamName); + const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); + + useEffect(() => { + signatureRef.current = ''; + setCards([]); + }, [teamName]); + + useLayoutEffect(() => { + if (!enabled) { + setCards([]); + return; + } + + let frameId = 0; + const tick = (): void => { + const snapshot = getTransientHandoffSnapshot({ + focusNodeIds, + focusEdgeIds, + }); + const nextCards = snapshot.cards.filter( + (card) => card.anchorKind === 'lead' || card.anchorKind === 'member' + ); + const nextSignature = nextCards + .map((card) => `${card.key}:${card.count}:${card.updatedAt}:${card.anchorNodeId}`) + .join('|'); + if (nextSignature !== signatureRef.current) { + signatureRef.current = nextSignature; + setCards(nextCards); + } + + 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 stackIndexByAnchor = new Map(); + for (const card of nextCards) { + const shell = shellRefs.current.get(card.key); + if (!shell) { + continue; + } + + const nodeWorld = getNodeWorldPosition(card.anchorNodeId); + const alpha = getTransientHandoffCardAlpha(card, snapshot.time); + if (!nodeWorld || !worldToScreen || alpha <= 0.001) { + shell.style.opacity = '0'; + continue; + } + + const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0; + stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1); + const lift = stackIndex * (CARD_HEIGHT * 0.34 + STACK_GAP); + const scale = 0.94 + alpha * 0.06; + + shell.style.left = `${Math.round(nodeWorld.x)}px`; + shell.style.top = `${Math.round(nodeWorld.y)}px`; + shell.style.opacity = String(alpha); + shell.style.transform = `translate(-50%, calc(-50% - ${lift.toFixed(1)}px)) scale(${scale.toFixed(3)})`; + } + + frameId = window.requestAnimationFrame(tick); + }; + + tick(); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [ + enabled, + focusEdgeIds, + focusNodeIds, + getCameraZoom, + getNodeWorldPosition, + getTransientHandoffSnapshot, + worldToScreen, + ]); + + const handoffMessages = useMemo( + () => + cards.map((card, index) => ({ + card, + message: buildTransientHandoffMessage(teamName, card), + zebraShade: index % 2 === 1, + })), + [cards, teamName] + ); + + if (!enabled || !teamData || cards.length === 0) { + return null; + } + + return ( +
+ {handoffMessages.map(({ card, message, zebraShade }) => ( +
{ + shellRefs.current.set(card.key, element); + }} + className="pointer-events-none absolute z-[9] origin-center opacity-0 transition-opacity duration-150 ease-out" + style={{ + width: `${CARD_WIDTH}px`, + maxWidth: `${CARD_WIDTH}px`, + }} + onDragStart={(event) => { + event.preventDefault(); + }} + > + +
+ ))} +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 78fa9802..69e3bc65 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphNodePopover } from './GraphNodePopover'; import { GraphProvisioningHud } from './GraphProvisioningHud'; +import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; import type { @@ -140,13 +141,30 @@ export const TeamGraphOverlay = ({ height: number; } | null; getCameraZoom?: () => number; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: import('@claude-teams/agent-graph').TransientHandoffCard[]; + time: number; + }; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusEdgeIds?: ReadonlySet | null; }; const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> + number; + getTransientHandoffSnapshot?: (options?: { + focusNodeIds?: ReadonlySet | null; + focusEdgeIds?: ReadonlySet | null; + }) => { + cards: import('@claude-teams/agent-graph').TransientHandoffCard[]; + time: number; + }; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + focusEdgeIds?: ReadonlySet | null; }; const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> + ${card.destinationLabel}`; +} + +function buildText(card: TransientHandoffCard): string { + const preview = card.preview?.trim(); + switch (card.kind) { + case 'task_assign': { + const taskLabel = card.relatedTaskDisplayId ?? card.relatedTaskId ?? 'task'; + return `New task assigned to you: ${taskLabel}${preview ? ` - ${preview}` : ''}`; + } + case 'task_comment': + return preview ?? `${card.sourceLabel} added a comment`; + case 'review_request': + return preview ?? `Review requested by ${card.sourceLabel}`; + case 'review_response': + return preview ?? `Review response from ${card.sourceLabel}`; + case 'inbox_message': + default: + return preview ?? `${card.sourceLabel} -> ${card.destinationLabel}`; + } +} + +export function buildTransientHandoffMessage( + teamName: string, + card: TransientHandoffCard +): InboxMessage { + const messageKind = card.kind === 'task_comment' ? 'task_comment_notification' : 'default'; + const taskRefs = buildTaskRefs(teamName, card); + + return { + from: card.sourceLabel, + to: card.destinationLabel, + text: buildText(card), + timestamp: new Date(card.updatedAt * 1000).toISOString(), + read: true, + summary: buildSummary(card), + color: card.color, + messageId: `graph-handoff:${card.key}`, + source: 'inbox', + messageKind, + taskRefs, + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index f73c9012..42b9d15a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -978,6 +978,7 @@ async function initializeServices(): Promise { boardTaskExactLogsService, boardTaskExactLogDetailService, teammateToolTracker ?? undefined, + teamLogSourceTracker, branchStatusService ?? undefined, { rewire: rewireContextEvents, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 772501e1..b7636889 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -106,6 +106,7 @@ import type { ServiceContextRegistry, SshConnectionManager, TeamDataService, + TeamLogSourceTracker, TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, @@ -141,6 +142,7 @@ export function initializeIpcHandlers( boardTaskExactLogsService: BoardTaskExactLogsService, boardTaskExactLogDetailService: BoardTaskExactLogDetailService, teammateToolTracker: TeammateToolTracker | undefined, + teamLogSourceTracker: TeamLogSourceTracker | undefined, branchStatusService: BranchStatusService | undefined, contextCallbacks: { rewire: (context: ServiceContext) => void; @@ -184,6 +186,7 @@ export function initializeIpcHandlers( memberStatsComputer, teamBackupService, teammateToolTracker, + teamLogSourceTracker, branchStatusService, boardTaskActivityService, boardTaskActivityDetailService, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 01fd2952..c11931e3 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -57,6 +57,7 @@ import { TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, @@ -136,6 +137,7 @@ import type { BranchStatusService, MemberStatsComputer, TeamDataService, + TeamLogSourceTracker, TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, @@ -460,6 +462,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; +let teamLogSourceTracker: TeamLogSourceTracker | null = null; let branchStatusService: BranchStatusService | null = null; let boardTaskActivityService: BoardTaskActivityService | null = null; let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null; @@ -496,6 +499,7 @@ export function initializeTeamHandlers( statsComputer?: MemberStatsComputer, backupService?: TeamBackupService, toolTracker?: TeammateToolTracker, + logSourceTracker?: TeamLogSourceTracker, branchTracker?: BranchStatusService, taskActivityService?: BoardTaskActivityService, taskActivityDetailService?: BoardTaskActivityDetailService, @@ -510,6 +514,7 @@ export function initializeTeamHandlers( memberStatsComputer = statsComputer ?? null; teamBackupService = backupService ?? null; teammateToolTracker = toolTracker ?? null; + teamLogSourceTracker = logSourceTracker ?? null; branchStatusService = branchTracker ?? null; boardTaskActivityService = taskActivityService ?? null; boardTaskActivityDetailService = taskActivityDetailService ?? null; @@ -524,6 +529,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking); + ipcMain.handle(TEAM_SET_TASK_LOG_STREAM_TRACKING, handleSetTaskLogStreamTracking); ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); @@ -597,6 +603,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING); + ipcMain.removeHandler(TEAM_SET_TASK_LOG_STREAM_TRACKING); ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); @@ -684,6 +691,13 @@ function getTeammateToolTracker(): TeammateToolTracker { return teammateToolTracker; } +function getTeamLogSourceTracker(): TeamLogSourceTracker { + if (!teamLogSourceTracker) { + throw new Error('Team log source tracker is not initialized'); + } + return teamLogSourceTracker; +} + function getBranchStatusService(): BranchStatusService { if (!branchStatusService) { throw new Error('Branch status service is not initialized'); @@ -948,6 +962,28 @@ async function handleSetToolActivityTracking( }); } +async function handleSetTaskLogStreamTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setTaskLogStreamTracking', async () => { + if (enabled) { + await getTeamLogSourceTracker().enableTracking(validated.value!, 'task_log_stream'); + return; + } + await getTeamLogSourceTracker().disableTracking(validated.value!, 'task_log_stream'); + }); +} + async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e1c24319..5d29e6db 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2697,9 +2697,11 @@ export class TeamDataService { } const leadName = transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; - const sessionIds = Array.from( - new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds]) - ); + const knownLeadSessionIds = this.getRecentLeadSessionIds(config); + if (knownLeadSessionIds.length === 0) { + return []; + } + const sessionIds = knownLeadSessionIds; if (sessionIds.length === 0) { return []; } diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 1c5ee933..045cc007 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types'; import type { FSWatcher } from 'chokidar'; const logger = createLogger('Service:TeamLogSourceTracker'); +const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness'; +const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json'; interface TeamLogSourceSnapshot { projectFingerprint: string | null; logSourceGeneration: string | null; } -export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity'; +export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream'; interface TrackingState { watcher: FSWatcher | null; @@ -31,7 +33,7 @@ interface TrackingState { recomputePromise: Promise | null; recomputeVersion: number | null; snapshot: TeamLogSourceSnapshot; - consumers: Set; + consumerCounts: Map; lifecycleVersion: number; } @@ -67,19 +69,29 @@ export class TeamLogSourceTracker { consumer: TeamLogSourceTrackingConsumer ): Promise { const state = this.getOrCreateState(teamName); - if (!state.consumers.has(consumer)) { - state.consumers.add(consumer); + const activeConsumerCountBefore = this.getActiveConsumerCount(state); + state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1); + if (activeConsumerCountBefore === 0) { state.lifecycleVersion += 1; } if ( state.initializePromise && state.initializeVersion === state.lifecycleVersion && - state.consumers.size > 0 + this.getActiveConsumerCount(state) > 0 ) { return state.initializePromise; } + if ( + activeConsumerCountBefore > 0 && + (state.watcher !== null || + state.projectDir !== null || + state.snapshot.logSourceGeneration !== null) + ) { + return { ...state.snapshot }; + } + const initializeVersion = state.lifecycleVersion; const initializePromise = this.initializeTeam(teamName, initializeVersion) .catch((error) => { @@ -118,13 +130,21 @@ export class TeamLogSourceTracker { recomputePromise: null, recomputeVersion: null, snapshot: { projectFingerprint: null, logSourceGeneration: null }, - consumers: new Set(), + consumerCounts: new Map(), lifecycleVersion: 0, }; this.stateByTeam.set(teamName, created); return created; } + private getActiveConsumerCount(state: TrackingState): number { + let count = 0; + for (const value of state.consumerCounts.values()) { + count += value; + } + return count; + } + async stopTracking(teamName: string): Promise { await this.disableTracking(teamName, 'change_presence'); } @@ -138,15 +158,24 @@ export class TeamLogSourceTracker { return { projectFingerprint: null, logSourceGeneration: null }; } - if (state.consumers.has(consumer)) { - state.consumers.delete(consumer); - state.lifecycleVersion += 1; + const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0; + if (currentConsumerCount > 1) { + state.consumerCounts.set(consumer, currentConsumerCount - 1); + return { ...state.snapshot }; } - if (state.consumers.size > 0) { + if (currentConsumerCount === 1) { + state.consumerCounts.delete(consumer); + } + + if (this.getActiveConsumerCount(state) > 0) { return { ...state.snapshot }; } + if (currentConsumerCount > 0) { + state.lifecycleVersion += 1; + } + if (state.refreshTimer) { clearTimeout(state.refreshTimer); state.refreshTimer = null; @@ -164,7 +193,11 @@ export class TeamLogSourceTracker { private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { const state = this.stateByTeam.get(teamName); - return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion; + return ( + !!state && + this.getActiveConsumerCount(state) > 0 && + state.lifecycleVersion === expectedVersion + ); } private async initializeTeam( @@ -207,7 +240,11 @@ export class TeamLogSourceTracker { expectedVersion: number ): Promise { const state = this.stateByTeam.get(teamName); - if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) { + if ( + !state || + this.getActiveConsumerCount(state) === 0 || + state.lifecycleVersion !== expectedVersion + ) { return; } if (state.projectDir === projectDir && state.watcher) { @@ -240,9 +277,15 @@ export class TeamLogSourceTracker { }, }); - const scheduleRecompute = (): void => { + const scheduleRecompute = (changedPath?: string): void => { const current = this.stateByTeam.get(teamName); - if (!current || current.consumers.size === 0) { + if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) { + return; + } + if ( + changedPath && + this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath) + ) { return; } if (current.refreshTimer) { @@ -264,15 +307,65 @@ export class TeamLogSourceTracker { }); } + private handleTaskLogFreshnessSignalChange( + teamName: string, + projectDir: string, + changedPath: string + ): boolean { + const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME); + const relativePath = path.relative(signalDir, changedPath); + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return path.normalize(changedPath) === path.normalize(signalDir); + } + + if (relativePath === '.') { + return true; + } + + if (relativePath.includes(path.sep)) { + return true; + } + + const taskId = this.decodeTaskLogFreshnessTaskId(relativePath); + if (!taskId) { + return true; + } + + this.emitter?.({ + type: 'task-log-change', + teamName, + taskId, + }); + return true; + } + + private decodeTaskLogFreshnessTaskId(fileName: string): string | null { + if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) { + return null; + } + + const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length); + if (!encodedTaskId) { + return null; + } + + try { + const taskId = decodeURIComponent(encodedTaskId); + return taskId.trim().length > 0 ? taskId : null; + } catch { + return null; + } + } + private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); - if (state.consumers.size === 0) { + if (this.getActiveConsumerCount(state) === 0) { return state.snapshot; } if ( state.recomputePromise && state.recomputeVersion === state.lifecycleVersion && - state.consumers.size > 0 + this.getActiveConsumerCount(state) > 0 ) { return state.recomputePromise; } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 15157c8f..9549c819 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -161,14 +161,47 @@ function extractBoardToolOutputText( return null; } + const normalizedToolName = toolName.trim().toLowerCase(); const payload = parsedPayload as Record; - if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { return comment.text; } } + if (normalizedToolName === 'sendmessage') { + const routing = payload.routing as Record | undefined; + const deliveryMessage = + typeof payload.message === 'string' && payload.message.trim().length > 0 + ? payload.message.trim() + : null; + const summary = + typeof routing?.summary === 'string' && routing.summary.trim().length > 0 + ? routing.summary.trim() + : null; + const target = + typeof routing?.target === 'string' && routing.target.trim().length > 0 + ? routing.target.trim() + : null; + + if (deliveryMessage && summary) { + return `${deliveryMessage} - ${summary}`; + } + if (summary && target) { + return `Message sent to ${target} - ${summary}`; + } + if (summary) { + return summary; + } + if (deliveryMessage) { + return deliveryMessage; + } + if (target) { + return `Message sent to ${target}`; + } + } + return null; } @@ -289,12 +322,67 @@ function sanitizeToolResultContent( }; } +function sanitizeToolResultPayloadValue( + value: string | unknown[], + canonicalToolName?: string +): string | unknown[] { + if (typeof value === 'string') { + const parsedPayload = parseJsonLikeString(value); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return extractedText; + } + return parsedPayload ? '' : value; + } + + const jsonText = collectTextBlockText(value); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return extractedText; + } + + const sanitizedChildren = value + .map((child) => { + if ( + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) { + return looksLikeJsonPayload(child.text) ? null : { ...child }; + } + return child; + }) + .filter((child) => child !== null); + + if (parsedPayload && sanitizedChildren.length === value.length) { + return ''; + } + + return sanitizedChildren.length > 0 ? sanitizedChildren : ''; +} + function sanitizeJsonLikeToolResultPayloads( messages: ParsedMessage[], canonicalToolName?: string ): ParsedMessage[] { return messages.map((message) => { let nextMessage = message; + let toolResultsChanged = false; + const nextToolResults = message.toolResults.map((toolResult) => { + const nextContent = sanitizeToolResultPayloadValue(toolResult.content, canonicalToolName); + if (JSON.stringify(nextContent) !== JSON.stringify(toolResult.content)) { + toolResultsChanged = true; + return { + ...toolResult, + content: nextContent, + }; + } + return toolResult; + }); const rawToolUseResult = message.toolUseResult as unknown; if ( @@ -388,12 +476,20 @@ function sanitizeJsonLikeToolResultPayloads( }); if (!changed) { - return nextMessage; + if (!toolResultsChanged) { + return nextMessage; + } + + return { + ...nextMessage, + toolResults: nextToolResults, + }; } return { ...nextMessage, content: nextContent, + toolResults: toolResultsChanged ? nextToolResults : nextMessage.toolResults, }; }); } @@ -1011,6 +1107,15 @@ export class BoardTaskLogStreamService { continue; } + const inferredToolName = [...messageToolUseIds] + .map((toolUseId) => toolNameByUseId.get(toolUseId)) + .find((toolName): toolName is string => typeof toolName === 'string'); + const sanitizedMessages = sanitizeJsonLikeToolResultPayloads([message], inferredToolName); + const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages); + if (prunedMessages.length === 0) { + continue; + } + inferredSlices.push({ id: `inferred:${filePath}:${message.uuid}`, timestamp: message.timestamp.toISOString(), @@ -1018,7 +1123,7 @@ export class BoardTaskLogStreamService { sortOrder: index, participantKey: buildParticipantKey(actor), actor, - filteredMessages: [message], + filteredMessages: prunedMessages, }); } } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 5d509e2d..3abbd293 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -219,6 +219,9 @@ export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking /** Enable or disable live teammate tool activity tracking for a visible team tab */ export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking'; +/** Enable or disable task log stream invalidation tracking for an open task log panel */ +export const TEAM_SET_TASK_LOG_STREAM_TRACKING = 'team:setTaskLogStreamTracking'; + /** Get buffered Claude CLI logs (paged, newest-first) */ export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 29fc3b1f..0a696938 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -162,6 +162,7 @@ import { TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TOOL_ACTIVITY_TRACKING, @@ -836,6 +837,9 @@ const electronAPI: ElectronAPI = { setChangePresenceTracking: async (teamName: string, enabled: boolean) => { return invokeIpcWithResult(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); }, + setTaskLogStreamTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_TASK_LOG_STREAM_TRACKING, teamName, enabled); + }, setToolActivityTracking: async (teamName: string, enabled: boolean) => { return invokeIpcWithResult(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index ddcec8fe..1efaa964 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -689,6 +689,9 @@ export class HttpAPIClient implements ElectronAPI { setChangePresenceTracking: async (): Promise => { // Not available in browser mode — no-op. }, + setTaskLogStreamTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, setToolActivityTracking: async (): Promise => { // Not available in browser mode — no-op. }, diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx index 8a0473ab..86008604 100644 --- a/src/renderer/components/common/OngoingIndicator.tsx +++ b/src/renderer/components/common/OngoingIndicator.tsx @@ -14,6 +14,8 @@ interface OngoingIndicatorProps { showLabel?: boolean; /** Custom label text */ label?: string; + /** Accessible title/tooltip text */ + title?: string; } /** @@ -24,11 +26,12 @@ export const OngoingIndicator = ({ size = 'sm', showLabel = false, label = 'Session in progress...', + title = label, }: Readonly): React.JSX.Element => { const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5'; return ( - + diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 31b5510b..d803c2de 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -48,12 +48,6 @@ interface ActivityTimelineProps { expandOverrides?: Set; /** Called when user toggles expand/collapse override on a specific message. */ onToggleExpandOverride?: (key: string) => void; - /** - * All session IDs belonging to this team (current + history). - * Used together with currentLeadSessionId to suppress only the reconnect boundary - * from the current live session back into the team's previous session history. - */ - teamSessionIds?: Set; /** Current lead session ID for the active team, if known. */ currentLeadSessionId?: string; /** Whether the current team is alive. */ @@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ allCollapsed, expandOverrides, onToggleExpandOverride, - teamSessionIds, currentLeadSessionId, isTeamAlive, leadActivity, @@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ setVisibleCount(Infinity); }; - const getItemSessionId = (item: TimelineItem): string | undefined => - item.type === 'lead-thoughts' - ? item.group.thoughts[0].leadSessionId - : item.message.leadSessionId; + const getItemSessionAnchorId = (item: TimelineItem): string | undefined => { + if (item.type === 'lead-thoughts') { + return item.group.thoughts[0]?.leadSessionId; + } + + return undefined; + }; // Pin the newest thought group (if first) so it stays at the top and doesn't jump. const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; @@ -535,32 +531,29 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ // Session boundary separator (messages sorted desc — new on top) let sessionSeparator: React.JSX.Element | null = null; if (realIndex > 0) { - const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); - const currSessionId = getItemSessionId(item); - if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { - // Suppress only the boundary between the current live session and the team's - // older session history. Older historical session boundaries should still render. - const isReconnectBoundary = - !!currentLeadSessionId && - teamSessionIds && - teamSessionIds.has(prevSessionId) && - teamSessionIds.has(currSessionId) && - (prevSessionId === currentLeadSessionId || currSessionId === currentLeadSessionId); - if (!isReconnectBoundary) { - sessionSeparator = ( -
-
- - New session - -
-
- ); + const currSessionId = getItemSessionAnchorId(item); + let prevSessionId: string | undefined; + for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) { + const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]); + if (candidateSessionId) { + prevSessionId = candidateSessionId; + break; } } + if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { + sessionSeparator = ( +
+
+ + New session + +
+
+ ); + } } if (item.type === 'lead-thoughts') { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 83d2cedf..3851faef 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator'; import { ImageLightbox, LightboxLockProvider, @@ -156,6 +157,8 @@ export const TaskDetailDialog = ({ const [logsRefreshing, setLogsRefreshing] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); + const [logsSectionOpen, setLogsSectionOpen] = useState(false); + const [taskLogActivityActive, setTaskLogActivityActive] = useState(false); const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [taskChangesFiles, setTaskChangesFiles] = useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); @@ -231,6 +234,8 @@ export const TaskDetailDialog = ({ setTaskChangesError(null); setLogsRefreshing(false); setExecutionPreviewOnline(false); + setLogsSectionOpen(false); + setTaskLogActivityActive(false); }, [open, currentTask?.id]); const [replyTo, setReplyTo] = useState<{ @@ -1258,16 +1263,23 @@ export const TaskDetailDialog = ({ key={`task-logs:${currentTask.id}`} title="Task Logs" icon={} + headerExtra={ + taskLogActivityActive ? ( + + ) : null + } contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} + onOpenChange={setLogsSectionOpen} keepMounted >
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index 40a498a5..96d2f727 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -28,10 +28,7 @@ export const MemberExecutionLog = ({ const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]); // Show newest groups first — most recent activity is most relevant in execution logs. - const orderedItems = useMemo( - () => [...conversation.items].reverse(), - [conversation.items] - ); + const orderedItems = useMemo(() => [...conversation.items].reverse(), [conversation.items]); // Store collapsed groups instead of expanded: by default, everything is expanded. // This avoids resetting state in an effect when conversation changes. @@ -179,6 +176,8 @@ const AIExecutionGroup = ({ return enhanceAIGroup({ ...group, processes: filteredProcesses }); }, [group, memberName]); const hasToggleContent = enhanced.displayItems.length > 0; + const visibleLastOutput = + enhanced.lastOutput?.type === 'tool_result' ? null : enhanced.lastOutput; return (
@@ -219,7 +218,7 @@ const AIExecutionGroup = ({
) : null} - +
); }; diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index d559cc70..6ecdd984 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -74,6 +74,7 @@ interface MemberLogsTabProps { teamName: string; memberName?: string; taskId?: string; + enabled?: boolean; /** When viewing task logs: include owner's sessions when task is in_progress */ taskOwner?: string; taskStatus?: string; @@ -100,6 +101,7 @@ export const MemberLogsTab = ({ teamName, memberName, taskId, + enabled = true, taskOwner, taskStatus, taskWorkIntervals, @@ -375,6 +377,7 @@ export const MemberLogsTab = ({ const previewHasMore = allPreviewMessages.length > previewVisibleCount; const previewOnline = useMemo((): boolean => { + if (!enabled) return false; if (!previewLog) return false; // Determine the most recent activity timestamp from preview messages const newest = previewMessages[0]; @@ -398,7 +401,7 @@ export const MemberLogsTab = ({ if (taskStatus === 'in_progress') return ageMs <= 60_000; // Completed/other tasks — shorter window return ageMs <= 15_000; - }, [previewLog, previewMessages, taskStatus]); + }, [enabled, previewLog, previewMessages, taskStatus]); const expandedLogSummary = useMemo(() => { if (!expandedId) return null; @@ -443,6 +446,17 @@ export const MemberLogsTab = ({ useEffect(() => { let cancelled = false; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; + if (!enabled) { + return () => { + cancelled = true; + refreshCountRef.current = 0; + if (refreshHideTimeoutRef.current) { + clearTimeout(refreshHideTimeoutRef.current); + refreshHideTimeoutRef.current = null; + } + setRefreshing(false); + }; + } const load = async (): Promise => { let didBeginRefreshing = false; @@ -505,7 +519,17 @@ export const MemberLogsTab = ({ setRefreshing(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops - }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince, isTabActive]); + }, [ + enabled, + teamName, + memberName, + taskId, + taskOwner, + taskStatus, + intervalsKey, + taskSince, + isTabActive, + ]); const fetchDetailForLog = useCallback( async ( @@ -532,6 +556,9 @@ export const MemberLogsTab = ({ ); useEffect(() => { + if (!enabled) { + return; + } if (!shouldShowPreview) { setPreviewChunks(null); return; @@ -557,9 +584,10 @@ export const MemberLogsTab = ({ return () => { cancelled = true; }; - }, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]); + }, [enabled, fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]); useEffect(() => { + if (!enabled) return; if (!shouldShowPreview) return; if (!previewLog) return; @@ -594,9 +622,11 @@ export const MemberLogsTab = ({ taskStatus, intervalsKey, isTabActive, + enabled, ]); useEffect(() => { + if (!enabled) return; const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; @@ -634,6 +664,7 @@ export const MemberLogsTab = ({ taskStatus, intervalsKey, isTabActive, + enabled, ]); const handleExpand = useCallback( diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9af5216f..8a883579 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -73,8 +73,6 @@ interface MessagesPanelProps { leadContextUpdatedAt?: string; /** Time window for filtering. */ timeWindow: TimeWindow | null; - /** Team session IDs for timeline. */ - teamSessionIds: Set; /** Current lead session ID. */ currentLeadSessionId?: string; /** Pending replies tracker (shared with parent for MemberList). */ @@ -106,7 +104,6 @@ export const MessagesPanel = memo(function MessagesPanel({ leadActivity, leadContextUpdatedAt, timeWindow, - teamSessionIds, currentLeadSessionId, pendingRepliesByMember, onPendingReplyChange, @@ -569,7 +566,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -755,7 +751,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -1042,7 +1037,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx index 5a99c65e..e41ec239 100644 --- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { asEnhancedChunkArray } from '@renderer/types/data'; @@ -26,6 +26,7 @@ import type { interface TaskActivitySectionProps { teamName: string; taskId: string; + enabled?: boolean; } function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean { @@ -262,12 +263,14 @@ const Row = ({ export const TaskActivitySection = ({ teamName, taskId, + enabled = true, }: TaskActivitySectionProps): React.JSX.Element => { const [detailStates, setDetailStates] = useState>({}); const [entries, setEntries] = useState([]); const [expandedId, setExpandedId] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); + const hasLoadedRef = useRef(false); const fetchDetail = useCallback( async (entry: BoardTaskActivityEntry): Promise => { @@ -325,13 +328,27 @@ export const TaskActivitySection = ({ ); useEffect(() => { - let cancelled = false; - setEntries([]); setExpandedId(null); setDetailStates({}); - setLoading(true); setError(null); + setLoading(enabled); + hasLoadedRef.current = false; + }, [taskId, teamName]); + + useEffect(() => { + if (!enabled) { + setLoading(false); + } + }, [enabled]); + + useEffect(() => { + let cancelled = false; + if (!enabled) { + return () => { + cancelled = true; + }; + } const load = async (showSpinner: boolean): Promise => { try { @@ -344,6 +361,7 @@ export const TaskActivitySection = ({ const result = await api.teams.getTaskActivity(teamName, taskId); if (!cancelled) { setEntries(result); + hasLoadedRef.current = true; } } catch (loadError) { if (!cancelled) { @@ -357,7 +375,7 @@ export const TaskActivitySection = ({ } }; - void load(true); + void load(!hasLoadedRef.current); const intervalId = window.setInterval(() => { void load(false); }, 8000); @@ -366,7 +384,7 @@ export const TaskActivitySection = ({ cancelled = true; window.clearInterval(intervalId); }; - }, [teamName, taskId]); + }, [enabled, teamName, taskId]); const visibleEntries = useMemo( () => diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index b1908430..1f47a6ec 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; @@ -14,8 +14,12 @@ import type { interface TaskLogStreamSectionProps { teamName: string; taskId: string; + taskStatus?: string; + liveEnabled?: boolean; } +const LIVE_RELOAD_DEBOUNCE_MS = 350; + function formatRelativeTime(isoString: string): string { const date = new Date(isoString); const diffMs = Date.now() - date.getTime(); @@ -86,39 +90,160 @@ const SegmentBlock = ({ export const TaskLogStreamSection = ({ teamName, taskId, + taskStatus, + liveEnabled = true, }: TaskLogStreamSectionProps): React.JSX.Element => { const [stream, setStream] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); + const requestSeqRef = useRef(0); + const streamRef = useRef(null); + const reloadTimerRef = useRef | null>(null); useEffect(() => { - let cancelled = false; + streamRef.current = stream; + }, [stream]); - const run = async (): Promise => { - try { + const loadStream = useCallback( + async (options?: { resetSelection?: boolean; background?: boolean }): Promise => { + const resetSelection = options?.resetSelection ?? false; + const background = options?.background ?? false; + const hadExistingStream = streamRef.current != null; + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + + if (!background) { setLoading(true); - setError(null); + } + setError((prev) => (background ? prev : null)); + + try { const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); - if (cancelled) return; + if (requestSeqRef.current !== requestSeq) { + return; + } + setStream(response); - setSelectedParticipantKey(response.defaultFilter); + 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 (cancelled) return; - setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream'); - setStream(null); + if (requestSeqRef.current !== requestSeq) { + return; + } + + if (!background || streamRef.current == null) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load task log stream' + ); + setStream(null); + } } finally { - if (!cancelled) { + if (requestSeqRef.current === requestSeq && (!background || !hadExistingStream)) { setLoading(false); } } + }, + [taskId, teamName] + ); + + useEffect(() => { + 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 }); + }, [loadStream]); + + const previousTaskMetaRef = useRef({ taskId, taskStatus }); + + useEffect(() => { + const previousTaskMeta = previousTaskMetaRef.current; + previousTaskMetaRef.current = { taskId, taskStatus }; + + if (previousTaskMeta.taskId !== taskId) { + return; + } + + if ( + previousTaskMeta.taskStatus === 'in_progress' && + taskStatus && + taskStatus !== 'in_progress' + ) { + void loadStream({ background: true }); + } + }, [loadStream, taskId, taskStatus]); + + useEffect(() => { + if (!liveEnabled) { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + return; + } + + const scheduleReload = (): 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 }); + }, LIVE_RELOAD_DEBOUNCE_MS); }; - void run(); - return () => { - cancelled = true; + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + if ( + event.teamName !== teamName || + event.type !== 'task-log-change' || + event.taskId !== taskId + ) { + return; + } + scheduleReload(); + }); + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'visible') { + scheduleReload(); + } }; - }, [taskId, teamName]); + + 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(); + } + }; + }, [liveEnabled, loadStream, taskId, teamName]); const participants = stream?.participants ?? []; const showChips = participants.length > 1; diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 0d364575..71570643 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '@renderer/api'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { ExecutionSessionsSection } from './ExecutionSessionsSection'; @@ -14,6 +15,7 @@ type TaskLogsTab = 'activity' | 'stream' | 'sessions'; interface TaskLogsPanelProps { teamName: string; task: TeamTaskWithKanban; + isOpen?: boolean; taskSince?: string; isExecutionRefreshing?: boolean; isExecutionPreviewOnline?: boolean; @@ -21,11 +23,15 @@ interface TaskLogsPanelProps { showSubagentPreview?: boolean; showLeadPreview?: boolean; onPreviewOnlineChange?: (isOnline: boolean) => void; + onTaskLogActivityChange?: (isActive: boolean) => void; } +const TASK_LOG_ACTIVITY_PULSE_MS = 1800; + export const TaskLogsPanel = ({ teamName, task, + isOpen = true, taskSince, isExecutionRefreshing = false, isExecutionPreviewOnline = false, @@ -33,6 +39,7 @@ export const TaskLogsPanel = ({ showSubagentPreview = false, showLeadPreview = false, onPreviewOnlineChange, + onTaskLogActivityChange, }: TaskLogsPanelProps): React.JSX.Element => { const availableTabs = useMemo(() => { const tabs: TaskLogsTab[] = []; @@ -48,6 +55,10 @@ export const TaskLogsPanel = ({ const defaultTab = availableTabs[0] ?? 'sessions'; const [activeTab, setActiveTab] = useState(defaultTab); + const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false); + const [hasOpenedContent, setHasOpenedContent] = useState(isOpen); + const pulseTimerRef = useRef | null>(null); + const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream'); useEffect(() => { setActiveTab(defaultTab); @@ -59,6 +70,77 @@ export const TaskLogsPanel = ({ } }, [activeTab, availableTabs, defaultTab]); + useEffect(() => { + if (isOpen) { + setHasOpenedContent(true); + } + }, [isOpen]); + + useEffect(() => { + onTaskLogActivityChange?.(isTaskLogActivityActive); + }, [isTaskLogActivityActive, onTaskLogActivityChange]); + + useEffect(() => { + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + pulseTimerRef.current = null; + } + setIsTaskLogActivityActive(false); + }, [task.id]); + + useEffect(() => { + if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) { + return; + } + + void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, true)).catch(() => undefined); + return () => { + void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, false)).catch( + () => undefined + ); + }; + }, [taskLogTrackingEnabled, teamName]); + + useEffect(() => { + if (!taskLogTrackingEnabled) { + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + pulseTimerRef.current = null; + } + setIsTaskLogActivityActive(false); + return; + } + + const unsubscribe = api.teams.onTeamChange?.((_event, event) => { + if ( + event.teamName !== teamName || + event.type !== 'task-log-change' || + event.taskId !== task.id + ) { + return; + } + + setIsTaskLogActivityActive(true); + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + } + pulseTimerRef.current = setTimeout(() => { + pulseTimerRef.current = null; + setIsTaskLogActivityActive(false); + }, TASK_LOG_ACTIVITY_PULSE_MS); + }); + + return () => { + if (pulseTimerRef.current) { + clearTimeout(pulseTimerRef.current); + pulseTimerRef.current = null; + } + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [task.id, taskLogTrackingEnabled, teamName]); + return ( - {availableTabs.includes('stream') ? ( + {availableTabs.includes('stream') && hasOpenedContent ? ( - + ) : null} - {availableTabs.includes('activity') ? ( + {availableTabs.includes('activity') && hasOpenedContent ? ( - + ) : null} - - - + {hasOpenedContent ? ( + + + + ) : null} ); }; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index aa108dbb..1bde5698 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -430,6 +430,7 @@ export interface TeamsAPI { getTaskChangePresence: (teamName: string) => Promise>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; setToolActivityTracking: (teamName: string, enabled: boolean) => Promise; + setTaskLogStreamTracking: (teamName: string, enabled: boolean) => Promise; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 5ab3a004..cb3bbe97 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -912,6 +912,7 @@ export interface TeamChangeEvent { | 'config' | 'inbox' | 'log-source-change' + | 'task-log-change' | 'task' | 'lead-activity' | 'lead-context' @@ -922,6 +923,7 @@ export interface TeamChangeEvent { teamName: string; runId?: string; detail?: string; + taskId?: string; } export interface ProjectBranchChangeEvent { diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index 4dc9b6d4..e19ac3b8 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -586,6 +586,189 @@ describe('BoardTaskLogStreamService integration', () => { expect(toolNames).toContain('mcp__agent-teams__task_complete'); }); + it('sanitizes inferred SendMessage results instead of surfacing raw json payloads', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-sendmessage-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T15:36:00.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:00.120Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-send', + timestamp: '2026-04-12T15:36:10.000Z', + requestId: 'req-send', + content: [ + { + type: 'tool_use', + id: 'call-send', + name: 'SendMessage', + input: { + to: 'team-lead', + summary: '#abc done', + message: 'Detailed body', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-send', + timestamp: '2026-04-12T15:36:10.200Z', + sourceToolAssistantUUID: 'a-send', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-send', + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: "Message sent to team-lead's inbox", + routing: { + target: '@team-lead', + summary: '#abc done', + content: 'Detailed body', + }, + }), + }, + ], + }, + ], + toolUseResult: { + success: true, + message: "Message sent to team-lead's inbox", + routing: { + target: '@team-lead', + summary: '#abc done', + content: 'Detailed body', + }, + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const sendResult = rawMessages.find((message) => message.uuid === 'u-send'); + const semanticToolResult = response.segments + .flatMap((segment) => segment.chunks) + .flatMap((chunk) => ('semanticSteps' in chunk ? (chunk.semanticSteps ?? []) : [])) + .find((step) => step.type === 'tool_result' && step.id === 'call-send'); + + expect(rawMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.name))).toContain( + 'SendMessage' + ); + expect(sendResult?.toolResults).toEqual([ + { + toolUseId: 'call-send', + content: "Message sent to team-lead's inbox - #abc done", + isError: false, + }, + ]); + expect(semanticToolResult).toMatchObject({ + id: 'call-send', + type: 'tool_result', + content: expect.objectContaining({ + toolResultContent: "Message sent to team-lead's inbox - #abc done", + }), + }); + }); + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); tempDirs.push(dir); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts index 3a89cca5..7c1220ef 100644 --- a/test/main/services/team/BoardTaskLogStreamService.test.ts +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -630,4 +630,154 @@ describe('BoardTaskLogStreamService', () => { }); expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' }); }); + + it('sanitizes SendMessage json payloads into a concise human-readable result', async () => { + const bob = { + memberName: 'bob', + role: 'member' as const, + sessionId: 'session-bob', + agentId: 'agent-bob', + isSidechain: true, + }; + const candidate = { + ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', bob, 'tool-send'), + actionCategory: 'execution' as const, + canonicalToolName: 'SendMessage', + }; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: bob, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-send', + toolUseId: 'tool-send', + sourceOrder: 1, + }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-send', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-send', + name: 'SendMessage', + input: { to: 'team-lead', summary: '#abc done' }, + } as never, + ], + toolCalls: [], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-send-result', + parentUuid: 'assistant-send', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-send', + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: "Message sent to team-lead's inbox", + routing: { + target: '@team-lead', + summary: '#abc done', + content: 'Detailed body that should not leak into the preview.', + }, + }), + } as never, + ], + } as never, + ], + toolCalls: [], + toolResults: [ + { + toolUseId: 'tool-send', + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: "Message sent to team-lead's inbox", + routing: { + target: '@team-lead', + summary: '#abc done', + content: 'Detailed body that should not leak into the preview.', + }, + }), + }, + ], + isError: false, + }, + ], + sourceToolUseID: 'tool-send', + sourceToolAssistantUUID: 'assistant-send', + toolUseResult: { + success: true, + message: "Message sent to team-lead's inbox", + routing: { + target: '@team-lead', + summary: '#abc done', + content: 'Detailed body that should not leak into the preview.', + }, + }, + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + ], + })), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + ); + + await service.getTaskLogStream('demo', 'task-a'); + + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-send-result'); + const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; + expect(content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-send', + content: "Message sent to team-lead's inbox - #abc done", + }); + expect(toolResultMessage?.toolResults).toEqual([ + { + toolUseId: 'tool-send', + content: "Message sent to team-lead's inbox - #abc done", + isError: false, + }, + ]); + }); }); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index c887a7f7..3f204b61 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -3437,7 +3437,7 @@ describe('TeamDataService', () => { expect(persistedConfig.projectPath).toBe(fixture.staleProjectPath); }); - it('uses resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => { + it('does not guess lead_session messages from resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => { const fixture = await createResolverBackedLeadFixture({ leadSessionId: undefined, sessionFileId: 'lead-discovered', @@ -3446,13 +3446,48 @@ describe('TeamDataService', () => { const page = await service.getMessagesPage(fixture.teamName, { limit: 10 }); + expect(page.messages.some((message) => message.source === 'lead_session')).toBe(false); + }); + + it('does not mix resolver-discovered non-lead session ids into durable lead_session messages when config already knows the lead session', async () => { + const fixture = await createResolverBackedLeadFixture(); + await fs.writeFile( + path.join(fixture.actualProjectDir, 'member-1.jsonl'), + `${JSON.stringify({ + teamName: fixture.teamName, + type: 'assistant', + timestamp: '2026-04-18T10:05:00.000Z', + cwd: fixture.actualProjectPath, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Member bootstrap noise that should never appear as a lead_session thought in the team activity timeline.', + }, + ], + }, + })}\n`, + 'utf8' + ); + const service = createResolverBackedService(); + + const page = await service.getMessagesPage(fixture.teamName, { limit: 20 }); + const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session'); + expect( - page.messages.find( - (message) => - message.source === 'lead_session' && - message.text.includes('recovered through the transcript resolver') + leadSessionMessages.some((message) => + message.text.includes('recovered through the transcript resolver') ) - ).toBeTruthy(); + ).toBe(true); + expect( + leadSessionMessages.some((message) => + message.text.includes('Member bootstrap noise that should never appear') + ) + ).toBe(false); + expect(new Set(leadSessionMessages.map((message) => message.leadSessionId))).toEqual( + new Set(['lead-1']) + ); }); it('fails fast when config is missing before any read-phase step starts', async () => { diff --git a/test/main/services/team/TeamLogSourceTracker.test.ts b/test/main/services/team/TeamLogSourceTracker.test.ts new file mode 100644 index 00000000..0baa59c6 --- /dev/null +++ b/test/main/services/team/TeamLogSourceTracker.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TeamLogSourceTracker } from '../../../../src/main/services/team/TeamLogSourceTracker'; + +import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; +import type { TeamChangeEvent } from '../../../../src/shared/types'; + +describe('TeamLogSourceTracker', () => { + let tempDir: string | null = null; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-')); + + const logsFinder = { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'change_presence'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const taskId = '123e4567-e89b-12d3-a456-426614174999'; + const signalDir = path.join(tempDir, '.board-task-log-freshness'); + await mkdir(signalDir, { recursive: true }); + await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}'); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + }); + }); + + expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change'); + + await tracker.disableTracking('demo', 'change_presence'); + }); + + it('keeps task-log tracking alive until the last consumer unsubscribes', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-')); + + const logsFinder = { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + tracker.setEmitter(emitter); + + await tracker.enableTracking('demo', 'task_log_stream'); + await tracker.enableTracking('demo', 'task_log_stream'); + emitter.mockClear(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + await tracker.disableTracking('demo', 'task_log_stream'); + + const taskId = '223e4567-e89b-12d3-a456-426614174999'; + const signalDir = path.join(tempDir, '.board-task-log-freshness'); + await mkdir(signalDir, { recursive: true }); + await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}'); + + await vi.waitFor(() => { + expect(emitter).toHaveBeenCalledWith({ + type: 'task-log-change', + teamName: 'demo', + taskId, + }); + }); + + emitter.mockClear(); + await tracker.disableTracking('demo', 'task_log_stream'); + await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":false}'); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(emitter).not.toHaveBeenCalled(); + }); + + it('does not reinitialize when another consumer joins an already tracked team', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-')); + + const logsFinder = { + getLogSourceWatchContext: vi.fn(async () => ({ + projectDir: tempDir!, + sessionIds: [], + })), + } as unknown as TeamMemberLogsFinder; + + const tracker = new TeamLogSourceTracker(logsFinder); + + await tracker.enableTracking('demo', 'tool_activity'); + await tracker.enableTracking('demo', 'task_log_stream'); + + expect(logsFinder.getLogSourceWatchContext).toHaveBeenCalledTimes(1); + + await tracker.disableTracking('demo', 'task_log_stream'); + await tracker.disableTracking('demo', 'tool_activity'); + }); +}); diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts new file mode 100644 index 00000000..fac0ca87 --- /dev/null +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -0,0 +1,156 @@ +import React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InboxMessage } from '@shared/types'; + +vi.mock('@renderer/components/team/activity/ActivityItem', () => ({ + ActivityItem: ({ message }: { message: InboxMessage }) => + React.createElement('div', { 'data-testid': 'activity-item' }, message.text), + isNoiseMessage: () => false, +})); + +vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({ + ENTRY_REVEAL_ANIMATION_MS: 220, + AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({ + useNewItemKeys: () => new Set(), +})); + +import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline'; + +function makeMessage(overrides: Partial = {}): InboxMessage { + return { + from: 'team-lead', + text: 'message', + timestamp: '2026-04-18T13:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'message-id', + leadSessionId: 'lead-session-1', + ...overrides, + }; +} + +describe('ActivityTimeline session separators', () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('does not render New session for regular message rows even when their session ids differ', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'member-newest', + text: 'member newest', + leadSessionId: 'member-session-2', + from: 'alice', + source: 'inbox', + }), + makeMessage({ + messageId: 'member-older', + text: 'member older', + leadSessionId: 'member-session-1', + from: 'alice', + source: 'inbox', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).not.toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('renders New session between lead thought groups from different sessions', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-newest', + text: 'lead thought newest', + leadSessionId: 'lead-session-2', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'regular-between', + text: 'regular message between sessions', + leadSessionId: 'member-session-1', + from: 'alice', + source: 'inbox', + }), + makeMessage({ + messageId: 'thought-older', + text: 'lead thought older', + leadSessionId: 'lead-session-1', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('still renders New session when the newest thought belongs to currentLeadSessionId', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-current', + text: 'current lead thought', + leadSessionId: 'lead-session-current', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-history', + text: 'historical lead thought', + leadSessionId: 'lead-session-history', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + currentLeadSessionId: 'lead-session-current', + }) + ); + }); + + expect(container.textContent).toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberExecutionLog.test.ts b/test/renderer/components/team/members/MemberExecutionLog.test.ts new file mode 100644 index 00000000..74c08f10 --- /dev/null +++ b/test/renderer/components/team/members/MemberExecutionLog.test.ts @@ -0,0 +1,129 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const transformState = { + items: [] as Array<{ type: 'ai'; group: Record }>, +}; + +const enhanceState = { + value: null as null | Record, +}; + +vi.mock('@renderer/utils/groupTransformer', () => ({ + transformChunksToConversation: () => ({ + items: transformState.items, + }), +})); + +vi.mock('@renderer/utils/aiGroupEnhancer', () => ({ + enhanceAIGroup: (group: Record) => ({ + ...group, + ...(enhanceState.value ?? {}), + }), +})); + +vi.mock('@renderer/components/chat/LastOutputDisplay', () => ({ + LastOutputDisplay: ({ lastOutput }: { lastOutput: unknown }) => { + if (!lastOutput) { + return null; + } + return React.createElement( + 'div', + { 'data-testid': 'last-output' }, + JSON.stringify(lastOutput) + ); + }, +})); + +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function setSingleAiGroup(): void { + transformState.items = [ + { + type: 'ai', + group: { + id: 'group-1', + steps: [], + responses: [], + processes: [], + }, + }, + ]; +} + +describe('MemberExecutionLog', () => { + afterEach(() => { + document.body.innerHTML = ''; + transformState.items = []; + enhanceState.value = null; + }); + + it('suppresses duplicated last tool_result banners in execution-log mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setSingleAiGroup(); + enhanceState.value = { + displayItems: [], + itemsSummary: '1 tool', + lastOutput: { + type: 'tool_result', + toolName: 'Read', + toolResult: 'raw file body', + isError: false, + timestamp: new Date('2026-04-18T13:23:12.982Z'), + }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(MemberExecutionLog, { chunks: [] })); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="last-output"]')).toBeNull(); + expect(host.textContent).not.toContain('raw file body'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps plain text last output visible', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setSingleAiGroup(); + enhanceState.value = { + displayItems: [], + itemsSummary: '1 output', + lastOutput: { + type: 'text', + text: 'final answer', + timestamp: new Date('2026-04-18T13:23:12.982Z'), + }, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(MemberExecutionLog, { chunks: [] })); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="last-output"]')).not.toBeNull(); + expect(host.textContent).toContain('final answer'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 72fbed31..632ced5b 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -211,7 +211,6 @@ describe('MessagesPanel idle summary invariants', () => { members: [], tasks: [], timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: {}, onPendingReplyChange: vi.fn(), }) @@ -272,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => { members: [], tasks: [], timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: { alice: pendingSentAtMs }, onPendingReplyChange, }) @@ -327,7 +325,6 @@ describe('MessagesPanel idle summary invariants', () => { members: [], tasks: [], timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: { alice: pendingSentAtMs }, onPendingReplyChange, }) @@ -376,7 +373,6 @@ describe('MessagesPanel idle summary invariants', () => { members: [], tasks: [], timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: {}, onPendingReplyChange: vi.fn(), }) diff --git a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts index b36a0196..8cd64eb0 100644 --- a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts @@ -241,6 +241,120 @@ describe('TaskActivitySection', () => { }); }); + it('does not load activity while disabled', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskActivitySection, { + teamName: 'demo', + taskId: 'task-a', + enabled: false, + }) + ); + await flushMicrotasks(); + }); + + expect(apiState.getTaskActivity).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('preserves loaded activity while disabled and refreshes again on re-enable', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskActivity + .mockResolvedValueOnce([ + makeEntry({ + id: 'started', + timestamp: '2026-04-13T10:34:00.000Z', + linkKind: 'lifecycle', + action: { + canonicalToolName: 'task_start', + category: 'status', + }, + }), + ]) + .mockResolvedValueOnce([ + makeEntry({ + id: 'started', + timestamp: '2026-04-13T10:34:00.000Z', + linkKind: 'lifecycle', + action: { + canonicalToolName: 'task_start', + category: 'status', + }, + }), + makeEntry({ + id: 'viewed', + timestamp: '2026-04-13T10:35:00.000Z', + linkKind: 'board_action', + action: { + canonicalToolName: 'task_get', + category: 'read', + }, + }), + ]); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskActivitySection, { + teamName: 'demo', + taskId: 'task-a', + enabled: true, + }) + ); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Started work'); + expect(apiState.getTaskActivity).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + React.createElement(TaskActivitySection, { + teamName: 'demo', + taskId: 'task-a', + enabled: false, + }) + ); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Started work'); + expect(apiState.getTaskActivity).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + React.createElement(TaskActivitySection, { + teamName: 'demo', + taskId: 'task-a', + enabled: true, + }) + ); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Started work'); + expect(host.textContent).toContain('Viewed task'); + expect(apiState.getTaskActivity).toHaveBeenCalledTimes(2); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + it('loads inline detail lazily and renders metadata plus a linked tool card', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); apiState.getTaskActivity.mockResolvedValue([ diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index 69f9bd60..6cfacbea 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -404,8 +404,8 @@ describe('TaskLogStreamSection integration', () => { expect(text).toContain('Edit'); expect(text).toContain('Claude'); expect(text).toContain('3 tool calls'); - expect(text).toContain('Audit complete'); expect(text).not.toContain('[]'); + expect(text).not.toContain('Audit complete'); expect(text).not.toContain('lead session'); await act(async () => { diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts index 4f34bdc0..7a721ac9 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts @@ -2,12 +2,15 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { TeamChangeEvent } from '../../../../../src/shared/types'; import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types'; const apiState = { getTaskLogStream: vi.fn< (teamName: string, taskId: string) => Promise >(), + onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(), + setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise>(), }; vi.mock('@renderer/api', () => ({ @@ -15,6 +18,10 @@ vi.mock('@renderer/api', () => ({ teams: { getTaskLogStream: (...args: Parameters) => apiState.getTaskLogStream(...args), + onTeamChange: (...args: Parameters) => + apiState.onTeamChange(...args), + setTaskLogStreamTracking: (...args: Parameters) => + apiState.setTaskLogStreamTracking(...args), }, }, })); @@ -40,10 +47,46 @@ function flushMicrotasks(): Promise { return Promise.resolve(); } +function buildParticipant(key: string, label: string) { + return { + key, + label, + role: 'member' as const, + isLead: false, + isSidechain: true, + }; +} + +function buildSegment(args: { + id: string; + participantKey: string; + memberName: string; + startTimestamp: string; + endTimestamp: string; +}) { + return { + id: args.id, + participantKey: args.participantKey, + actor: { + memberName: args.memberName, + role: 'member' as const, + sessionId: `${args.memberName}-session-${args.id}`, + agentId: `${args.memberName}-agent`, + isSidechain: true, + }, + startTimestamp: args.startTimestamp, + endTimestamp: args.endTimestamp, + chunks: [{ id: `chunk-${args.id}`, chunkType: 'user', rawMessages: [] }] as never, + }; +} + describe('TaskLogStreamSection', () => { afterEach(() => { document.body.innerHTML = ''; apiState.getTaskLogStream.mockReset(); + apiState.onTeamChange.mockReset(); + apiState.setTaskLogStreamTracking.mockReset(); + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -175,6 +218,7 @@ describe('TaskLogStreamSection', () => { it('honors a participant default filter from the stream response', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.onTeamChange.mockImplementation(() => () => undefined); apiState.getTaskLogStream.mockResolvedValueOnce({ participants: [ { @@ -220,4 +264,248 @@ describe('TaskLogStreamSection', () => { await flushMicrotasks(); }); }); + + it('live-refreshes on matching task-log changes and preserves the selected participant filter', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + + let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null; + apiState.onTeamChange.mockImplementation((callback) => { + handler = callback; + return () => { + handler = null; + }; + }); + + apiState.getTaskLogStream + .mockResolvedValueOnce({ + participants: [ + buildParticipant('member:tom', 'tom'), + buildParticipant('member:alice', 'alice'), + ], + defaultFilter: 'all', + segments: [ + buildSegment({ + id: 'tom-1', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + }), + buildSegment({ + id: 'alice-1', + participantKey: 'member:alice', + memberName: 'alice', + startTimestamp: '2026-04-12T16:02:00.000Z', + endTimestamp: '2026-04-12T16:03:00.000Z', + }), + ], + }) + .mockResolvedValueOnce({ + participants: [ + buildParticipant('member:tom', 'tom'), + buildParticipant('member:alice', 'alice'), + ], + defaultFilter: 'all', + segments: [ + buildSegment({ + id: 'tom-1', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + }), + buildSegment({ + id: 'alice-1', + participantKey: 'member:alice', + memberName: 'alice', + startTimestamp: '2026-04-12T16:02:00.000Z', + endTimestamp: '2026-04-12T16:03:00.000Z', + }), + buildSegment({ + id: 'tom-2', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:04:00.000Z', + endTimestamp: '2026-04-12T16:05:00.000Z', + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + const tomButton = [...host.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'tom' + ); + expect(tomButton).toBeDefined(); + + await act(async () => { + tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + }); + + expect( + [...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent) + ).toEqual(['tom:1']); + + expect(handler).toBeTypeOf('function'); + + await act(async () => { + handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' }); + vi.advanceTimersByTime(400); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' }); + vi.advanceTimersByTime(400); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' }); + vi.advanceTimersByTime(400); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2); + expect( + [...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent) + ).toEqual(['tom:1', 'tom:1']); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('does not subscribe to live refresh when live mode is disabled', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + apiState.onTeamChange.mockImplementation(() => () => undefined); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [buildParticipant('member:tom', 'tom')], + defaultFilter: 'all', + segments: [ + buildSegment({ + id: 'tom-1', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskLogStreamSection, { + teamName: 'demo', + taskId: 'task-a', + liveEnabled: false, + }) + ); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); + expect(apiState.onTeamChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('revalidates once when the task leaves in-progress state', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + apiState.getTaskLogStream + .mockResolvedValueOnce({ + participants: [buildParticipant('member:tom', 'tom')], + defaultFilter: 'all', + segments: [ + buildSegment({ + id: 'tom-1', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + }), + ], + }) + .mockResolvedValueOnce({ + participants: [buildParticipant('member:tom', 'tom')], + defaultFilter: 'all', + segments: [ + buildSegment({ + id: 'tom-1', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + }), + buildSegment({ + id: 'tom-2', + participantKey: 'member:tom', + memberName: 'tom', + startTimestamp: '2026-04-12T16:02:00.000Z', + endTimestamp: '2026-04-12T16:03:00.000Z', + }), + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskLogStreamSection, { + teamName: 'demo', + taskId: 'task-a', + taskStatus: 'in_progress', + liveEnabled: true, + }) + ); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + React.createElement(TaskLogStreamSection, { + teamName: 'demo', + taskId: 'task-a', + taskStatus: 'completed', + liveEnabled: false, + }) + ); + await flushMicrotasks(); + }); + + expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2); + expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(2); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); }); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index 3ce7aa11..cedb1bb9 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -4,25 +4,61 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { TaskLogsPanel } from '../../../../../src/renderer/components/team/taskLogs/TaskLogsPanel'; +import type { TeamChangeEvent } from '../../../../../src/shared/types'; import type { TeamTaskWithKanban } from '../../../../../src/shared/types'; +const apiState = { + onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(), + setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise>(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + onTeamChange: (...args: Parameters) => + apiState.onTeamChange(...args), + setTaskLogStreamTracking: (...args: Parameters) => + apiState.setTaskLogStreamTracking(...args), + }, + }, +})); + const featureGateState = { activityEnabled: true, exactLogsEnabled: true, }; +const taskActivityProps = vi.hoisted(() => ({ + calls: [] as Array>, +})); + vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskActivitySection', () => ({ - TaskActivitySection: () => React.createElement('div', { 'data-testid': 'task-activity' }, 'activity'), + TaskActivitySection: (props: Record) => { + taskActivityProps.calls.push(props); + return React.createElement('div', { 'data-testid': 'task-activity' }, 'activity'); + }, +})); + +const taskLogStreamProps = vi.hoisted(() => ({ + calls: [] as Array>, +})); + +const executionSessionsProps = vi.hoisted(() => ({ + calls: [] as Array>, })); vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskLogStreamSection', () => ({ - TaskLogStreamSection: () => - React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'), + TaskLogStreamSection: (props: Record) => { + taskLogStreamProps.calls.push(props); + return React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'); + }, })); vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({ - ExecutionSessionsSection: () => - React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'), + ExecutionSessionsSection: (props: Record) => { + executionSessionsProps.calls.push(props); + return React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'); + }, })); vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({ @@ -128,6 +164,12 @@ describe('TaskLogsPanel', () => { document.body.innerHTML = ''; featureGateState.activityEnabled = true; featureGateState.exactLogsEnabled = true; + taskActivityProps.calls = []; + taskLogStreamProps.calls = []; + executionSessionsProps.calls = []; + apiState.onTeamChange.mockReset(); + apiState.setTaskLogStreamTracking.mockReset(); + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -147,6 +189,12 @@ describe('TaskLogsPanel', () => { expect(host.textContent).toContain('Execution Sessions'); expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active'); expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull(); + expect(taskLogStreamProps.calls.at(-1)).toMatchObject({ + teamName: 'demo', + taskId: 'task-1', + taskStatus: 'in_progress', + liveEnabled: true, + }); const activityTab = findTabButton(host, 'Task Activity'); expect(activityTab).not.toBeNull(); @@ -158,6 +206,11 @@ describe('TaskLogsPanel', () => { expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active'); expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull(); + expect(taskActivityProps.calls.at(-1)).toMatchObject({ + teamName: 'demo', + taskId: 'task-1', + enabled: true, + }); const sessionsTab = findTabButton(host, 'Execution Sessions'); expect(sessionsTab).not.toBeNull(); @@ -169,6 +222,11 @@ describe('TaskLogsPanel', () => { expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active'); expect(host.querySelector('[data-testid="execution-sessions"]')).not.toBeNull(); + expect(executionSessionsProps.calls.at(-1)).toMatchObject({ + teamName: 'demo', + taskId: 'task-1', + enabled: true, + }); await act(async () => { root.unmount(); @@ -192,6 +250,234 @@ describe('TaskLogsPanel', () => { expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active'); expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull(); expect(host.textContent).not.toContain('Task Log Stream'); + expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled(); + expect(apiState.onTeamChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('does not mount Task Activity content while the section is collapsed and stream is disabled', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + featureGateState.exactLogsEnabled = false; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + isOpen: false, + }) + ); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull(); + expect(host.querySelector('[data-testid="task-activity"]')).toBeNull(); + expect(taskLogStreamProps.calls).toHaveLength(0); + expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled(); + expect(apiState.onTeamChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps task-log tracking active across tab switches and pulses on matching live updates', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + + const activityStates: boolean[] = []; + let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null; + apiState.onTeamChange.mockImplementation((callback) => { + handler = callback; + return () => { + handler = null; + }; + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + }) + ); + await flushMicrotasks(); + }); + + expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1); + expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); + expect(handler).toBeTypeOf('function'); + expect(activityStates).toEqual([false]); + + const activityTab = findTabButton(host, 'Task Activity'); + expect(activityTab).not.toBeNull(); + + await act(async () => { + activityTab?.click(); + await flushMicrotasks(); + }); + + expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1); + + await act(async () => { + handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-1' }); + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-2' }); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false]); + + await act(async () => { + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true]); + + await act(async () => { + vi.advanceTimersByTime(1800); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true, false]); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + + expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false); + }); + + it('does not mount Task Log Stream content while the section is collapsed but still pulses on matching updates', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + + const activityStates: boolean[] = []; + let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null; + apiState.onTeamChange.mockImplementation((callback) => { + handler = callback; + return () => { + handler = null; + }; + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + isOpen: false, + onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + }) + ); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull(); + expect(taskLogStreamProps.calls).toHaveLength(0); + expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); + expect(handler).toBeTypeOf('function'); + expect(activityStates).toEqual([false]); + + await act(async () => { + handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true]); + + await act(async () => { + vi.advanceTimersByTime(1800); + await flushMicrotasks(); + }); + + expect(activityStates).toEqual([false, true, false]); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + + expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false); + }); + + it('pauses mounted activity and sessions tabs when the section collapses', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() })); + await flushMicrotasks(); + }); + + const activityTab = findTabButton(host, 'Task Activity'); + expect(activityTab).not.toBeNull(); + + await act(async () => { + activityTab?.click(); + await flushMicrotasks(); + }); + + expect(taskActivityProps.calls.at(-1)).toMatchObject({ enabled: true }); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + isOpen: false, + }) + ); + await flushMicrotasks(); + }); + + expect(taskActivityProps.calls.at(-1)).toMatchObject({ enabled: false }); + + const sessionsTab = findTabButton(host, 'Execution Sessions'); + expect(sessionsTab).not.toBeNull(); + + await act(async () => { + root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() })); + sessionsTab?.click(); + await flushMicrotasks(); + }); + + expect(executionSessionsProps.calls.at(-1)).toMatchObject({ enabled: true }); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + isOpen: false, + }) + ); + await flushMicrotasks(); + }); + + expect(executionSessionsProps.calls.at(-1)).toMatchObject({ enabled: false }); await act(async () => { root.unmount(); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 55294abe..62240baf 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -717,6 +717,63 @@ describe('TeamGraphAdapter particles', () => { ]); }); + it('maps lead-owned tasks onto the lead board without routing unknown owners to lead', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + config: { + name: 'My Team', + members: [{ name: 'olivia', agentType: 'lead' }, { name: 'alice' }], + projectPath: '/repo', + }, + members: [ + { + name: 'olivia', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentType: 'lead', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + ], + tasks: [ + { + id: 'lead-task', + displayId: '#11', + subject: 'Lead summary', + owner: 'olivia', + status: 'in_progress', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + { + id: 'unknown-task', + displayId: '#12', + subject: 'Unknown owner', + owner: 'ghost', + status: 'in_progress', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:lead-task')?.ownerId).toBe('lead:my-team'); + expect(findNode(graph, 'task:my-team:unknown-task')?.ownerId).toBeNull(); + }); + it('builds member activity feeds from inbox messages in newest-first order', () => { const adapter = TeamGraphAdapter.create(); diff --git a/test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts b/test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts new file mode 100644 index 00000000..cbc0b5a7 --- /dev/null +++ b/test/renderer/features/agent-graph/buildTransientHandoffMessage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTransientHandoffMessage } from '../../../../src/features/agent-graph/renderer/ui/buildTransientHandoffMessage'; + +import type { TransientHandoffCard } from '@claude-teams/agent-graph'; + +function buildCard(overrides: Partial = {}): TransientHandoffCard { + return { + key: 'edge-1:fwd:task_comment', + edgeId: 'edge-1', + sourceNodeId: 'member:bob', + destinationNodeId: 'task:abc', + anchorNodeId: 'member:bob', + anchorKind: 'member', + sourceLabel: 'bob', + destinationLabel: 'abc12345', + destinationKind: 'task', + kind: 'task_comment', + color: '#22c55e', + preview: 'Dependency resolved', + relatedTaskId: 'abc12345def67890', + relatedTaskDisplayId: 'abc12345', + count: 1, + activatedAt: 10, + updatedAt: 11, + expiresAt: 15, + ...overrides, + }; +} + +describe('buildTransientHandoffMessage', () => { + it('builds task comment notifications with task refs', () => { + const message = buildTransientHandoffMessage('signal-ops-2', buildCard()); + + expect(message.messageKind).toBe('task_comment_notification'); + expect(message.from).toBe('bob'); + expect(message.taskRefs).toEqual([ + { + taskId: 'abc12345def67890', + displayId: 'abc12345', + teamName: 'signal-ops-2', + }, + ]); + }); + + it('builds task assign text that ActivityItem recognizes as task badge', () => { + const message = buildTransientHandoffMessage( + 'signal-ops-2', + buildCard({ + key: 'edge-2:fwd:task_assign', + kind: 'task_assign', + destinationNodeId: 'member:bob', + destinationKind: 'member', + destinationLabel: 'bob', + }) + ); + + expect(message.messageKind).toBe('default'); + expect(message.text.startsWith('New task assigned to you:')).toBe(true); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index a485d673..ea22cb6e 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -797,6 +797,40 @@ describe('stable slot layout planner', () => { } }); + it('builds central collisions from occupied lead sub-rects instead of the full lead slot bounds', () => { + const teamName = 'team-lead-central-collision'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const leadTasks = [ + createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }), + createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const nodes = [lead, alice, ...leadTasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + 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.kanbanBandRect); + expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(snapshot!.leadSlotFrame.bounds.width); + expect(snapshot!.leadCentralReservedBlock.height).toBeLessThan( + snapshot!.leadSlotFrame.bounds.height + ); + }); + it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => { const teamName = 'team-wide-spill'; const lead = createLead(teamName);