Merge branch 'dev' into spike/team-snapshot-split-plan
# Conflicts: # src/renderer/components/sidebar/GlobalTaskList.tsx # src/renderer/components/team/members/MemberMessagesTab.tsx # src/renderer/components/team/messages/MessagesPanel.tsx # test/main/services/team/BoardTaskLogStreamIntegration.test.ts
This commit is contained in:
commit
ce60831758
7 changed files with 3084 additions and 21 deletions
2916
docs/extensions/plugin-kit-ai-integration-plan.md
Normal file
2916
docs/extensions/plugin-kit-ai-integration-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,7 @@ import {
|
||||||
getNonEmptyTaskCategories,
|
getNonEmptyTaskCategories,
|
||||||
groupTasksByDate,
|
groupTasksByDate,
|
||||||
groupTasksByProject,
|
groupTasksByProject,
|
||||||
|
NO_PROJECT_KEY,
|
||||||
sortTasksByFreshness,
|
sortTasksByFreshness,
|
||||||
} from '@renderer/utils/taskGrouping';
|
} from '@renderer/utils/taskGrouping';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -430,8 +431,18 @@ export const GlobalTaskList = ({
|
||||||
? categories.length > 0
|
? categories.length > 0
|
||||||
: projectGroups.some((g) => g.tasks.length > 0));
|
: projectGroups.some((g) => g.tasks.length > 0));
|
||||||
|
|
||||||
|
const noProjectGroupColor = useMemo(
|
||||||
|
() => ({
|
||||||
|
border: 'var(--color-border)',
|
||||||
|
glow: 'transparent',
|
||||||
|
icon: 'var(--color-text-muted)',
|
||||||
|
text: 'var(--color-text-secondary)',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full min-w-0 flex-col">
|
<div className="flex size-full min-w-0 flex-col overflow-x-hidden">
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
|
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
|
||||||
|
|
@ -597,7 +608,7 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
{globalTasksLoading && !globalTasksInitialized && (
|
{globalTasksLoading && !globalTasksInitialized && (
|
||||||
<div className="space-y-2 p-3">
|
<div className="space-y-2 p-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
|
|
@ -644,7 +655,10 @@ export const GlobalTaskList = ({
|
||||||
projectGroups.map((group) => {
|
projectGroups.map((group) => {
|
||||||
if (group.tasks.length === 0) return null;
|
if (group.tasks.length === 0) return null;
|
||||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||||
const groupColor = projectColor(group.projectLabel);
|
const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY;
|
||||||
|
const groupColor = isNoProjectGroup
|
||||||
|
? noProjectGroupColor
|
||||||
|
: projectColor(group.projectLabel);
|
||||||
const visibleCount = getProjectGroupVisibleCount(
|
const visibleCount = getProjectGroupVisibleCount(
|
||||||
projectVisibleCountByKey[group.projectKey],
|
projectVisibleCountByKey[group.projectKey],
|
||||||
group.tasks.length
|
group.tasks.length
|
||||||
|
|
@ -661,7 +675,9 @@ export const GlobalTaskList = ({
|
||||||
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"
|
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={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-sidebar)',
|
backgroundColor: 'var(--color-surface-sidebar)',
|
||||||
backgroundImage: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
|
backgroundImage: isNoProjectGroup
|
||||||
|
? undefined
|
||||||
|
: `linear-gradient(90deg, ${groupColor.glow} 0%, transparent 80%)`,
|
||||||
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
|
boxShadow: `inset 2px 0 0 ${groupColor.border}, inset 0 -1px 0 var(--color-border)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -676,7 +692,7 @@ export const GlobalTaskList = ({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="truncate text-[13px] font-bold leading-none"
|
className="truncate text-[11px] font-bold leading-none"
|
||||||
style={{ color: groupColor.icon }}
|
style={{ color: groupColor.icon }}
|
||||||
>
|
>
|
||||||
{group.projectLabel}
|
{group.projectLabel}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export const MemberMessagesTab = ({
|
||||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||||
|
|
||||||
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||||
|
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||||
const hasMore = messagesState?.hasMore ?? false;
|
const hasMore = messagesState?.hasMore ?? false;
|
||||||
|
|
||||||
const activityEntries = useMemo(() => {
|
const activityEntries = useMemo(() => {
|
||||||
|
|
@ -129,7 +130,8 @@ export const MemberMessagesTab = ({
|
||||||
[onTaskClick, taskMap, tasks]
|
[onTaskClick, taskMap, tasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyStateText = loading
|
const initialPageLoading = loading && activityEntries.length === 0;
|
||||||
|
const emptyStateText = initialPageLoading
|
||||||
? 'Loading activity...'
|
? 'Loading activity...'
|
||||||
: activityFilter === 'comments'
|
: activityFilter === 'comments'
|
||||||
? 'No comments for this member'
|
? 'No comments for this member'
|
||||||
|
|
@ -221,10 +223,11 @@ export const MemberMessagesTab = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
disabled={loading}
|
aria-busy={loadingOlderMessages}
|
||||||
|
disabled={loadingOlderMessages}
|
||||||
onClick={() => void loadOlderMessages()}
|
onClick={() => void loadOlderMessages()}
|
||||||
>
|
>
|
||||||
{loading ? 'Loading...' : 'Load older messages'}
|
Load older messages
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
||||||
const messagesLoading =
|
const messagesLoading =
|
||||||
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||||
|
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||||
const hasMore = messagesState?.hasMore ?? false;
|
const hasMore = messagesState?.hasMore ?? false;
|
||||||
const effectiveMessages = messages;
|
const effectiveMessages = messages;
|
||||||
|
|
||||||
|
|
@ -246,7 +247,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
for (const [element, setHeight] of observedEntries) {
|
for (const [element, setHeight] of observedEntries) {
|
||||||
if (!element) continue;
|
if (!element) continue;
|
||||||
|
|
||||||
const updateHeight = () => {
|
const updateHeight = (): void => {
|
||||||
const nextHeight = Math.ceil(element.getBoundingClientRect().height);
|
const nextHeight = Math.ceil(element.getBoundingClientRect().height);
|
||||||
if (nextHeight > 0) {
|
if (nextHeight > 0) {
|
||||||
setHeight(nextHeight);
|
setHeight(nextHeight);
|
||||||
|
|
@ -591,10 +592,11 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-text-muted"
|
className="text-xs text-text-muted"
|
||||||
disabled={messagesLoading}
|
aria-busy={loadingOlderMessages}
|
||||||
|
disabled={loadingOlderMessages}
|
||||||
onClick={() => void loadOlderMessages()}
|
onClick={() => void loadOlderMessages()}
|
||||||
>
|
>
|
||||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
Load older messages
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -776,10 +778,11 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-text-muted"
|
className="text-xs text-text-muted"
|
||||||
disabled={messagesLoading}
|
aria-busy={loadingOlderMessages}
|
||||||
|
disabled={loadingOlderMessages}
|
||||||
onClick={() => void loadOlderMessages()}
|
onClick={() => void loadOlderMessages()}
|
||||||
>
|
>
|
||||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
Load older messages
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1062,10 +1065,11 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-text-muted"
|
className="text-xs text-text-muted"
|
||||||
disabled={messagesLoading}
|
aria-busy={loadingOlderMessages}
|
||||||
|
disabled={loadingOlderMessages}
|
||||||
onClick={() => void loadOlderMessages()}
|
onClick={() => void loadOlderMessages()}
|
||||||
>
|
>
|
||||||
{messagesLoading ? 'Loading...' : 'Load older messages'}
|
Load older messages
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ export function getNonEmptyTaskCategories(groups: DateGroupedTasks): DateCategor
|
||||||
return DATE_CATEGORY_ORDER.filter((cat) => groups[cat].length > 0);
|
return DATE_CATEGORY_ORDER.filter((cat) => groups[cat].length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_PROJECT_KEY = '__no_project__';
|
export const NO_PROJECT_KEY = '__no_project__';
|
||||||
const NO_PROJECT_LABEL = 'Without project';
|
export const NO_PROJECT_LABEL = 'No project';
|
||||||
|
|
||||||
function trimTrailingPathSep(p: string): string {
|
function trimTrailingPathSep(p: string): string {
|
||||||
let s = p;
|
let s = p;
|
||||||
|
|
|
||||||
|
|
@ -643,4 +643,51 @@ describe('BoardTaskLogStreamService integration', () => {
|
||||||
expect(bashCommands).not.toContain('echo alien');
|
expect(bashCommands).not.toContain('echo alien');
|
||||||
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
|
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
|
||||||
});
|
});
|
||||||
|
it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-'));
|
||||||
|
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',
|
||||||
|
createdAt: '2026-04-12T15:35:50.000Z',
|
||||||
|
updatedAt: '2026-04-12T15:37:00.000Z',
|
||||||
|
workIntervals: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(response.participants.map((participant) => participant.label)).toEqual(['tom']);
|
||||||
|
expect(rawMessages.some((message) => message.uuid === 'a-bash-real')).toBe(true);
|
||||||
|
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
|
|
@ -14,6 +14,10 @@ import type { TeamTask } from '../../../../../src/shared/types';
|
||||||
|
|
||||||
const TEAM_NAME = 'beacon-desk-2';
|
const TEAM_NAME = 'beacon-desk-2';
|
||||||
const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7';
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
const apiState = {
|
const apiState = {
|
||||||
getTaskLogStream: vi.fn(),
|
getTaskLogStream: vi.fn(),
|
||||||
|
|
@ -105,8 +109,7 @@ function createUserEntry(args: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildStreamResponse(transcriptPath: string) {
|
async function buildStreamResponse(transcriptPath: string, task: TeamTask = createTask()) {
|
||||||
const task = createTask();
|
|
||||||
const transcriptReader = new BoardTaskActivityTranscriptReader();
|
const transcriptReader = new BoardTaskActivityTranscriptReader();
|
||||||
const recordBuilder = new BoardTaskActivityRecordBuilder();
|
const recordBuilder = new BoardTaskActivityRecordBuilder();
|
||||||
const messages = await transcriptReader.readFiles([transcriptPath]);
|
const messages = await transcriptReader.readFiles([transcriptPath]);
|
||||||
|
|
@ -119,8 +122,29 @@ async function buildStreamResponse(transcriptPath: string) {
|
||||||
messages,
|
messages,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
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);
|
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,
|
||||||
|
);
|
||||||
return service.getTaskLogStream(TEAM_NAME, task.id);
|
return service.getTaskLogStream(TEAM_NAME, task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -547,4 +571,57 @@ describe('TaskLogStreamSection integration', () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders fallback worker logs from a real-format transcript fixture and hides unrelated participant logs', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-render-real-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const transcriptPath = path.join(dir, 'session.jsonl');
|
||||||
|
const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8');
|
||||||
|
await writeFile(transcriptPath, fixtureText, 'utf8');
|
||||||
|
|
||||||
|
apiState.getTaskLogStream.mockResolvedValueOnce(
|
||||||
|
await buildStreamResponse(
|
||||||
|
transcriptPath,
|
||||||
|
createTask({
|
||||||
|
owner: 'tom',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-04-12T15:36:00.000Z',
|
||||||
|
completedAt: '2026-04-12T15:40:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
TooltipProvider,
|
||||||
|
null,
|
||||||
|
React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = host.textContent ?? '';
|
||||||
|
expect(text).toContain('Task Log Stream');
|
||||||
|
expect(text).toContain('Bash');
|
||||||
|
expect(text).toContain('Run targeted tests');
|
||||||
|
expect(text).not.toContain('echo alien');
|
||||||
|
expect(text).not.toContain('alice');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue