Merge branch 'dev' into spike/team-snapshot-split-plan

This commit is contained in:
777genius 2026-04-18 13:13:07 +03:00
commit f22373d4a9
13 changed files with 1232 additions and 36 deletions

View file

@ -1977,6 +1977,7 @@ function buildDeterministicLaunchHydrationPrompt(
const userPromptBlock = request.prompt?.trim()
? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n`
: '';
const hasOriginalUserPrompt = Boolean(request.prompt?.trim());
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const persistentContext = buildPersistentLeadContext({
teamName: request.teamName,
@ -1990,13 +1991,21 @@ Do NOT call TeamCreate.
Do NOT use Agent to spawn or restore teammates.
Do NOT start implementation in this turn.
Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.
If the user instructions imply new substantial work that is not on the board yet, you MAY create or update board tasks for yourself, but do not begin executing them yet.`
${
hasOriginalUserPrompt
? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.'
: 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
}`
: `This reconnect/bootstrap step has already been completed deterministically by the runtime.
Do NOT call TeamCreate.
Do NOT use Agent to spawn or restore teammates.
Do NOT repeat the launch summary.
Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.
If the user instructions imply new substantial work that is not on the board yet, you MAY create or update team-board tasks and assign owners now, but do NOT start implementation work in this turn.
Use this turn only to refresh context and review the current board snapshot.
${
hasOriginalUserPrompt
? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.'
: 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
}
Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`;
return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
@ -2023,6 +2032,7 @@ function buildGeminiPostLaunchHydrationPrompt(
const userPromptBlock = run.request.prompt?.trim()
? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n`
: '';
const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim());
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const teammateBootstrapSnapshot = members.length
? `Current teammate launch status:\n${members
@ -2051,8 +2061,12 @@ function buildGeminiPostLaunchHydrationPrompt(
members,
});
const nextStepInstruction = isSolo
? 'From this point on, use the full operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now create board tasks for yourself, but do not start implementation in this context-refresh turn.'
: 'From this point on, use the full team operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now translate them into board tasks and prepare delegation, but do not start implementation work in this context-refresh turn. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.';
? hasOriginalUserPrompt
? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.'
: 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.'
: hasOriginalUserPrompt
? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'
: 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.';
return `Gemini launch phase 2 — operating context for team "${run.teamName}".

View file

@ -1,6 +1,9 @@
import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction';
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
import { TeamTaskReader } from '../../TeamTaskReader';
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector';
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
@ -16,12 +19,14 @@ import type {
BoardTaskLogParticipant,
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
TeamTask,
} from '@shared/types';
interface StreamSlice {
id: string;
timestamp: string;
filePath: string;
sortOrder?: number;
participantKey: string;
actor: BoardTaskLogActor;
actionCategory?: BoardTaskActivityCategory;
@ -37,6 +42,17 @@ interface MergedMessageAccumulator {
toolUseResults: ToolUseResultData[];
}
interface TimeWindow {
startMs: number;
endMs: number | null;
}
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000;
const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000;
const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000;
const INFERRED_RECORD_RANGE_AFTER_MS = 60_000;
function emptyResponse(): BoardTaskLogStreamResponse {
return {
participants: [],
@ -49,6 +65,12 @@ function normalizeMemberName(value: string): string {
return value.trim().toLowerCase();
}
function isBoardMcpToolName(toolName: string | undefined): boolean {
if (!toolName) return false;
const normalized = toolName.trim().toLowerCase();
return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor {
return {
...(detail.memberName ? { memberName: detail.memberName } : {}),
@ -691,15 +713,319 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string {
return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`;
}
function buildToolNameByUseId(
parsedMessagesByFile: Map<string, ParsedMessage[]>
): Map<string, string> {
const toolNameByUseId = new Map<string, string>();
for (const messages of parsedMessagesByFile.values()) {
for (const message of messages) {
for (const toolCall of message.toolCalls) {
toolNameByUseId.set(toolCall.id, toolCall.name);
}
}
}
return toolNameByUseId;
}
function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] {
const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : [])
.map((interval) => {
const startedAt = Date.parse(interval.startedAt);
if (!Number.isFinite(startedAt)) {
return null;
}
const completedAt =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN;
return {
startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS,
endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null,
};
})
.filter((window): window is TimeWindow => window !== null);
if (windowsFromIntervals.length > 0) {
return windowsFromIntervals;
}
const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN;
const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN;
if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) {
const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs;
return [
{
startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS,
endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null,
},
];
}
const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp));
if (finiteRecordTimestamps.length === 0) {
return [];
}
return [
{
startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS,
endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS,
},
];
}
function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean {
const messageTime = timestamp.getTime();
if (!Number.isFinite(messageTime)) {
return false;
}
if (windows.length === 0) {
return true;
}
const now = Date.now();
return windows.some((window) => {
const endMs = window.endMs ?? now;
return messageTime >= window.startMs && messageTime <= endMs;
});
}
function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set<string> {
return new Set(records.map((record) => record.source.messageUuid));
}
function collectExplicitToolUseIds(
records: {
source: { toolUseId?: string };
action?: { toolUseId?: string };
}[]
): Set<string> {
const toolUseIds = new Set<string>();
for (const record of records) {
const sourceToolUseId = record.source.toolUseId?.trim();
if (sourceToolUseId) {
toolUseIds.add(sourceToolUseId);
}
const actionToolUseId = record.action?.toolUseId?.trim();
if (actionToolUseId) {
toolUseIds.add(actionToolUseId);
}
}
return toolUseIds;
}
function collectAllowedMemberNames(
task: TeamTask,
records: { actor: { memberName?: string } }[]
): Set<string> {
const allowedNames = new Set<string>();
if (typeof task.owner === 'string' && task.owner.trim().length > 0) {
allowedNames.add(normalizeMemberName(task.owner));
}
for (const record of records) {
if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) {
allowedNames.add(normalizeMemberName(record.actor.memberName));
}
}
return allowedNames;
}
function extractMessageToolUseIds(message: ParsedMessage): Set<string> {
const toolUseIds = new Set<string>();
for (const toolCall of message.toolCalls) {
if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) {
toolUseIds.add(toolCall.id.trim());
}
}
for (const toolResult of message.toolResults) {
if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) {
toolUseIds.add(toolResult.toolUseId.trim());
}
}
if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) {
toolUseIds.add(message.sourceToolUseID.trim());
}
return toolUseIds;
}
function messageHasNonBoardToolActivity(
message: ParsedMessage,
toolNameByUseId: Map<string, string>
): boolean {
for (const toolCall of message.toolCalls) {
if (!isBoardMcpToolName(toolCall.name)) {
return true;
}
}
for (const toolResult of message.toolResults) {
if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) {
return true;
}
}
if (message.sourceToolUseID) {
const sourceToolName = toolNameByUseId.get(message.sourceToolUseID);
if (sourceToolName && !isBoardMcpToolName(sourceToolName)) {
return true;
}
}
return false;
}
function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null {
const sessionId = message.sessionId?.trim();
if (!sessionId) {
return null;
}
const memberName =
typeof message.agentName === 'string' && message.agentName.trim().length > 0
? message.agentName.trim()
: undefined;
const isLead =
memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName);
return {
...(memberName ? { memberName } : {}),
role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown',
sessionId,
...(message.agentId ? { agentId: message.agentId } : {}),
isSidechain: message.isSidechain,
};
}
function compareSlices(left: StreamSlice, right: StreamSlice): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return leftTs - rightTs;
}
if (left.filePath !== right.filePath) {
return left.filePath.localeCompare(right.filePath);
}
if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) {
return (left.sortOrder ?? 0) - (right.sortOrder ?? 0);
}
return left.id.localeCompare(right.id);
}
export class BoardTaskLogStreamService {
constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(),
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(),
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator()
) {}
private async buildInferredExecutionSlices(
teamName: string,
taskId: string,
records: Awaited<ReturnType<BoardTaskActivityRecordSource['getTaskRecords']>>,
parsedMessagesByFile: Map<string, ParsedMessage[]>
): Promise<StreamSlice[]> {
if (records.some((record) => record.linkKind === 'execution')) {
return [];
}
const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([
this.taskReader.getTasks(teamName),
this.taskReader.getDeletedTasks(teamName),
this.transcriptSourceLocator.getContext(teamName),
]);
const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId);
if (!task) {
return [];
}
const transcriptFiles = transcriptContext?.transcriptFiles ?? [];
const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath));
let mergedParsedMessagesByFile = parsedMessagesByFile;
if (missingFiles.length > 0) {
const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles);
mergedParsedMessagesByFile = new Map([
...parsedMessagesByFile.entries(),
...additionalParsedMessages.entries(),
]);
}
const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile);
const recordTimestamps = records.map((record) => Date.parse(record.timestamp));
const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps);
if (taskTimeWindows.length === 0) {
return [];
}
const explicitMessageIds = collectExplicitMessageIds(records);
const explicitToolUseIds = collectExplicitToolUseIds(records);
const allowedMemberNames = collectAllowedMemberNames(task, records);
const leadName =
transcriptContext?.config.members
?.find((member) => isLeadMemberCheck(member))
?.name?.trim() || 'team-lead';
const inferredSlices: StreamSlice[] = [];
for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) {
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (explicitMessageIds.has(message.uuid)) {
continue;
}
if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) {
continue;
}
const actor = buildInferredActor(message, leadName);
if (!actor || !actor.memberName) {
continue;
}
if (
allowedMemberNames.size > 0 &&
!allowedMemberNames.has(normalizeMemberName(actor.memberName))
) {
continue;
}
const messageToolUseIds = extractMessageToolUseIds(message);
if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) {
continue;
}
if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) {
continue;
}
inferredSlices.push({
id: `inferred:${filePath}:${message.uuid}`,
timestamp: message.timestamp.toISOString(),
filePath,
sortOrder: index,
participantKey: buildParticipantKey(actor),
actor,
filteredMessages: [message],
});
}
}
return inferredSlices.sort(compareSlices);
}
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
if (!isBoardTaskExactLogsReadEnabled()) {
return emptyResponse();
@ -762,6 +1088,7 @@ export class BoardTaskLogStreamService {
id: detail.id,
timestamp: detail.timestamp,
filePath: detail.source.filePath,
sortOrder: detail.source.sourceOrder,
participantKey: buildParticipantKey(actor),
actor,
actionCategory: candidate.actionCategory,
@ -773,7 +1100,14 @@ export class BoardTaskLogStreamService {
return emptyResponse();
}
const deNoisedSlices = filterReadOnlySlices(slices);
const inferredExecutionSlices = await this.buildInferredExecutionSlices(
teamName,
taskId,
records,
parsedMessagesByFile
);
const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices);
const deNoisedSlices = filterReadOnlySlices(combinedSlices);
const namedParticipantSlices = deNoisedSlices.filter((slice) =>
hasNamedParticipant(slice.actor)

View file

@ -130,6 +130,7 @@ interface ConversationalEntry extends BaseEntry {
sessionId: string;
version: string;
gitBranch: string;
agentName?: string;
slug?: string;
}

View file

@ -80,10 +80,14 @@ export interface ParsedMessage {
// Metadata
/** Current working directory when message was created */
cwd?: string;
/** Root/session identifier from transcript */
sessionId?: string;
/** Git branch context */
gitBranch?: string;
/** Agent ID for subagent messages */
agentId?: string;
/** Human-readable agent/member name from transcript */
agentName?: string;
/** Whether this is a sidechain message */
isSidechain: boolean;
/** Whether this is a meta message */

View file

@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
let model: string | undefined;
let requestId: string | undefined;
let cwd: string | undefined;
let sessionId: string | undefined;
let gitBranch: string | undefined;
let agentId: string | undefined;
let agentName: string | undefined;
let isSidechain = false;
let isMeta = false;
let userType: string | undefined;
@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
if (isConversationalEntry(entry)) {
// Common properties from ConversationalEntry base
cwd = entry.cwd;
sessionId = entry.sessionId;
gitBranch = entry.gitBranch;
isSidechain = entry.isSidechain ?? false;
userType = entry.userType;
parentUuid = entry.parentUuid ?? null;
agentName = entry.agentName;
// Type-specific properties
if (entry.type === 'user') {
@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
model,
// Metadata
cwd,
sessionId,
gitBranch,
agentId,
agentName,
isSidechain,
isMeta,
userType,

View file

@ -33,6 +33,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal';
import { type ComboboxOption } from '../ui/combobox';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
canProjectGroupShowLess,
canProjectGroupShowMore,
getNextProjectGroupVisibleCount,
getPreviousProjectGroupVisibleCount,
getProjectGroupVisibleCount,
syncProjectGroupVisibleCountByKey,
} from './projectGroupPagination';
import { SidebarTaskItem } from './SidebarTaskItem';
import { TaskContextMenu } from './TaskContextMenu';
import { TaskFiltersPopover } from './TaskFiltersPopover';
@ -207,6 +215,9 @@ export const GlobalTaskList = ({
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState<
Record<string, number>
>({});
const searchInputRef = useRef<HTMLInputElement>(null);
const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot();
@ -221,21 +232,23 @@ export const GlobalTaskList = ({
return new Set<string>();
}
// First load: seed all known IDs, no animations
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
if (isInitialTaskLoadRef.current) {
isInitialTaskLoadRef.current = false;
for (const t of globalTasks) {
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`);
}
return new Set<string>();
}
// Subsequent updates: detect truly new tasks
const newIds = new Set<string>();
for (const t of globalTasks) {
const key = `${t.teamName}:${t.id}`;
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
if (!knownTaskIdsRef.current.has(key)) {
newIds.add(key);
// eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true.
knownTaskIdsRef.current.add(key);
}
}
@ -326,6 +339,11 @@ export const GlobalTaskList = ({
// Resolve project filter from filters state
const selectedProjectPath = filters.projectPath;
const hasArchivedTasks = useMemo(
() => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)),
[globalTasks, taskLocalState]
);
const effectiveShowArchived = showArchived && hasArchivedTasks;
const filtered = useMemo(() => {
let result = globalTasks;
@ -345,7 +363,7 @@ export const GlobalTaskList = ({
}
result = applySearch(result, searchQuery);
// Archive filtering
if (showArchived) {
if (effectiveShowArchived) {
result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id));
} else {
result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id));
@ -353,29 +371,16 @@ export const GlobalTaskList = ({
return result;
}, [
globalTasks,
filters.projectPath,
selectedProjectPath,
filters.statusIds,
filters.teamName,
filters.readFilter,
searchQuery,
readState,
showArchived,
effectiveShowArchived,
taskLocalState,
]);
// Check if any archived tasks exist (before archive filtering) to conditionally show the toggle
const hasArchivedTasks = useMemo(
() => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)),
[globalTasks, taskLocalState]
);
// Reset showArchived when archive becomes empty
useEffect(() => {
if (showArchived && !hasArchivedTasks) {
setShowArchived(false);
}
}, [showArchived, hasArchivedTasks]);
// Split into pinned and normal (non-pinned) tasks
const pinnedTasks = useMemo(
() => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)),
@ -400,6 +405,19 @@ export const GlobalTaskList = ({
[projectGroups]
);
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
const projectGroupVisibility = useMemo(
() =>
projectGroups.map((group) => ({
projectKey: group.projectKey,
taskCount: group.tasks.length,
})),
[projectGroups]
);
const projectVisibleCountByKey = useMemo(
() =>
syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility),
[projectRequestedVisibleCountByKey, projectGroupVisibility]
);
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
@ -499,7 +517,7 @@ export const GlobalTaskList = ({
</div>
{/* Pinned tasks section */}
{pinnedTasks.length > 0 && !showArchived && (
{pinnedTasks.length > 0 && !effectiveShowArchived && (
<div className="shrink-0 border-b" style={{ borderColor: 'var(--color-border)' }}>
<div className="flex items-center gap-1 px-2 py-1">
<Pin className="size-3 text-text-muted" />
@ -562,7 +580,7 @@ export const GlobalTaskList = ({
onClick={() => setShowArchived(!showArchived)}
className={cn(
'rounded p-0.5 transition-colors',
showArchived
effectiveShowArchived
? 'bg-surface-raised text-text-secondary'
: 'text-text-muted hover:text-text-secondary'
)}
@ -571,7 +589,7 @@ export const GlobalTaskList = ({
</button>
</TooltipTrigger>
<TooltipContent side="top">
{showArchived ? 'Hide archived' : 'Show archived'}
{effectiveShowArchived ? 'Hide archived' : 'Show archived'}
</TooltipContent>
</Tooltip>
</div>
@ -627,14 +645,25 @@ export const GlobalTaskList = ({
if (group.tasks.length === 0) return null;
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
const groupColor = projectColor(group.projectLabel);
const visibleCount = getProjectGroupVisibleCount(
projectVisibleCountByKey[group.projectKey],
group.tasks.length
);
const visibleTasks = group.tasks.slice(0, visibleCount);
const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length);
const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length);
let lastTeam: string | null = null;
return (
<div key={group.projectKey}>
<button
type="button"
onClick={() => projectCollapsed.toggle(group.projectKey)}
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold transition-colors"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1.5 p-2 transition-colors"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
backgroundImage: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
}}
>
{isGroupCollapsed ? (
<ChevronRight className="size-3 shrink-0 text-text-muted" />
@ -642,11 +671,14 @@ export const GlobalTaskList = ({
<ChevronDown className="size-3 shrink-0 text-text-muted" />
)}
<Folder
className="size-3 shrink-0"
style={{ color: groupColor.border }}
className="size-3.5 shrink-0"
style={{ color: groupColor.icon }}
aria-hidden="true"
/>
<span className="truncate" style={{ color: groupColor.text }}>
<span
className="truncate text-[13px] font-bold leading-none"
style={{ color: groupColor.icon }}
>
{group.projectLabel}
</span>
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
@ -654,7 +686,7 @@ export const GlobalTaskList = ({
</span>
</button>
{!isGroupCollapsed &&
group.tasks.map((task) => {
visibleTasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
@ -691,6 +723,44 @@ export const GlobalTaskList = ({
</div>
);
})}
{!isGroupCollapsed && (showMoreVisible || showLessVisible) && (
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
{showMoreVisible && (
<button
type="button"
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
onClick={() =>
setProjectRequestedVisibleCountByKey((prev) => ({
...prev,
[group.projectKey]: getNextProjectGroupVisibleCount(
projectVisibleCountByKey[group.projectKey],
group.tasks.length
),
}))
}
>
Show more
</button>
)}
{showLessVisible && (
<button
type="button"
className="text-[11px] font-medium text-text-muted transition-colors hover:text-text"
onClick={() =>
setProjectRequestedVisibleCountByKey((prev) => ({
...prev,
[group.projectKey]: getPreviousProjectGroupVisibleCount(
projectVisibleCountByKey[group.projectKey],
group.tasks.length
),
}))
}
>
Show less
</button>
)}
</div>
)}
</div>
);
})}

View file

@ -0,0 +1,89 @@
export const PROJECT_GROUP_PAGE_SIZE = 5;
export interface ProjectGroupVisibilityDescriptor {
projectKey: string;
taskCount: number;
}
export function getProjectGroupVisibleCount(
visibleCount: number | undefined,
taskCount: number
): number {
if (taskCount <= 0) {
return 0;
}
const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount);
if (visibleCount == null || !Number.isFinite(visibleCount)) {
return minimumVisibleCount;
}
const normalizedVisibleCount = Math.floor(visibleCount);
return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount));
}
export function getNextProjectGroupVisibleCount(
visibleCount: number | undefined,
taskCount: number
): number {
const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount);
if (currentVisibleCount >= taskCount) {
return currentVisibleCount;
}
return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE);
}
export function getPreviousProjectGroupVisibleCount(
visibleCount: number | undefined,
taskCount: number
): number {
const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount);
const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount);
return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE);
}
export function canProjectGroupShowMore(
visibleCount: number | undefined,
taskCount: number
): boolean {
return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount;
}
export function canProjectGroupShowLess(
visibleCount: number | undefined,
taskCount: number
): boolean {
if (taskCount <= PROJECT_GROUP_PAGE_SIZE) {
return false;
}
return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE;
}
export function syncProjectGroupVisibleCountByKey(
previousVisibleCountByKey: Record<string, number>,
groups: readonly ProjectGroupVisibilityDescriptor[]
): Record<string, number> {
let changed = false;
const nextVisibleCountByKey: Record<string, number> = {};
for (const group of groups) {
const nextVisibleCount = getProjectGroupVisibleCount(
previousVisibleCountByKey[group.projectKey],
group.taskCount
);
if (nextVisibleCount > 0) {
nextVisibleCountByKey[group.projectKey] = nextVisibleCount;
}
if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) {
changed = true;
}
}
if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) {
changed = true;
}
return changed ? nextVisibleCountByKey : previousVisibleCountByKey;
}

View file

@ -0,0 +1,10 @@
{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-start-real","timestamp":"2026-04-12T15:36:00.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-start-real","message":{"id":"msg-a-start-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","id":"call-task-start-real","name":"mcp__agent-teams__task_start","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}}
{"parentUuid":"a-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-start-real","timestamp":"2026-04-12T15:36:00.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-start-real","sourceToolUseID":"call-task-start-real","toolUseResult":{"toolUseId":"call-task-start-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-task-start-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"idle"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-task-start-real","canonicalToolName":"task_start"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-task-start-real","content":"ok"}]}}
{"parentUuid":"u-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-real","timestamp":"2026-04-12T15:36:14.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-bash-real","message":{"id":"msg-a-bash-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":7},"content":[{"type":"tool_use","id":"call-bash-real","name":"Bash","input":{"command":"pnpm test --filter signal-ops","description":"Run targeted tests"}}]}}
{"parentUuid":"a-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-real","timestamp":"2026-04-12T15:36:14.250Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-real","sourceToolUseID":"call-bash-real","toolUseResult":{"toolUseId":"call-bash-real","stdout":"tests ok","stderr":"","exitCode":0,"content":"tests ok"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-real","content":"tests ok"}]}}
{"parentUuid":"u-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-comment-real","timestamp":"2026-04-12T15:36:30.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-comment-real","message":{"id":"msg-a-comment-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":6},"content":[{"type":"tool_use","id":"call-comment-real","name":"mcp__agent-teams__task_add_comment","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7","text":"Diagnostics complete - passing targeted checks."}}]}}
{"parentUuid":"a-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-comment-real","timestamp":"2026-04-12T15:36:30.150Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-comment-real","sourceToolUseID":"call-comment-real","toolUseResult":{"toolUseId":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-comment-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"board_action","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-comment-real","canonicalToolName":"task_add_comment","resultRefs":{"commentId":"comment-real-1"}}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"}]}}
{"parentUuid":"u-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-alice-real","timestamp":"2026-04-12T15:36:35.000Z","agentName":"alice","teamName":"beacon-desk-2","requestId":"req-bash-alice-real","message":{"id":"msg-a-bash-alice-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":9,"output_tokens":4},"content":[{"type":"tool_use","id":"call-bash-alice-real","name":"Bash","input":{"command":"echo alien","description":"Unrelated command"}}]}}
{"parentUuid":"a-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-alice-real","timestamp":"2026-04-12T15:36:35.100Z","agentName":"alice","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-alice-real","sourceToolUseID":"call-bash-alice-real","toolUseResult":{"toolUseId":"call-bash-alice-real","stdout":"alien","stderr":"","exitCode":0,"content":"alien"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-alice-real","content":"alien"}]}}
{"parentUuid":"u-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-complete-real","timestamp":"2026-04-12T15:36:45.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-complete-real","message":{"id":"msg-a-complete-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":8,"output_tokens":4},"content":[{"type":"tool_use","id":"call-complete-real","name":"mcp__agent-teams__task_complete","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}}
{"parentUuid":"a-complete-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-complete-real","timestamp":"2026-04-12T15:36:45.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-complete-real","sourceToolUseID":"call-complete-real","toolUseResult":{"toolUseId":"call-complete-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-complete-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-complete-real","canonicalToolName":"task_complete"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-complete-real","content":"ok"}]}}

View file

@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
@ -13,6 +13,10 @@ import type { TeamTask } from '../../../../src/shared/types';
const TEAM_NAME = 'beacon-desk-2';
const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7';
const REAL_FIXTURE_PATH = path.resolve(
process.cwd(),
'test/fixtures/team/task-log-stream-fallback-real.jsonl',
);
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
return {
@ -377,4 +381,266 @@ describe('BoardTaskLogStreamService integration', () => {
expect(response.segments).toHaveLength(1);
expect(commentResult).toBeUndefined();
});
it('falls back to task time-window worker logs when explicit execution links are missing', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const task = createTask({
owner: 'tom',
workIntervals: [
{
startedAt: '2026-04-12T15:36:00.000Z',
completedAt: '2026-04-12T15:40:00.000Z',
},
],
});
const lines = [
createAssistantEntry({
uuid: 'a-start',
timestamp: '2026-04-12T15:36:00.000Z',
requestId: 'req-start',
content: [
{
type: 'tool_use',
id: 'call-task-start',
name: 'mcp__agent-teams__task_start',
input: {
teamName: TEAM_NAME,
taskId: TASK_ID,
},
},
],
}),
createUserEntry({
uuid: 'u-start',
timestamp: '2026-04-12T15:36:00.120Z',
sourceToolAssistantUUID: 'a-start',
content: [
{
type: 'tool_result',
tool_use_id: 'call-task-start',
content: 'ok',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'idle',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
canonicalToolName: 'task_start',
},
],
toolUseResult: {
toolUseId: 'call-task-start',
content: '{"id":"c414cd52"}',
},
}),
createAssistantEntry({
uuid: 'a-bash',
timestamp: '2026-04-12T15:36:14.000Z',
requestId: 'req-bash',
content: [
{
type: 'tool_use',
id: 'call-bash',
name: 'Bash',
input: {
command: 'pnpm test',
},
},
],
}),
createUserEntry({
uuid: 'u-bash',
timestamp: '2026-04-12T15:36:14.300Z',
sourceToolAssistantUUID: 'a-bash',
content: [
{
type: 'tool_result',
tool_use_id: 'call-bash',
content: 'tests ok',
},
],
toolUseResult: {
toolUseId: 'call-bash',
content: 'tests ok',
},
}),
createAssistantEntry({
uuid: 'a-complete',
timestamp: '2026-04-12T15:36:30.000Z',
requestId: 'req-complete',
content: [
{
type: 'tool_use',
id: 'call-complete',
name: 'mcp__agent-teams__task_complete',
input: {
teamName: TEAM_NAME,
taskId: TASK_ID,
},
},
],
}),
createUserEntry({
uuid: 'u-complete',
timestamp: '2026-04-12T15:36:30.150Z',
sourceToolAssistantUUID: 'a-complete',
content: [
{
type: 'tool_result',
tool_use_id: 'call-complete',
content: 'ok',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-complete',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'same_task',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-complete',
canonicalToolName: 'task_complete',
},
],
toolUseResult: {
toolUseId: 'call-complete',
content: '{"id":"c414cd52"}',
},
}),
];
await writeFile(
transcriptPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8',
);
const recordSource = {
getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task),
};
const taskReader = {
getTasks: async () => [task],
getDeletedTasks: async () => [] as TeamTask[],
};
const transcriptSourceLocator = {
getContext: async () =>
({
transcriptFiles: [transcriptPath],
config: {
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
}) as never,
};
const service = new BoardTaskLogStreamService(
recordSource as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
taskReader as never,
transcriptSourceLocator as never,
);
const response = await service.getTaskLogStream(TEAM_NAME, task.id);
const rawMessages = flattenRawMessages(response);
const toolNames = rawMessages.flatMap((message) =>
message.toolCalls.map((toolCall) => toolCall.name),
);
expect(response.participants.map((participant) => participant.label)).toEqual(['tom']);
expect(response.defaultFilter).toBe('member:tom');
expect(toolNames).toContain('Bash');
expect(toolNames).toContain('mcp__agent-teams__task_complete');
});
it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8');
await writeFile(transcriptPath, fixtureText, 'utf8');
const task = createTask({
owner: 'tom',
workIntervals: [
{
startedAt: '2026-04-12T15:36:00.000Z',
completedAt: '2026-04-12T15:40:00.000Z',
},
],
});
const recordSource = {
getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task),
};
const taskReader = {
getTasks: async () => [task],
getDeletedTasks: async () => [] as TeamTask[],
};
const transcriptSourceLocator = {
getContext: async () =>
({
transcriptFiles: [transcriptPath],
config: {
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
}) as never,
};
const service = new BoardTaskLogStreamService(
recordSource as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
taskReader as never,
transcriptSourceLocator as never,
);
const response = await service.getTaskLogStream(TEAM_NAME, task.id);
const rawMessages = flattenRawMessages(response);
const bashCommands = rawMessages.flatMap((message) =>
message.toolCalls
.filter((toolCall) => toolCall.name === 'Bash')
.map((toolCall) => String(toolCall.input.command ?? '')),
);
expect(response.participants.map((participant) => participant.label)).toEqual(['tom']);
expect(response.defaultFilter).toBe('member:tom');
expect(bashCommands).toContain('pnpm test --filter signal-ops');
expect(bashCommands).not.toContain('echo alien');
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
});
});

View file

@ -215,6 +215,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
expect(prompt).toContain('Do NOT start implementation in this turn.');
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.');
expect(prompt).toContain(
'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
);
expect(prompt).toContain(
'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request'
);
@ -473,7 +476,10 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
const prompt = extractPromptFromBootstrapFile();
expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.');
expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.');
expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.');
expect(prompt).toContain('Use this turn only to refresh context and review the current board snapshot.');
expect(prompt).toContain(
'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
);
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);

View file

@ -233,5 +233,48 @@ describe('jsonl', () => {
expect(parsed?.uuid).toBe('bom-1');
});
it('preserves real transcript metadata needed by task-log fallback selection', () => {
const parsed = parseJsonlLine(
JSON.stringify({
parentUuid: 'assistant-1',
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-real-1',
version: '1.0.0',
gitBranch: 'main',
type: 'user',
uuid: 'user-real-1',
timestamp: '2026-04-12T15:36:14.250Z',
agentName: 'tom',
isMeta: true,
sourceToolAssistantUUID: 'assistant-1',
sourceToolUseID: 'call-bash-real',
toolUseResult: {
toolUseId: 'call-bash-real',
stdout: 'tests ok',
exitCode: 0,
},
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call-bash-real',
content: 'tests ok',
},
],
},
}),
);
expect(parsed?.sessionId).toBe('session-real-1');
expect(parsed?.agentName).toBe('tom');
expect(parsed?.isMeta).toBe(true);
expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1');
expect(parsed?.sourceToolUseID).toBe('call-bash-real');
expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real');
});
});
});

View file

@ -0,0 +1,278 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GlobalTask } from '../../../../src/shared/types';
interface StoreState {
globalTasks: GlobalTask[];
globalTasksLoading: boolean;
globalTasksInitialized: boolean;
fetchAllTasks: ReturnType<typeof vi.fn>;
softDeleteTask: ReturnType<typeof vi.fn>;
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
viewMode: 'flat' | 'grouped';
repositoryGroups: {
id: string;
name: string;
totalSessions: number;
worktrees: { path: string }[];
}[];
teams: { teamName: string; displayName: string }[];
}
const storeState = {} as StoreState;
const toggleCollapsedGroup = vi.fn();
const taskLocalState = {
isPinned: vi.fn(() => false),
isArchived: vi.fn(() => false),
getRenamedSubject: vi.fn(() => undefined),
togglePin: vi.fn(),
toggleArchive: vi.fn(),
renameTask: vi.fn(),
};
vi.mock('../../../../src/renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('zustand/react/shallow', () => ({
useShallow: <T,>(selector: T) => selector,
}));
vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({
confirm: vi.fn(() => Promise.resolve(true)),
}));
vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({
useCollapsedGroups: () => ({
isCollapsed: () => false,
toggle: toggleCollapsedGroup,
}),
}));
vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({
useTaskLocalState: () => taskLocalState,
}));
vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({
AnimatedHeightReveal: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
TaskContextMenu: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
SidebarTaskItem: ({ task }: { task: GlobalTask }) =>
React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject),
}));
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({
TaskFiltersPopover: () => null,
}));
vi.mock('../../../../src/renderer/components/ui/popover', () => ({
Popover: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
PopoverTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
PopoverContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
Archive: Icon,
ArrowUpDown: Icon,
Check: Icon,
ChevronDown: Icon,
ChevronRight: Icon,
Folder: Icon,
ListTodo: Icon,
Pin: Icon,
Search: Icon,
X: Icon,
};
});
import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function findButton(host: HTMLElement, label: string): HTMLButtonElement | null {
return Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === label
) ?? null;
}
function visibleSubjects(host: HTMLElement): string[] {
return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map(
(node) => node.textContent ?? ''
);
}
function makeTask(index: number, overrides: Partial<GlobalTask> = {}): GlobalTask {
const timestamp = String(60 - index).padStart(2, '0');
return {
id: `task-${index}`,
displayId: `task${index}`,
teamName: 'alpha-team',
teamDisplayName: 'Alpha Team',
subject: `Task ${index}`,
description: '',
status: 'in_progress',
owner: 'alice',
createdAt: `2026-04-18T10:${timestamp}:00.000Z`,
updatedAt: `2026-04-18T10:${timestamp}:00.000Z`,
reviewState: 'none',
reviewNotes: [],
blockedBy: [],
blocks: [],
comments: [],
attachments: [],
workIntervals: [],
kanbanColumnId: null,
projectPath: '/workspace/hookplex',
...overrides,
} as GlobalTask;
}
describe('GlobalTaskList project grouping', () => {
beforeEach(() => {
storeState.globalTasks = [];
storeState.globalTasksLoading = false;
storeState.globalTasksInitialized = true;
storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined));
storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined));
storeState.projects = [];
storeState.viewMode = 'flat';
storeState.repositoryGroups = [];
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
toggleCollapsedGroup.mockReset();
taskLocalState.isPinned.mockClear();
taskLocalState.isArchived.mockClear();
taskLocalState.getRenamedSubject.mockClear();
taskLocalState.togglePin.mockClear();
taskLocalState.toggleArchive.mockClear();
taskLocalState.renameTask.mockClear();
localStorage.clear();
localStorage.setItem('sidebarTasksGrouping', 'project');
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('shows five tasks first, then expands and collapses with Show more and Show less', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']);
expect(findButton(host, 'Show more')).not.toBeNull();
expect(findButton(host, 'Show less')).toBeNull();
await act(async () => {
findButton(host, 'Show more')?.click();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5', 'Task 6']);
expect(findButton(host, 'Show less')).not.toBeNull();
await act(async () => {
findButton(host, 'Show less')?.click();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']);
expect(findButton(host, 'Show less')).toBeNull();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
await act(async () => {
findButton(host, 'Show more')?.click();
await flushMicrotasks();
});
expect(visibleSubjects(host)).toHaveLength(10);
expect(findButton(host, 'Show less')).not.toBeNull();
storeState.globalTasks = [
makeTask(0, {
id: 'task-new',
displayId: 'task-new',
subject: 'Task 0',
createdAt: '2026-04-18T11:00:00.000Z',
updatedAt: '2026-04-18T11:00:00.000Z',
}),
...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)),
];
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(visibleSubjects(host)).toHaveLength(10);
expect(visibleSubjects(host)).toEqual([
'Task 0',
'Task 1',
'Task 2',
'Task 3',
'Task 4',
'Task 5',
'Task 6',
'Task 7',
'Task 8',
'Task 9',
]);
expect(visibleSubjects(host)).not.toContain('Task 10');
expect(findButton(host, 'Show more')).not.toBeNull();
expect(findButton(host, 'Show less')).not.toBeNull();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -0,0 +1,75 @@
import {
PROJECT_GROUP_PAGE_SIZE,
canProjectGroupShowLess,
canProjectGroupShowMore,
getNextProjectGroupVisibleCount,
getPreviousProjectGroupVisibleCount,
getProjectGroupVisibleCount,
syncProjectGroupVisibleCountByKey,
} from '../../../../src/renderer/components/sidebar/projectGroupPagination';
import { describe, expect, it } from 'vitest';
describe('projectGroupPagination', () => {
it('defaults to the first page and respects small groups', () => {
expect(getProjectGroupVisibleCount(undefined, 0)).toBe(0);
expect(getProjectGroupVisibleCount(undefined, 3)).toBe(3);
expect(getProjectGroupVisibleCount(undefined, 12)).toBe(PROJECT_GROUP_PAGE_SIZE);
});
it('expands in steps of five and clamps to the group size', () => {
let visibleCount = getProjectGroupVisibleCount(undefined, 17);
expect(visibleCount).toBe(5);
visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17);
expect(visibleCount).toBe(10);
visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17);
expect(visibleCount).toBe(15);
visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17);
expect(visibleCount).toBe(17);
expect(canProjectGroupShowMore(visibleCount, 17)).toBe(false);
});
it('collapses in steps of five and never goes below the first page', () => {
expect(getPreviousProjectGroupVisibleCount(15, 17)).toBe(10);
expect(getPreviousProjectGroupVisibleCount(10, 17)).toBe(5);
expect(getPreviousProjectGroupVisibleCount(5, 17)).toBe(5);
expect(canProjectGroupShowLess(5, 17)).toBe(false);
expect(canProjectGroupShowLess(10, 17)).toBe(true);
});
it('clamps existing counts when the group shrinks and removes missing groups', () => {
const previousVisibleCounts = {
active: 15,
compact: 7,
removed: 10,
};
expect(
syncProjectGroupVisibleCountByKey(previousVisibleCounts, [
{ projectKey: 'active', taskCount: 9 },
{ projectKey: 'compact', taskCount: 4 },
])
).toEqual({
active: 9,
compact: 4,
});
});
it('returns the same object when nothing changes', () => {
const previousVisibleCounts = {
active: 10,
compact: 4,
};
const nextVisibleCounts = syncProjectGroupVisibleCountByKey(previousVisibleCounts, [
{ projectKey: 'active', taskCount: 12 },
{ projectKey: 'compact', taskCount: 4 },
]);
expect(nextVisibleCounts).toBe(previousVisibleCounts);
});
});