feat(team): show live task log activity
This commit is contained in:
parent
f57b1bf18b
commit
b7fa5443fd
18 changed files with 880 additions and 49 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10645,6 +10645,7 @@ export class TeamProvisioningService {
|
|||
runId,
|
||||
taskId,
|
||||
detail: `opencode-runtime-task-event:${event}`,
|
||||
taskSignalKind: 'log',
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
18
src/renderer/utils/teamChangeEvents.ts
Normal file
18
src/renderer/utils/teamChangeEvents.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue