feat(team): show live task log activity

This commit is contained in:
777genius 2026-05-06 23:15:27 +03:00
parent f57b1bf18b
commit b7fa5443fd
18 changed files with 880 additions and 49 deletions

View file

@ -69,10 +69,22 @@ type DecodedFreshnessTaskId =
| { kind: 'opaque-safe-segment' }
| { kind: 'invalid' };
type TaskFreshnessSignalKind = NonNullable<TeamChangeEvent['taskSignalKind']>;
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
return /^task-id-[0-9a-f]{32}$/.test(segment);
}
function pushUniqueNormalizedPath(paths: string[], candidate: string | undefined): void {
if (!candidate || !path.isAbsolute(candidate)) {
return;
}
const normalized = path.normalize(candidate);
if (!paths.some((existing) => path.normalize(existing) === normalized)) {
paths.push(normalized);
}
}
export function shouldIgnoreLogSourceWatcherPath(
projectDir: string,
watchedPath: string,
@ -368,14 +380,20 @@ export class TeamLogSourceTracker {
return;
}
await this.ensureLogSourceFreshnessDirs(context.projectDir).catch((error) => {
const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context);
const taskFreshnessWatchRootDirs = await this.ensureLogSourceFreshnessDirs(
context.projectDir,
taskFreshnessRootDirs
).catch((error) => {
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
return [path.normalize(context.projectDir)];
});
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
context.projectDir,
context.watchSessionIds,
this.getPendingUnknownSessionIds(state)
this.getPendingUnknownSessionIds(state),
taskFreshnessWatchRootDirs
);
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
return;
@ -411,6 +429,18 @@ export class TeamLogSourceTracker {
) {
return;
}
const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext);
pushUniqueNormalizedPath(eventTaskFreshnessRootDirs, current.projectDir);
if (
this.handleTaskFreshnessSignalChangeForRoots(
teamName,
changedPath,
eventTaskFreshnessRootDirs
)
) {
return;
}
const action = classifyLogSourceWatcherEvent({
projectDir: current.projectDir,
changedPath,
@ -420,21 +450,6 @@ export class TeamLogSourceTracker {
});
if (action.kind === 'task-freshness') {
if (
!this.handleTaskFreshnessSignalChange(
teamName,
current.projectDir,
changedPath,
BOARD_TASK_LOG_FRESHNESS_DIRNAME
)
) {
this.handleTaskFreshnessSignalChange(
teamName,
current.projectDir,
changedPath,
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME
);
}
return;
}
@ -458,24 +473,74 @@ export class TeamLogSourceTracker {
});
}
private async ensureLogSourceFreshnessDirs(projectDir: string): Promise<void> {
private getTaskFreshnessRootDirs(context: TeamLogSourceLiveContext | null): string[] {
const roots: string[] = [];
pushUniqueNormalizedPath(roots, context?.projectDir);
pushUniqueNormalizedPath(roots, context?.projectPath);
for (const rootDir of context?.taskFreshnessRootDirs ?? []) {
pushUniqueNormalizedPath(roots, rootDir);
}
return roots;
}
private async ensureLogSourceFreshnessDirs(
transcriptProjectDir: string,
projectDirs: readonly string[]
): Promise<string[]> {
const watchRootDirs: string[] = [];
const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir);
pushUniqueNormalizedPath(watchRootDirs, normalizedTranscriptProjectDir);
await Promise.all([
fs.mkdir(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { recursive: true }),
fs.mkdir(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true }),
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
recursive: true,
}),
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
recursive: true,
}),
]);
await Promise.all(
projectDirs.map(async (projectDir) => {
try {
const normalizedProjectDir = path.normalize(projectDir);
if (normalizedProjectDir === normalizedTranscriptProjectDir) {
return;
}
if (!(await this.isDirectory(normalizedProjectDir))) {
return;
}
await Promise.all([
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
recursive: true,
}),
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
recursive: true,
}),
]);
pushUniqueNormalizedPath(watchRootDirs, normalizedProjectDir);
} catch (error) {
logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`);
}
})
);
return watchRootDirs;
}
private async buildScopedWatchTargets(
projectDir: string,
confirmedSessionIds: readonly string[],
pendingRootSessionIds: readonly string[]
pendingRootSessionIds: readonly string[],
taskFreshnessRootDirs: readonly string[] = [projectDir]
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
const targets = new Set<string>();
const scopedSessionIds = new Set<string>();
targets.add(projectDir);
targets.add(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
targets.add(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
for (const freshnessRootDir of taskFreshnessRootDirs) {
targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
}
for (const rawSessionId of confirmedSessionIds) {
const sessionId = normalizeLogSourceSessionId(rawSessionId);
@ -664,11 +729,10 @@ export class TeamLogSourceTracker {
private handleTaskFreshnessSignalChange(
teamName: string,
projectDir: string,
changedPath: string,
signalDirName: string
signalDir: string,
taskSignalKind: TaskFreshnessSignalKind
): boolean {
const signalDir = path.join(projectDir, signalDirName);
const relativePath = path.relative(signalDir, changedPath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return path.normalize(changedPath) === path.normalize(signalDir);
@ -687,7 +751,7 @@ export class TeamLogSourceTracker {
return true;
}
if (decoded.kind === 'opaque-safe-segment') {
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath);
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath, taskSignalKind);
return true;
}
@ -695,6 +759,7 @@ export class TeamLogSourceTracker {
type: 'task-log-change',
teamName,
taskId: decoded.taskId,
taskSignalKind,
});
return true;
}
@ -720,7 +785,11 @@ export class TeamLogSourceTracker {
}
}
private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise<void> {
private async emitTaskFreshnessSignalFromFile(
teamName: string,
filePath: string,
taskSignalKind: TaskFreshnessSignalKind
): Promise<void> {
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
@ -733,6 +802,7 @@ export class TeamLogSourceTracker {
type: 'task-log-change',
teamName,
taskId,
taskSignalKind,
});
return;
}
@ -742,6 +812,36 @@ export class TeamLogSourceTracker {
this.emitLogSourceChange(teamName);
}
private handleTaskFreshnessSignalChangeForRoots(
teamName: string,
changedPath: string,
taskFreshnessRootDirs: readonly string[]
): boolean {
for (const freshnessRootDir of taskFreshnessRootDirs) {
if (
this.handleTaskFreshnessSignalChange(
teamName,
changedPath,
path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME),
'log'
)
) {
return true;
}
if (
this.handleTaskFreshnessSignalChange(
teamName,
changedPath,
path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME),
'change'
)
) {
return true;
}
}
return false;
}
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (this.getActiveConsumerCount(state) === 0) {

View file

@ -46,6 +46,7 @@ const SCAN_CONCURRENCY = 15;
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
const DISCOVERY_CACHE_TTL = 30_000;
const MAX_TASK_FRESHNESS_ROOT_DIRS = 64;
/** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
@ -116,6 +117,7 @@ export interface MemberLogFileRef {
export interface TeamLogSourceLiveContext {
projectDir: string;
projectPath?: string;
taskFreshnessRootDirs?: string[];
leadSessionId?: string;
sessionIds: string[];
watchSessionIds: string[];
@ -143,6 +145,30 @@ async function mapLimit<T, R>(
return results;
}
function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[] {
const roots: string[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (typeof candidate !== 'string') {
continue;
}
const trimmed = candidate.trim();
if (!trimmed || !path.isAbsolute(trimmed)) {
continue;
}
const normalized = path.normalize(trimmed);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
roots.push(normalized);
if (roots.length >= MAX_TASK_FRESHNESS_ROOT_DIRS) {
break;
}
}
return roots;
}
export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>();
private readonly attributionCache = new Map<
@ -286,13 +312,13 @@ export class TeamMemberLogsFinder {
readBootstrapLaunchSnapshot(teamName).catch(() => null),
]);
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
const extraProjectPathCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
const runtimeMemberCwdCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
(member) => member.cwd
);
const base = await this.projectResolver.getLiveBaseContext(teamName, {
forceRefresh: options?.forceRefresh,
extraProjectPathCandidates,
extraProjectPathCandidates: runtimeMemberCwdCandidates,
});
if (!base) {
return null;
@ -308,6 +334,11 @@ export class TeamMemberLogsFinder {
return {
projectDir: base.projectDir,
projectPath: base.config.projectPath,
taskFreshnessRootDirs: collectTaskFreshnessRootDirs([
base.config.projectPath,
...(base.config.members ?? []).map((member) => member.cwd),
...runtimeMemberCwdCandidates,
]),
leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId,
sessionIds: watchSessionIds,
watchSessionIds,

View file

@ -10645,6 +10645,7 @@ export class TeamProvisioningService {
runId,
taskId,
detail: `opencode-runtime-task-event:${event}`,
taskSignalKind: 'log',
});
return {

View file

@ -1243,6 +1243,7 @@ export const TeamDetailView = memo(function TeamDetailView({
restoreTask,
fetchDeletedTasks,
deletedTasks,
activeTaskLogActivity,
launchParams,
messagesPanelMode,
messagesPanelWidth,
@ -1299,6 +1300,7 @@ export const TeamDetailView = memo(function TeamDetailView({
restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
messagesPanelMode: s.messagesPanelMode,
messagesPanelWidth: s.messagesPanelWidth,
@ -2554,6 +2556,7 @@ export const TeamDetailView = memo(function TeamDetailView({
sessions={teamSessions}
leadSessionId={data.config.leadSessionId}
members={activeMembers}
activeTaskLogActivity={activeTaskLogActivity}
forceShowAllTasks={isKanbanSearchActive}
onFilterChange={setKanbanFilter}
onSortChange={setKanbanSort}

View file

@ -78,6 +78,7 @@ interface KanbanBoardProps {
sessions: Session[];
leadSessionId?: string;
members: ResolvedTeamMember[];
activeTaskLogActivity?: Record<string, true>;
/** Shows all cards when another UI flow, such as search, must not hide matches. */
forceShowAllTasks?: boolean;
onFilterChange: (filter: KanbanFilterState) => void;
@ -244,6 +245,7 @@ interface SortableKanbanTaskCardProps {
compact?: boolean;
taskMap: Map<string, TeamTask>;
memberColorMap: Map<string, string>;
hasLiveTaskLogs?: boolean;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -265,6 +267,7 @@ const SortableKanbanTaskCard = ({
compact,
taskMap,
memberColorMap,
hasLiveTaskLogs,
onRequestReview,
onApprove,
onRequestChanges,
@ -300,6 +303,7 @@ const SortableKanbanTaskCard = ({
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
hasLiveTaskLogs={hasLiveTaskLogs}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -325,6 +329,7 @@ export const KanbanBoard = memo(function KanbanBoard({
sessions,
leadSessionId,
members,
activeTaskLogActivity,
forceShowAllTasks = false,
onFilterChange,
onSortChange,
@ -578,6 +583,7 @@ export const KanbanBoard = memo(function KanbanBoard({
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -610,6 +616,7 @@ export const KanbanBoard = memo(function KanbanBoard({
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -630,6 +637,7 @@ export const KanbanBoard = memo(function KanbanBoard({
},
[
enableTaskSorting,
activeTaskLogActivity,
handleScrollToTask,
hasReviewers,
kanbanState,

View file

@ -274,3 +274,46 @@ describe('KanbanTaskCard blocked border', () => {
}
);
});
describe('KanbanTaskCard live log indicator', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('shows the live log indicator only when task log activity is active', async () => {
const { host, root } = await renderTaskCard({ hasLiveTaskLogs: true });
expect(host.querySelector('[aria-label="Task logs active"]')).not.toBeNull();
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: baseTask,
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
hasLiveTaskLogs: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Task logs active"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -1,5 +1,6 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Button } from '@renderer/components/ui/button';
@ -38,6 +39,7 @@ interface KanbanTaskCardProps {
compact?: boolean;
taskMap: Map<string, TeamTask>;
memberColorMap: Map<string, string>;
hasLiveTaskLogs?: boolean;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -227,6 +229,7 @@ export const KanbanTaskCard = memo(
compact,
taskMap,
memberColorMap,
hasLiveTaskLogs = false,
onRequestReview,
onApprove,
onRequestChanges,
@ -304,8 +307,13 @@ export const KanbanTaskCard = memo(
}
}}
>
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
{formatTaskDisplayLabel(task)}
<span className="absolute left-[3px] top-[2px] flex max-w-[calc(100%-72px)] items-center gap-1 text-[9px] leading-none text-[var(--color-text-muted)]">
<span className="truncate">{formatTaskDisplayLabel(task)}</span>
{hasLiveTaskLogs ? (
<span aria-label="Task logs active" className="inline-flex">
<OngoingIndicator size="sm" title="New task logs arriving" />
</span>
) : null}
</span>
{task.owner ? (
<span className="absolute right-[6px] top-[2px]">
@ -491,6 +499,7 @@ export const KanbanTaskCard = memo(
prev.compact === next.compact &&
prev.taskMap === next.taskMap &&
prev.memberColorMap === next.memberColorMap &&
prev.hasLiveTaskLogs === next.hasLiveTaskLogs &&
prev.onRequestReview === next.onRequestReview &&
prev.onApprove === next.onApprove &&
prev.onRequestChanges === next.onRequestChanges &&

View file

@ -14,6 +14,7 @@ import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
@ -375,7 +376,7 @@ export const TaskLogStreamSection = ({
}
const shouldReload =
event.type === 'log-source-change' ||
(event.type === 'task-log-change' && event.taskId === taskId);
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
if (!shouldReload) {
return;
}

View file

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
@ -187,7 +188,7 @@ export const TaskLogsPanel = ({
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (
event.teamName !== teamName ||
event.type !== 'task-log-change' ||
!isTaskLogActivityChangeEvent(event) ||
event.taskId !== task.id
) {
return;

View file

@ -12,6 +12,7 @@ import {
buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import { create } from 'zustand';
@ -87,6 +88,7 @@ const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const TASK_LOG_ACTIVITY_PULSE_MS = 2_500;
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
@ -273,6 +275,7 @@ export function initializeNotificationListeners(): () => void {
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let taskLogActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let processLiteStructuralReconcileTimers = new Map<
string,
{ firstScheduledAt: number; timer: ReturnType<typeof setTimeout> }
@ -547,6 +550,67 @@ export function initializeNotificationListeners(): () => void {
toolActivityTimers.delete(key);
}
};
const buildTaskLogActivityTimerKey = (teamName: string, taskId: string): string =>
`${teamName}\u0000${taskId}`;
const clearTaskLogActivityTimer = (teamName: string, taskId: string): void => {
const key = buildTaskLogActivityTimerKey(teamName, taskId);
const existing = taskLogActivityTimers.get(key);
if (existing) {
clearTimeout(existing);
taskLogActivityTimers.delete(key);
}
};
const clearTaskLogActivityTimersForTeam = (teamName: string): void => {
const prefix = `${teamName}\u0000`;
for (const [key, timer] of taskLogActivityTimers.entries()) {
if (!key.startsWith(prefix)) continue;
clearTimeout(timer);
taskLogActivityTimers.delete(key);
}
};
const clearTaskLogActivityStateForTeam = (teamName: string): void => {
clearTaskLogActivityTimersForTeam(teamName);
useStore.setState((prev) => {
if (!(teamName in prev.activeTaskLogActivityByTeam)) {
return {};
}
const next = { ...prev.activeTaskLogActivityByTeam };
delete next[teamName];
return { activeTaskLogActivityByTeam: next };
});
};
const markTaskLogActivity = (teamName: string, taskId: string): void => {
clearTaskLogActivityTimer(teamName, taskId);
useStore.setState((prev) => ({
activeTaskLogActivityByTeam: {
...prev.activeTaskLogActivityByTeam,
[teamName]: {
...(prev.activeTaskLogActivityByTeam[teamName] ?? {}),
[taskId]: true,
},
},
}));
const timerKey = buildTaskLogActivityTimerKey(teamName, taskId);
const timer = setTimeout(() => {
taskLogActivityTimers.delete(timerKey);
useStore.setState((prev) => {
const teamActivity = prev.activeTaskLogActivityByTeam[teamName];
if (!teamActivity?.[taskId]) {
return {};
}
const nextTeamActivity = { ...teamActivity };
delete nextTeamActivity[taskId];
const nextByTeam = { ...prev.activeTaskLogActivityByTeam };
if (Object.keys(nextTeamActivity).length === 0) {
delete nextByTeam[teamName];
} else {
nextByTeam[teamName] = nextTeamActivity;
}
return { activeTaskLogActivityByTeam: nextByTeam };
});
}, TASK_LOG_ACTIVITY_PULSE_MS);
taskLogActivityTimers.set(timerKey, timer);
};
const clearRuntimeToolStateForTeam = (
prev: AppState,
teamName: string
@ -860,6 +924,10 @@ export function initializeNotificationListeners(): () => void {
return getVisibleTeamNamesInAnyPane();
};
const getTrackedTaskLogActivityTeams = (): Set<string> => {
return getVisibleTeamNamesInAnyPane();
};
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
teamLastRelevantActivityAt.set(teamName, timestamp);
};
@ -1220,6 +1288,46 @@ export function initializeNotificationListeners(): () => void {
});
}
if (api.teams?.setTaskLogStreamTracking) {
let trackedTeamNames = new Set<string>();
const syncVisibleTeamTracking = (): void => {
const nextTrackedTeamNames = getTrackedTaskLogActivityTeams();
for (const teamName of nextTrackedTeamNames) {
if (!trackedTeamNames.has(teamName)) {
void api.teams.setTaskLogStreamTracking(teamName, true).catch(() => undefined);
}
}
for (const teamName of trackedTeamNames) {
if (!nextTrackedTeamNames.has(teamName)) {
void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined);
clearTaskLogActivityStateForTeam(teamName);
}
}
trackedTeamNames = nextTrackedTeamNames;
};
syncVisibleTeamTracking();
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
if (state.paneLayout === prevState.paneLayout) {
return;
}
syncVisibleTeamTracking();
});
cleanupFns.push(() => {
unsubscribeVisibleTeamTracking();
for (const teamName of trackedTeamNames) {
void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined);
clearTaskLogActivityStateForTeam(teamName);
}
trackedTeamNames.clear();
});
}
// Listen for task-list file changes to refresh currently viewed session metadata
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
@ -1422,6 +1530,8 @@ export function initializeNotificationListeners(): () => void {
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
delete nextState.leadContextByTeam[event.teamName];
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
nextState.activeTaskLogActivityByTeam = { ...prev.activeTaskLogActivityByTeam };
delete nextState.activeTaskLogActivityByTeam[event.teamName];
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
nextState.ignoredRuntimeRunIds = event.runId
@ -1431,6 +1541,7 @@ export function initializeNotificationListeners(): () => void {
}
: prev.ignoredRuntimeRunIds;
clearToolActivityTimersForTeam(event.teamName);
clearTaskLogActivityTimersForTeam(event.teamName);
}
return nextState as typeof prev;
@ -1585,6 +1696,55 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (event.type === 'task-log-change') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
const visible = isTeamVisibleInAnyPane(event.teamName);
if (event.taskId && visible) {
if (isTaskLogActivityChangeEvent(event)) {
markTaskLogActivity(event.teamName, event.taskId);
}
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
reason: 'event:task-log-change:task-state-safety',
operation: 'refreshTeamData',
eventType: event.type,
selected: useStore.getState().selectedTeamName === event.teamName,
visible,
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
if (!existingDetailTimer) {
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
const visibleAtExecution = isTeamVisibleInAnyPane(event.teamName);
noteTeamRefreshFanout({
teamName: event.teamName,
surface: 'team-change-listener',
phase: visibleAtExecution ? 'executed' : 'skipped',
reason: 'event:task-log-change:task-state-safety',
operation: 'refreshTeamData',
eventType: event.type,
selected: current.selectedTeamName === event.teamName,
visible: visibleAtExecution,
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
if (!visibleAtExecution) {
return;
}
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
}
}
return;
}
// Member spawn status change: fetch updated spawn statuses for the team.
if (event.type === 'member-spawn') {
if (isStaleRuntimeEvent) {
@ -1870,6 +2030,8 @@ export function initializeNotificationListeners(): () => void {
teamAgentRuntimeRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map();
for (const t of taskLogActivityTimers.values()) clearTimeout(t);
taskLogActivityTimers = new Map();
for (const state of processLiteStructuralReconcileTimers.values()) {
clearTimeout(state.timer);
}

View file

@ -328,6 +328,7 @@ function collectTeamScopedStateRemovals(
| 'provisioningStartedAtFloorByTeam'
| 'leadActivityByTeam'
| 'leadContextByTeam'
| 'activeTaskLogActivityByTeam'
| 'activeToolsByTeam'
| 'finishedVisibleByTeam'
| 'toolHistoryByTeam'
@ -353,6 +354,7 @@ function collectTeamScopedStateRemovals(
);
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
@ -378,6 +380,9 @@ function collectTeamScopedStateRemovals(
: {}),
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
...(nextActiveTaskLogActivity
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
: {}),
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
@ -2385,6 +2390,7 @@ export interface TeamSlice {
provisioningStartedAtFloorByTeam: Record<string, string>;
leadActivityByTeam: Record<string, LeadActivityState>;
leadContextByTeam: Record<string, LeadContextUsage>;
activeTaskLogActivityByTeam: Record<string, Record<string, true>>;
activeToolsByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
finishedVisibleByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
toolHistoryByTeam: Record<string, Record<string, ActiveToolCall[]>>;
@ -2727,6 +2733,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
provisioningStartedAtFloorByTeam: {},
leadActivityByTeam: {},
leadContextByTeam: {},
activeTaskLogActivityByTeam: {},
activeToolsByTeam: {},
finishedVisibleByTeam: {},
toolHistoryByTeam: {},

View file

@ -0,0 +1,18 @@
import type { TeamChangeEvent } from '@shared/types';
const RUNTIME_TASK_EVENT_DETAIL_PREFIX = 'opencode-runtime-task-event:';
export function isTaskLogActivityChangeEvent(event: TeamChangeEvent): boolean {
if (event.type !== 'task-log-change') {
return false;
}
if (event.taskSignalKind === 'log') {
return true;
}
if (event.taskSignalKind === 'change') {
return false;
}
return (
typeof event.detail === 'string' && event.detail.startsWith(RUNTIME_TASK_EVENT_DETAIL_PREFIX)
);
}

View file

@ -1208,6 +1208,8 @@ export interface TeamChangeEvent {
runId?: string;
detail?: string;
taskId?: string;
/** Distinguishes real task log freshness from task-change presence freshness. */
taskSignalKind?: 'log' | 'change';
}
export interface ProjectBranchChangeEvent {

View file

@ -1,5 +1,5 @@
import { createHash } from 'crypto';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import { mkdtemp, mkdir, rm, stat, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@ -55,6 +55,7 @@ describe('TeamLogSourceTracker', () => {
type: 'task-log-change',
teamName: 'demo',
taskId,
taskSignalKind: 'log',
});
});
@ -95,6 +96,7 @@ describe('TeamLogSourceTracker', () => {
type: 'task-log-change',
teamName: 'demo',
taskId,
taskSignalKind: 'log',
});
});
@ -106,6 +108,167 @@ describe('TeamLogSourceTracker', () => {
expect(emitter).not.toHaveBeenCalled();
});
it('creates transcript freshness dirs without creating missing live cwd roots', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-missing-root-'));
const transcriptProjectDir = path.join(tempDir, 'transcript-project');
const missingWorkspaceDir = path.join(tempDir, 'missing-workspace');
const logsFinder = {
getLiveLogSourceWatchContext: vi.fn(async () => ({
projectDir: transcriptProjectDir,
projectPath: missingWorkspaceDir,
taskFreshnessRootDirs: [missingWorkspaceDir],
sessionIds: [],
watchSessionIds: [],
})),
} 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');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
expect((await stat(path.join(transcriptProjectDir, '.board-task-log-freshness'))).isDirectory())
.toBe(true);
await expect(stat(missingWorkspaceDir)).rejects.toThrow();
const taskId = 'transcript-root-task';
await writeFile(
path.join(
transcriptProjectDir,
'.board-task-log-freshness',
`${encodeURIComponent(taskId)}.json`
),
JSON.stringify({ taskId }),
'utf8'
);
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId,
taskSignalKind: 'log',
});
});
await tracker.disableTracking('demo', 'task_log_stream');
});
it('emits log freshness kind from Windows-safe hashed task-log freshness files', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-log-'));
const logsFinder = {
getLiveLogSourceWatchContext: vi.fn(async () => ({
projectDir: tempDir!,
sessionIds: [],
watchSessionIds: [],
})),
} 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');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
const taskId = 'AUX';
const signalDir = path.join(tempDir, '.board-task-log-freshness');
await mkdir(signalDir, { recursive: true });
await writeFile(
path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`),
JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }),
'utf8'
);
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId,
taskSignalKind: 'log',
});
});
await tracker.disableTracking('demo', 'task_log_stream');
});
it('watches live cwd freshness roots used by Codex Native traces', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-codex-root-'));
const transcriptProjectDir = path.join(tempDir, 'transcripts');
const workspaceProjectDir = path.join(tempDir, 'workspace');
const memberProjectDir = path.join(tempDir, 'member-workspace');
await mkdir(transcriptProjectDir, { recursive: true });
await mkdir(workspaceProjectDir, { recursive: true });
await mkdir(memberProjectDir, { recursive: true });
const logsFinder = {
getLiveLogSourceWatchContext: vi.fn(async () => ({
projectDir: transcriptProjectDir,
projectPath: workspaceProjectDir,
taskFreshnessRootDirs: [workspaceProjectDir, memberProjectDir],
sessionIds: [],
watchSessionIds: [],
})),
} 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');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
const logTaskId = 'codex-task-1';
await writeFile(
path.join(
memberProjectDir,
'.board-task-log-freshness',
`${encodeURIComponent(logTaskId)}.json`
),
JSON.stringify({ taskId: logTaskId, source: 'codex-native-trace' }),
'utf8'
);
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId: logTaskId,
taskSignalKind: 'log',
});
});
emitter.mockClear();
const changeTaskId = 'codex-task-2';
await writeFile(
path.join(
workspaceProjectDir,
'.board-task-change-freshness',
`${encodeURIComponent(changeTaskId)}.json`
),
JSON.stringify({ taskId: changeTaskId }),
'utf8'
);
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId: changeTaskId,
taskSignalKind: 'change',
});
});
await tracker.disableTracking('demo', 'task_log_stream');
});
it('emits log-source-change for scoped root transcripts', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-scoped-root-'));
await writeFile(path.join(tempDir, 'lead-session.jsonl'), '{"seq":1}\n');
@ -275,6 +438,7 @@ describe('TeamLogSourceTracker', () => {
type: 'task-log-change',
teamName: 'demo',
taskId,
taskSignalKind: 'log',
});
});
@ -314,6 +478,7 @@ describe('TeamLogSourceTracker', () => {
type: 'task-log-change',
teamName: 'demo',
taskId,
taskSignalKind: 'change',
});
});
expect(emitter.mock.calls).not.toContainEqual([

View file

@ -24,13 +24,15 @@ describe('TeamMemberLogsFinder', () => {
const teamName = 'live-context-team';
const projectPath = '/Users/test/live-context';
const memberProjectPath = '/Users/test/member-cwd';
const runtimeProjectPath = '/Users/test/runtime-bob-cwd';
const projectRoot = path.join(tmpDir, 'projects', '-Users-test-live-context');
const config = {
name: teamName,
projectPath,
leadSessionId: 'lead-session',
sessionHistory: ['old-session', 'recent-session'],
members: [],
members: [{ name: 'bob', cwd: memberProjectPath }],
};
await fs.mkdir(projectRoot, { recursive: true });
@ -61,6 +63,7 @@ describe('TeamMemberLogsFinder', () => {
bootstrapConfirmed: false,
hardFailure: false,
runtimeSessionId: 'runtime-bob',
cwd: runtimeProjectPath,
updatedAt: '2026-05-03T12:00:00.000Z',
},
},
@ -81,7 +84,10 @@ describe('TeamMemberLogsFinder', () => {
expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith(
teamName,
expect.objectContaining({ forceRefresh: true })
expect.objectContaining({
forceRefresh: true,
extraProjectPathCandidates: [runtimeProjectPath],
})
);
expect(projectResolver.getContext).not.toHaveBeenCalled();
expect(context?.projectDir).toBe(projectRoot);
@ -92,6 +98,11 @@ describe('TeamMemberLogsFinder', () => {
'old-session',
]);
expect(context?.sessionIds).toEqual(context?.watchSessionIds);
expect(context?.taskFreshnessRootDirs).toEqual([
projectPath,
memberProjectPath,
runtimeProjectPath,
]);
});
it('returns subagent logs for a member and lead session for team-lead', async () => {

View file

@ -478,7 +478,12 @@ describe('TaskLogStreamSection', () => {
expect(handler).toBeTypeOf('function');
await act(async () => {
handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' });
handler?.(null, {
teamName: 'other-team',
type: 'task-log-change',
taskId: 'task-a',
taskSignalKind: 'log',
});
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
@ -486,7 +491,12 @@ describe('TaskLogStreamSection', () => {
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-b',
taskSignalKind: 'log',
});
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
@ -494,7 +504,25 @@ describe('TaskLogStreamSection', () => {
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-a',
taskSignalKind: 'change',
});
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-a',
taskSignalKind: 'log',
});
vi.advanceTimersByTime(400);
await flushMicrotasks();
});
@ -586,7 +614,12 @@ describe('TaskLogStreamSection', () => {
).toBe('false');
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-a',
taskSignalKind: 'log',
});
vi.advanceTimersByTime(400);
await flushMicrotasks();
});

View file

@ -341,15 +341,36 @@ describe('TaskLogsPanel', () => {
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' });
handler?.(null, {
teamName: 'other-team',
type: 'task-log-change',
taskId: 'task-1',
taskSignalKind: 'log',
});
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-2',
taskSignalKind: 'log',
});
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-1',
taskSignalKind: 'change',
});
await flushMicrotasks();
});
expect(activityStates).toEqual([false]);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-1',
taskSignalKind: 'log',
});
await flushMicrotasks();
});
@ -411,7 +432,12 @@ describe('TaskLogsPanel', () => {
expect(activityStates).toEqual([false]);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-1',
taskSignalKind: 'log',
});
await flushMicrotasks();
});
@ -443,7 +469,12 @@ describe('TaskLogsPanel', () => {
expect(handler).toBeTypeOf('function');
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-1',
taskSignalKind: 'log',
});
await flushMicrotasks();
});
@ -564,7 +595,12 @@ describe('TaskLogsPanel', () => {
expect(counts).toEqual([undefined, 4]);
await act(async () => {
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
handler?.(null, {
teamName: 'demo',
type: 'task-log-change',
taskId: 'task-1',
taskSignalKind: 'log',
});
vi.advanceTimersByTime(350);
await flushMicrotasks();
});

View file

@ -4,7 +4,14 @@ const hoisted = vi.hoisted(() => ({
onTeamChangeCb: null as
| ((
event: unknown,
data: { type?: string; teamName: string; detail?: string; runId?: string }
data: {
type?: string;
teamName: string;
detail?: string;
runId?: string;
taskId?: string;
taskSignalKind?: 'log' | 'change';
}
) => void)
| null,
onProvisioningProgressCb: null as
@ -36,11 +43,19 @@ vi.mock('@renderer/api', () => ({
teams: {
setChangePresenceTracking: vi.fn(async () => undefined),
setToolActivityTracking: vi.fn(async () => undefined),
setTaskLogStreamTracking: vi.fn(async () => undefined),
onTeamChange: vi.fn(
(
cb: (
event: unknown,
data: { teamName: string; type?: string; detail?: string; runId?: string }
data: {
teamName: string;
type?: string;
detail?: string;
runId?: string;
taskId?: string;
taskSignalKind?: 'log' | 'change';
}
) => void
): (() => void) => {
hoisted.onTeamChangeCb = cb;
@ -112,6 +127,7 @@ describe('team change throttling', () => {
currentRuntimeRunIdByTeam: {},
ignoredProvisioningRunIds: {},
ignoredRuntimeRunIds: {},
activeTaskLogActivityByTeam: {},
memberSpawnStatusesByTeam: {},
memberSpawnSnapshotsByTeam: {},
teamAgentRuntimeByTeam: {},
@ -1543,6 +1559,190 @@ describe('team change throttling', () => {
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
});
it('tracks visible team tabs for task log activity and disables tracking when tab disappears', async () => {
const setTaskLogStreamTrackingSpy = vi.mocked(api.teams.setTaskLogStreamTracking);
setTaskLogStreamTrackingSpy.mockClear();
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', true);
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
},
} as never);
await vi.advanceTimersByTimeAsync(0);
expect(setTaskLogStreamTrackingSpy).toHaveBeenCalledWith('my-team', false);
});
it('pulses task log activity only for real log signals and clears it after inactivity', async () => {
hoisted.onTeamChangeCb?.({}, {
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-change-only',
taskSignalKind: 'change',
});
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
useStore.setState({ currentRuntimeRunIdByTeam: { 'my-team': 'run-current' } } as never);
hoisted.onTeamChangeCb?.({}, {
type: 'task-log-change',
teamName: 'my-team',
runId: 'run-old',
taskId: 'task-stale',
taskSignalKind: 'log',
});
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
hoisted.onTeamChangeCb?.({}, {
type: 'task-log-change',
teamName: 'my-team',
runId: 'run-current',
taskId: 'task-live',
taskSignalKind: 'log',
});
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
'task-live': true,
});
await vi.advanceTimersByTimeAsync(2499);
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
'task-live': true,
});
await vi.advanceTimersByTimeAsync(1);
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
});
it('schedules a bounded team data refresh for visible task log signals', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-live',
taskSignalKind: 'log',
}
);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('refreshes visible team data for task change freshness without pulsing live log activity', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-completed',
taskSignalKind: 'change',
}
);
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('skips the bounded task log refresh if the team is hidden before execution', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-live',
taskSignalKind: 'log',
}
);
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
},
} as never);
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
});
it('extends task log activity pulse on repeated log signals and ignores hidden teams', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.({}, {
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-live',
taskSignalKind: 'log',
});
await vi.advanceTimersByTimeAsync(2000);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
hoisted.onTeamChangeCb?.({}, {
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-live',
taskSignalKind: 'log',
});
await vi.advanceTimersByTimeAsync(2499);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toEqual({
'task-live': true,
});
await vi.advanceTimersByTimeAsync(1);
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
},
} as never);
hoisted.onTeamChangeCb?.({}, {
type: 'task-log-change',
teamName: 'my-team',
taskId: 'task-hidden',
taskSignalKind: 'log',
});
expect(useStore.getState().activeTaskLogActivityByTeam['my-team']).toBeUndefined();
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
});
it('applies targeted tool resets without clearing sibling tools', async () => {
useStore.setState({
activeToolsByTeam: {