feat(graph-controls): integrate tooltip support, refine button styles, and enhance task creation functionality

This commit is contained in:
777genius 2026-04-13 20:51:45 +03:00
parent ce0eb75429
commit a0c8db4771
16 changed files with 989 additions and 239 deletions

View file

@ -4,6 +4,7 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
import {
Columns3,
Expand,
@ -45,6 +46,9 @@ export interface GraphControlsProps {
isAlive?: boolean;
}
const TOPBAR_BUTTON_SIZE = 25;
const TOPBAR_ICON_SIZE = 10;
export function GraphControls({
filters,
onFiltersChange,
@ -56,9 +60,7 @@ export function GraphControls({
onRequestFullscreen,
onOpenTeamPage,
onCreateTask,
teamName,
teamColor,
isAlive,
}: GraphControlsProps): React.JSX.Element {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
@ -97,40 +99,44 @@ export function GraphControls({
return (
<>
<div className="absolute left-20 top-3 z-20 flex items-center gap-1.5 pointer-events-none">
<div className="absolute left-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
{onOpenTeamPage ? (
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
}}
>
<ToolbarButton onClick={onOpenTeamPage} icon={<Users size={9} />} mini title="Team page" />
{onCreateTask ? (
<ToolbarButton onClick={onCreateTask} icon={<Plus size={9} />} mini title="Create task" />
) : null}
<ToolbarButton
onClick={onOpenTeamPage}
icon={<Users size={TOPBAR_ICON_SIZE} />}
toolbar
title="Open team page"
/>
</div>
) : null}
{onCreateTask ? (
<div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
}}
>
<ToolbarButton
onClick={onCreateTask}
icon={<Plus size={TOPBAR_ICON_SIZE} />}
toolbar
title="Create task"
/>
</div>
) : null}
<div
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
}}
>
{isAlive && (
<div className="size-2 rounded-full animate-pulse" style={{ background: nameColor }} />
)}
<span className="text-xs font-mono font-semibold" style={{ color: nameColor }}>
{teamName}
</span>
</div>
</div>
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
<div
className="pointer-events-auto flex items-center rounded-md px-px py-px backdrop-blur-sm"
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
@ -138,14 +144,15 @@ export function GraphControls({
>
<ToolbarButton
onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={9} /> : <Pause size={9} />}
mini
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
toolbar
title={filters.paused ? 'Resume animation' : 'Pause animation'}
/>
</div>
<div ref={settingsRef} className="relative pointer-events-auto">
<div
className="flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
@ -153,9 +160,10 @@ export function GraphControls({
>
<ToolbarButton
onClick={() => setIsSettingsOpen((value) => !value)}
icon={<Settings2 size={9} />}
icon={<Settings2 size={TOPBAR_ICON_SIZE} />}
active={isSettingsOpen}
mini
toolbar
title="Graph settings"
/>
</div>
@ -193,23 +201,36 @@ export function GraphControls({
</div>
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
{onRequestPinAsTab && (
<ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={9} />} mini />
<ToolbarButton
onClick={onRequestPinAsTab}
icon={<Pin size={TOPBAR_ICON_SIZE} />}
toolbar
title="Pin as tab"
/>
)}
{onRequestFullscreen && (
<ToolbarButton
onClick={onRequestFullscreen}
icon={<Expand size={9} />}
mini
icon={<Expand size={TOPBAR_ICON_SIZE} />}
toolbar
title="Fullscreen"
/>
)}
{onRequestClose && (
<ToolbarButton
onClick={onRequestClose}
icon={<X size={TOPBAR_ICON_SIZE} />}
toolbar
title="Close graph"
/>
)}
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={9} />} mini />}
</div>
</div>
@ -239,6 +260,7 @@ function ToolbarButton({
active = false,
compact = false,
mini = false,
toolbar = false,
title,
}: {
onClick?: () => void;
@ -247,18 +269,40 @@ function ToolbarButton({
active?: boolean;
compact?: boolean;
mini?: boolean;
toolbar?: boolean;
title?: string;
}): React.JSX.Element {
return (
const button = (
<button
onClick={onClick}
title={title}
aria-label={title}
style={
toolbar
? {
width: TOPBAR_BUTTON_SIZE,
height: TOPBAR_BUTTON_SIZE,
minWidth: TOPBAR_BUTTON_SIZE,
minHeight: TOPBAR_BUTTON_SIZE,
padding: 0,
}
: mini
? {
width: 16,
height: 16,
minWidth: 16,
minHeight: 16,
padding: 0,
}
: undefined
}
className={`flex items-center rounded-md font-mono transition-colors cursor-pointer ${
mini
? 'size-5 justify-center p-0 text-[0]'
: compact
? 'gap-0.5 px-1 py-0.5 text-[9px]'
: 'gap-1 px-2 py-1 text-[11px]'
toolbar
? 'justify-center text-[0]'
: mini
? 'justify-center text-[0]'
: compact
? 'gap-0.5 px-1 py-0.5 text-[9px]'
: 'gap-1 px-2 py-1 text-[11px]'
} ${
active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
@ -269,6 +313,26 @@ function ToolbarButton({
{label && <span>{label}</span>}
</button>
);
if (!title) {
return button;
}
return (
<Tooltip.Root delayDuration={180}>
<Tooltip.Trigger asChild>{button}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
sideOffset={8}
className="z-[100] rounded-md border border-[rgba(100,200,255,0.14)] bg-[rgba(8,12,24,0.96)] px-2 py-1 text-[11px] font-mono text-[#dff6ff] shadow-xl backdrop-blur-sm"
>
{title}
<Tooltip.Arrow className="fill-[rgba(8,12,24,0.96)]" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}
function ToolbarToggle({

View file

@ -549,31 +549,6 @@ function sanitizeDetailMessages(
);
}
function hasMeaningfulText(value: string): boolean {
const trimmed = value.trim();
return trimmed.length > 0 && !looksLikeJsonPayload(trimmed);
}
function hasUsefulLinkedToolMessages(messages: ParsedMessage[]): boolean {
return messages.some((message) => {
if (hasMeaningfulToolUseResult(message)) {
return true;
}
if (typeof message.content === 'string') {
return hasMeaningfulText(message.content);
}
return message.content.some((block) => {
if (block.type !== 'text') {
return false;
}
return hasMeaningfulText(block.text);
});
});
}
function hasUsefulLinkedToolChunks(chunks: EnhancedChunk[]): boolean {
return chunks.some((chunk) => isEnhancedAIChunk(chunk) && chunk.toolExecutions.length > 0);
}
@ -621,11 +596,7 @@ export class BoardTaskActivityDetailService {
record.source.toolUseId
);
const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages);
if (
chunks.length > 0 &&
hasUsefulLinkedToolMessages(filteredMessages) &&
hasUsefulLinkedToolChunks(chunks)
) {
if (chunks.length > 0 && hasUsefulLinkedToolChunks(chunks)) {
detail.logDetail = {
id: detailCandidate.id,
chunks,

View file

@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react';
import type { TmuxStatus } from '@shared/types';
import type { TmuxPlatform, TmuxStatus } from '@shared/types';
const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing';
const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README';
@ -16,6 +16,18 @@ interface SourceLink {
url: string;
}
interface PlatformInstallGuideStep {
kind: 'text' | 'code';
value: string;
}
interface PlatformInstallGuide {
platform: Exclude<TmuxPlatform, 'unknown'>;
title: string;
steps: PlatformInstallGuideStep[];
sources: SourceLink[];
}
type BannerState =
| { loading: true; status: null; error: null }
| { loading: false; status: TmuxStatus; error: null }
@ -23,6 +35,56 @@ type BannerState =
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
const PLATFORM_INSTALL_GUIDES: readonly PlatformInstallGuide[] = [
{
platform: 'darwin',
title: 'macOS',
steps: [
{ kind: 'text', value: 'Recommended: Homebrew' },
{ kind: 'code', value: 'brew install tmux' },
{ kind: 'text', value: 'Alternative: MacPorts' },
{ kind: 'code', value: 'sudo port install tmux' },
],
sources: [
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
{ label: 'Homebrew', url: HOMEBREW_TMUX_URL },
{ label: 'MacPorts', url: MACPORTS_TMUX_URL },
],
},
{
platform: 'linux',
title: 'Linux',
steps: [
{ kind: 'text', value: 'Use your distro package manager:' },
{ kind: 'code', value: 'sudo apt install tmux' },
{ kind: 'code', value: 'sudo dnf install tmux' },
{ kind: 'code', value: 'sudo yum install tmux' },
{ kind: 'code', value: 'sudo zypper install tmux' },
{ kind: 'code', value: 'sudo pacman -S tmux' },
],
sources: [{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }],
},
{
platform: 'win32',
title: 'Windows',
steps: [
{
kind: 'text',
value: 'The tmux docs do not provide an official native Windows install command.',
},
{ kind: 'text', value: '1. Install WSL' },
{ kind: 'code', value: 'wsl --install' },
{ kind: 'text', value: '2. Inside Ubuntu or another distro' },
{ kind: 'code', value: 'sudo apt install tmux' },
],
sources: [
{ label: 'tmux README', url: TMUX_README_URL },
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
{ label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL },
],
},
] as const;
const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => {
return (
<div className="pt-1">
@ -50,91 +112,65 @@ const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => {
);
};
const PlatformInstallMatrix = (): React.JSX.Element => {
function getPlatformLabel(platform: TmuxPlatform): string {
if (platform === 'darwin') return 'macOS';
if (platform === 'linux') return 'Linux';
if (platform === 'win32') return 'Windows';
return 'your OS';
}
const PlatformInstallCard = ({ guide }: { guide: PlatformInstallGuide }): React.JSX.Element => {
return (
<div className="mt-3 grid gap-2 lg:grid-cols-3">
<div
className="rounded-md border px-3 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
macOS
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<div>Recommended: Homebrew</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">brew install tmux</code>
<div>Alternative: MacPorts</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo port install tmux
</code>
<SourceLinks
links={[
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
{ label: 'Homebrew', url: HOMEBREW_TMUX_URL },
{ label: 'MacPorts', url: MACPORTS_TMUX_URL },
]}
/>
</div>
<div
className="rounded-md border px-3 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
{guide.title}
</div>
<div
className="rounded-md border px-3 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
Linux
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<div>Use your distro package manager:</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo apt install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo dnf install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo yum install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo zypper install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">sudo pacman -S tmux</code>
<SourceLinks links={[{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }]} />
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{guide.steps.map((step) =>
step.kind === 'code' ? (
<code
key={`${guide.platform}-${step.value}`}
className="block rounded bg-black/20 px-2 py-1 font-mono"
>
{step.value}
</code>
) : (
<div key={`${guide.platform}-${step.value}`}>{step.value}</div>
)
)}
<SourceLinks links={guide.sources} />
</div>
</div>
);
};
<div
className="rounded-md border px-3 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
Windows
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<p>The tmux docs do not provide an official native Windows install command.</p>
<div>1. Install WSL</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">wsl --install</code>
<div>2. Inside Ubuntu or another distro</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo apt install tmux
</code>
<SourceLinks
links={[
{ label: 'tmux README', url: TMUX_README_URL },
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
{ label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL },
]}
/>
const PlatformInstallMatrix = ({ platform }: { platform: TmuxPlatform }): React.JSX.Element => {
const guides =
platform === 'unknown'
? PLATFORM_INSTALL_GUIDES
: PLATFORM_INSTALL_GUIDES.filter((guide) => guide.platform === platform);
const singleGuide = guides.length === 1;
return (
<div className="mt-3">
{singleGuide && (
<div
className="mb-2 text-[10px] uppercase tracking-wide"
style={{ color: 'var(--color-text-muted)' }}
>
Detected OS: {getPlatformLabel(platform)}
</div>
)}
<div className={singleGuide ? 'max-w-xl' : 'grid gap-2 lg:grid-cols-3'}>
{guides.map((guide) => (
<PlatformInstallCard key={guide.platform} guide={guide} />
))}
</div>
</div>
);
@ -305,7 +341,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
</div>
</div>
<PlatformInstallMatrix />
<PlatformInstallMatrix platform={state.status.platform} />
</div>
);
};

View file

@ -20,6 +20,7 @@ import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useStore } from '@renderer/store';
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -77,7 +78,9 @@ export const CreateTaskDialog = ({
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const projectPath = useStore(
(s) => selectTeamDataForName(s, teamName)?.config.projectPath ?? null
);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const [subject, setSubject] = useState(defaultSubject);
const descriptionDraft = useDraftPersistence({

View file

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
interface TaskActivityLinkedToolCardProps {
linkedTool: LinkedToolItem;
}
export const TaskActivityLinkedToolCard = ({
linkedTool,
}: TaskActivityLinkedToolCardProps): React.JSX.Element => {
const items = useMemo<AIGroupDisplayItem[]>(
() => [{ type: 'tool', tool: linkedTool }],
[linkedTool]
);
const expandedItemIds = useMemo(() => new Set([`tool-${linkedTool.id}-0`]), [linkedTool.id]);
return (
<div className="pt-1">
<DisplayItemList
items={items}
onItemClick={() => {}}
expandedItemIds={expandedItemIds}
aiGroupId={`task-activity:${linkedTool.id}`}
order="chronological"
/>
</div>
);
};

View file

@ -1,7 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
@ -15,11 +14,14 @@ import {
} from '@shared/utils/boardTaskActivityPresentation';
import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { TaskActivityLinkedToolCard } from './TaskActivityLinkedToolCard';
import type {
BoardTaskActivityDetail,
BoardTaskActivityEntry,
BoardTaskActivityTaskRef,
} from '@shared/types';
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
interface TaskActivitySectionProps {
teamName: string;
@ -92,18 +94,27 @@ function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDeta
};
}
function hasRenderableLinkedTool(detail: BoardTaskActivityDetail): boolean {
function getFirstRenderableLinkedTool(detail: BoardTaskActivityDetail): LinkedToolItem | null {
if (!detail.logDetail || detail.logDetail.chunks.length === 0) {
return false;
return null;
}
const conversation = transformChunksToConversation(detail.logDetail.chunks, [], false);
return conversation.items.some((item) => {
for (const item of conversation.items) {
if (item.type !== 'ai') {
return false;
continue;
}
return enhanceAIGroup(item.group).displayItems.length > 0;
});
const linkedTool = enhanceAIGroup(item.group).displayItems.find(
(displayItem): displayItem is Extract<AIGroupDisplayItem, { type: 'tool' }> =>
displayItem.type === 'tool'
);
if (linkedTool) {
return linkedTool.tool;
}
}
return null;
}
function ActivityMetadata({
@ -153,7 +164,7 @@ function ActivityDetailPanel({
}): React.JSX.Element {
if (detailState.status === 'loading') {
return (
<div className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/25 flex items-center gap-2 rounded-md border px-3 py-3 text-xs text-[var(--color-text-muted)]">
<div className="border-[var(--color-border)]/20 bg-[var(--color-bg-elevated)]/18 flex items-center gap-2 rounded-md border px-3 py-3 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading activity details...
</div>
@ -171,7 +182,7 @@ function ActivityDetailPanel({
if (detailState.status === 'missing') {
return (
<div className="border-[var(--color-border-muted)]/50 bg-[var(--color-bg-elevated)]/25 rounded-md border px-3 py-3 text-xs text-[var(--color-text-muted)]">
<div className="border-[var(--color-border)]/20 bg-[var(--color-bg-elevated)]/18 rounded-md border px-3 py-3 text-xs text-[var(--color-text-muted)]">
Detailed transcript context is no longer available for this activity.
</div>
);
@ -182,20 +193,13 @@ function ActivityDetailPanel({
}
const { detail } = detailState;
const hasRenderableLog = hasRenderableLinkedTool(detail);
const linkedTool = getFirstRenderableLinkedTool(detail);
return (
<div className="border-[var(--color-border-muted)]/50 space-y-3 border-t pt-3">
<div className="border-[var(--color-border)]/18 space-y-3 border-t pt-3">
<ActivityMetadata detail={detail} />
{detail.logDetail && hasRenderableLog ? (
<div className="pt-1">
<MemberExecutionLog
chunks={detail.logDetail.chunks}
memberName={detail.actorLabel === 'lead session' ? undefined : detail.actorLabel}
/>
</div>
) : null}
{linkedTool ? <TaskActivityLinkedToolCard linkedTool={linkedTool} /> : null}
</div>
);
}
@ -218,10 +222,14 @@ const Row = ({
: 'text-[var(--color-text-muted)]';
return (
<div className="border-[var(--color-border-muted)]/60 bg-[var(--color-bg-elevated)]/40 rounded-md border">
<div
className={`bg-[var(--color-bg-elevated)]/20 rounded-md border shadow-sm shadow-black/10 transition-colors ${
expanded ? 'border-[var(--color-border-emphasis)]' : 'border-[var(--color-border-subtle)]'
}`}
>
<button
type="button"
className="hover:bg-[var(--color-bg-elevated)]/35 flex w-full items-start gap-3 px-3 py-2 text-left transition-colors"
className="hover:bg-[var(--color-bg-elevated)]/28 flex w-full items-start gap-3 px-3 py-2 text-left transition-colors"
onClick={onToggle}
>
<div className="pt-0.5 text-[var(--color-text-muted)]">

View file

@ -1,10 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
import { TaskActivitySection } from './TaskActivitySection';
import { TaskLogStreamSection } from './TaskLogStreamSection';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import type { TeamTaskWithKanban } from '@shared/types';
type TaskLogsTab = 'activity' | 'stream' | 'sessions';
interface TaskLogsPanelProps {
teamName: string;
task: TeamTaskWithKanban;
@ -28,28 +33,81 @@ export const TaskLogsPanel = ({
showLeadPreview = false,
onPreviewOnlineChange,
}: TaskLogsPanelProps): React.JSX.Element => {
const availableTabs = useMemo<TaskLogsTab[]>(() => {
const tabs: TaskLogsTab[] = [];
if (isBoardTaskExactLogsUiEnabled()) {
tabs.push('stream');
}
if (isBoardTaskActivityUiEnabled()) {
tabs.push('activity');
}
tabs.push('sessions');
return tabs;
}, []);
const defaultTab = availableTabs[0] ?? 'sessions';
const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab);
useEffect(() => {
setActiveTab(defaultTab);
}, [defaultTab, task.id]);
useEffect(() => {
if (!availableTabs.includes(activeTab)) {
setActiveTab(defaultTab);
}
}, [activeTab, availableTabs, defaultTab]);
return (
<div className="space-y-4">
{isBoardTaskActivityUiEnabled() ? (
<TaskActivitySection teamName={teamName} taskId={task.id} />
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as TaskLogsTab)}
className="space-y-3"
>
<TabsList className="bg-[var(--color-surface-raised)]/80 h-auto w-full justify-start gap-1 rounded-lg p-1">
{availableTabs.includes('stream') ? (
<TabsTrigger value="stream" className="gap-1.5">
Task Log Stream
</TabsTrigger>
) : null}
{availableTabs.includes('activity') ? (
<TabsTrigger value="activity" className="gap-1.5">
Task Activity
</TabsTrigger>
) : null}
<TabsTrigger value="sessions" className="gap-1.5">
Execution Sessions
</TabsTrigger>
</TabsList>
{availableTabs.includes('stream') ? (
<TabsContent value="stream" className="mt-0">
<TaskLogStreamSection teamName={teamName} taskId={task.id} />
</TabsContent>
) : null}
{isBoardTaskExactLogsUiEnabled() ? (
<TaskLogStreamSection teamName={teamName} taskId={task.id} />
{availableTabs.includes('activity') ? (
<TabsContent value="activity" className="mt-0">
<TaskActivitySection teamName={teamName} taskId={task.id} />
</TabsContent>
) : null}
<ExecutionSessionsSection
teamName={teamName}
taskId={task.id}
taskOwner={task.owner}
taskStatus={task.status}
taskWorkIntervals={task.workIntervals}
taskSince={taskSince}
isRefreshing={isExecutionRefreshing}
isPreviewOnline={isExecutionPreviewOnline}
onRefreshingChange={onRefreshingChange}
showSubagentPreview={showSubagentPreview}
showLeadPreview={showLeadPreview}
onPreviewOnlineChange={onPreviewOnlineChange}
/>
</div>
<TabsContent value="sessions" className="mt-0">
<ExecutionSessionsSection
teamName={teamName}
taskId={task.id}
taskOwner={task.owner}
taskStatus={task.status}
taskWorkIntervals={task.workIntervals}
taskSince={taskSince}
isRefreshing={isExecutionRefreshing}
isPreviewOnline={isExecutionPreviewOnline}
onRefreshingChange={onRefreshingChange}
showSubagentPreview={showSubagentPreview}
showLeadPreview={showLeadPreview}
onPreviewOnlineChange={onPreviewOnlineChange}
/>
</TabsContent>
</Tabs>
);
};

View file

@ -15,6 +15,7 @@ import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
import type {
@ -46,6 +47,7 @@ export const TeamGraphOverlay = ({
onOpenMemberProfile,
}: TeamGraphOverlayProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const leadNodeId = useMemo(
() => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null,
[graphData.nodes]
@ -75,8 +77,8 @@ export const TeamGraphOverlay = ({
onClose();
}, [onClose, teamName]);
const openCreateTask = useCallback(() => {
window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner: '' } }));
}, [teamName]);
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
@ -154,6 +156,7 @@ export const TeamGraphOverlay = ({
onSendMessage?.(name);
closePopover();
}}
onCreateTask={openCreateTaskDialog}
onOpenTaskDetail={(id) => {
onOpenTaskDetail?.(id);
closePopover();
@ -166,6 +169,7 @@ export const TeamGraphOverlay = ({
/>
)}
/>
{createTaskDialog}
</div>
);
};

View file

@ -15,6 +15,7 @@ import { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph';
import type {
@ -48,6 +49,7 @@ export const TeamGraphTab = ({
[graphData.nodes]
);
const [fullscreen, setFullscreen] = useState(false);
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
// Typed event dispatchers (DRY — used in both events + renderOverlay)
const dispatchOpenTask = useCallback(
@ -71,20 +73,12 @@ export const TeamGraphTab = ({
),
[teamName]
);
const dispatchCreateTask = useCallback(
(owner: string) =>
window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })),
[teamName]
);
const openTeamPage = useCallback(() => {
useStore.getState().openTeamTab(teamName);
}, [teamName]);
const openCreateTask = useCallback(() => {
useStore.getState().openTeamTab(teamName);
window.setTimeout(() => {
dispatchCreateTask('');
}, 0);
}, [dispatchCreateTask, teamName]);
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
// Task action dispatchers
const dispatchTaskAction = useCallback(
@ -195,7 +189,7 @@ export const TeamGraphTab = ({
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onCreateTask={dispatchCreateTask}
onCreateTask={openCreateTaskDialog}
onStartTask={dispatchStartTask}
onCompleteTask={dispatchCompleteTask}
onApproveTask={dispatchApproveTask}
@ -208,6 +202,7 @@ export const TeamGraphTab = ({
)}
/>
</div>
{createTaskDialog}
{fullscreen && (
<Suspense fallback={null}>
<TeamGraphOverlay

View file

@ -0,0 +1,124 @@
import { useCallback, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import type { TaskRef } from '@shared/types';
interface CreateTaskDialogState {
open: boolean;
defaultOwner: string;
}
interface UseGraphCreateTaskDialogResult {
dialog: React.ReactNode;
openCreateTaskDialog: (owner?: string) => void;
}
export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDialogResult {
const [dialogState, setDialogState] = useState<CreateTaskDialogState>({
open: false,
defaultOwner: '',
});
const [submitting, setSubmitting] = useState(false);
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
useShallow((state) => ({
teamData: selectTeamDataForName(state, teamName),
createTeamTask: state.createTeamTask,
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
}))
);
const activeMembers = useMemo(
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
[teamData?.members]
);
const openCreateTaskDialog = useCallback((owner = ''): void => {
setDialogState({
open: true,
defaultOwner: owner,
});
}, []);
const closeCreateTaskDialog = useCallback((): void => {
setDialogState({
open: false,
defaultOwner: '',
});
}, []);
const handleCreateTask = useCallback(
(
subject: string,
description: string,
owner?: string,
blockedBy?: string[],
related?: string[],
prompt?: string,
startImmediately?: boolean,
descriptionTaskRefs?: TaskRef[],
promptTaskRefs?: TaskRef[]
): void => {
setSubmitting(true);
void (async () => {
try {
await createTeamTask(teamName, {
subject,
description: description || undefined,
owner,
blockedBy,
related,
prompt,
descriptionTaskRefs,
promptTaskRefs,
startImmediately,
});
if (
prompt &&
owner &&
teamData?.isAlive &&
!isTeamProvisioning &&
startImmediately !== false
) {
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
try {
await api.teams.processSend(teamName, msg);
} catch {
// best-effort only
}
}
closeCreateTaskDialog();
} catch {
// store already exposes the error
} finally {
setSubmitting(false);
}
})();
},
[closeCreateTaskDialog, createTeamTask, isTeamProvisioning, teamData?.isAlive, teamName]
);
return {
openCreateTaskDialog,
dialog: (
<CreateTaskDialog
open={dialogState.open}
teamName={teamName}
members={activeMembers}
tasks={teamData?.tasks ?? []}
isTeamAlive={Boolean(teamData?.isAlive && !isTeamProvisioning)}
defaultOwner={dialogState.defaultOwner}
onClose={closeCreateTaskDialog}
onSubmit={handleCreateTask}
submitting={submitting}
/>
),
};
}

View file

@ -1418,10 +1418,13 @@ body.theme-transitioning {
stroke: rgba(138, 130, 118, 0.2);
}
:root.light .team-graph-view > .absolute.left-20.top-3 {
display: none !important;
:root.light .team-graph-view > .absolute.left-3.top-3 > div {
background: rgba(248, 244, 237, 0.9) !important;
border-color: rgba(120, 113, 108, 0.18) !important;
box-shadow: 0 10px 24px rgba(120, 113, 108, 0.1);
}
:root.light .team-graph-view > .absolute.left-3.top-3 > div,
:root.light .team-graph-view > .absolute.right-3.top-3 > div:not(.relative),
:root.light .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child,
:root.light .team-graph-view > .absolute.bottom-3.right-3 > div,
@ -1437,34 +1440,38 @@ body.theme-transitioning {
box-shadow: 0 16px 32px rgba(120, 113, 108, 0.12);
}
.team-graph-view > .absolute.left-3.top-3 > div,
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative),
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child {
width: 32px !important;
min-width: 32px !important;
height: 32px !important;
min-height: 32px !important;
width: 25px !important;
min-width: 25px !important;
height: 25px !important;
min-height: 25px !important;
padding: 0 !important;
justify-content: center !important;
border-radius: 10px !important;
border-radius: 6px !important;
}
.team-graph-view > .absolute.left-3.top-3 > div > button,
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button,
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button {
width: 100% !important;
height: 100% !important;
min-width: 100% !important;
min-height: 100% !important;
padding: 6px !important;
padding: 0 !important;
justify-content: center !important;
gap: 0 !important;
}
.team-graph-view > .absolute.left-3.top-3 > div > button svg,
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button svg,
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button svg {
width: 100% !important;
height: 100% !important;
width: 10px !important;
height: 10px !important;
}
.team-graph-view > .absolute.left-3.top-3 > div > button span,
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button span,
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button span {
display: none !important;

View file

@ -107,8 +107,14 @@ export function buildDisplayItems(
subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id)
);
// Find the step ID of lastOutput to skip it
let lastOutputStepId: string | undefined;
// Find the exact lastOutput step to skip it without accidentally
// dropping the paired tool_call, which shares the same step id.
let lastOutputStepRef:
| {
id: string;
type: SemanticStep['type'];
}
| undefined;
if (lastOutput) {
for (let i = steps.length - 1; i >= 0; i--) {
const step = steps[i];
@ -117,7 +123,7 @@ export function buildDisplayItems(
step.type === 'output' &&
step.content.outputText === lastOutput.text
) {
lastOutputStepId = step.id;
lastOutputStepRef = { id: step.id, type: step.type };
break;
}
if (
@ -125,7 +131,7 @@ export function buildDisplayItems(
step.type === 'tool_result' &&
step.content.toolResultContent === lastOutput.toolResult
) {
lastOutputStepId = step.id;
lastOutputStepRef = { id: step.id, type: step.type };
break;
}
if (
@ -133,7 +139,7 @@ export function buildDisplayItems(
step.type === 'interruption' &&
step.content.interruptionText === lastOutput.interruptionMessage
) {
lastOutputStepId = step.id;
lastOutputStepRef = { id: step.id, type: step.type };
break;
}
}
@ -142,7 +148,11 @@ export function buildDisplayItems(
// Build display items
for (const step of steps) {
// Skip the last output step
if (lastOutputStepId && step.id === lastOutputStepId) {
if (
lastOutputStepRef &&
step.id === lastOutputStepRef.id &&
step.type === lastOutputStepRef.type
) {
continue;
}

View file

@ -136,6 +136,104 @@ describe('BoardTaskActivityDetailService', () => {
);
});
it('keeps lifecycle tool-backed activity renderable when focused detail contains a tool execution', async () => {
const record = makeRecord({
id: 'record-complete',
linkKind: 'lifecycle',
action: {
canonicalToolName: 'task_complete',
toolUseId: 'tool-complete',
category: 'status',
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-complete',
toolUseId: 'tool-complete',
sourceOrder: 9,
},
});
const service = new BoardTaskActivityDetailService(
{ getTaskRecords: vi.fn(async () => [record]) } as never,
{ parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])) } as never,
{
selectDetail: vi.fn(() => ({
id: 'activity:record-complete',
timestamp: record.timestamp,
actor: record.actor,
source: record.source,
records: [record],
filteredMessages: [
{
uuid: 'msg-complete-assistant',
parentUuid: null,
type: 'assistant',
timestamp: new Date(record.timestamp),
role: 'assistant',
content: [{ type: 'tool_use', id: 'tool-complete', name: 'task_complete', input: {} }],
isSidechain: true,
isMeta: false,
toolCalls: [{ id: 'tool-complete', name: 'task_complete', input: {}, isTask: false }],
toolResults: [],
} as never,
{
uuid: 'msg-complete-user',
parentUuid: 'msg-complete-assistant',
type: 'user',
timestamp: new Date(record.timestamp),
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'tool-complete', content: '' }],
isSidechain: true,
isMeta: true,
toolCalls: [],
toolResults: [{ toolUseId: 'tool-complete', content: '', isError: false }],
toolUseResult: { content: '' },
} as never,
],
})),
} as never,
{
buildBundleChunks: vi.fn(() => [
{
id: 'chunk-complete',
chunkType: 'ai',
toolExecutions: [
{
toolCall: {
id: 'tool-complete',
name: 'task_complete',
input: {},
isTask: false,
},
startTime: new Date(record.timestamp),
},
],
semanticSteps: [
{ id: 'step-complete-call', type: 'tool_call' },
{ id: 'step-complete-result', type: 'tool_result' },
],
},
]),
} as never
);
const result = await service.getTaskActivityDetail('demo', 'task-a', 'record-complete');
expect(result.status).toBe('ok');
if (result.status !== 'ok') {
throw new Error('expected ok detail');
}
expect(result.detail.summaryLabel).toBe('Completed task');
expect(result.detail.logDetail?.chunks).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'chunk-complete',
chunkType: 'ai',
}),
])
);
});
it('returns metadata only for non-tool-backed activity without parsing transcript content', async () => {
const record = makeRecord({
id: 'record-2',

View file

@ -17,6 +17,7 @@ const apiState = {
const renderabilityState = {
hasDisplayItems: true,
toolName: 'task_add_comment',
};
vi.mock('@renderer/api', () => ({
@ -30,18 +31,16 @@ vi.mock('@renderer/api', () => ({
},
}));
vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({
MemberExecutionLog: ({
memberName,
chunks,
vi.mock('@renderer/components/chat/DisplayItemList', () => ({
DisplayItemList: ({
items,
}: {
memberName?: string;
chunks: { id: string }[];
items: Array<{ type: string; tool?: { name?: string } }>;
}) =>
React.createElement(
'div',
{ 'data-testid': 'member-execution-log' },
`${memberName ?? 'lead'}:${chunks.length}`
{ 'data-testid': 'linked-tool-card' },
items.map((item) => `${item.type}:${item.tool?.name ?? 'unknown'}`).join(',')
),
}));
@ -57,7 +56,21 @@ vi.mock('@renderer/utils/groupTransformer', () => ({
vi.mock('@renderer/utils/aiGroupEnhancer', () => ({
enhanceAIGroup: () => ({
displayItems: renderabilityState.hasDisplayItems ? [{ id: 'tool-1' }] : [],
displayItems: renderabilityState.hasDisplayItems
? [
{
type: 'tool',
tool: {
id: 'tool-1',
name: renderabilityState.toolName,
input: {},
inputPreview: '',
startTime: new Date('2026-04-13T10:35:00.000Z'),
isOrphaned: false,
},
},
]
: [],
}),
}));
@ -143,6 +156,7 @@ describe('TaskActivitySection', () => {
apiState.getTaskActivity.mockReset();
apiState.getTaskActivityDetail.mockReset();
renderabilityState.hasDisplayItems = true;
renderabilityState.toolName = 'task_add_comment';
vi.unstubAllGlobals();
});
@ -227,7 +241,7 @@ describe('TaskActivitySection', () => {
});
});
it('loads inline detail lazily and renders metadata plus a focused log snippet', async () => {
it('loads inline detail lazily and renders metadata plus a linked tool card', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskActivity.mockResolvedValue([
makeEntry({
@ -309,7 +323,9 @@ describe('TaskActivitySection', () => {
expect(host.textContent).toContain('Comment');
expect(host.textContent).toContain('42');
expect(host.textContent).toContain('while working on #peer12345');
expect(host.querySelector('[data-testid="member-execution-log"]')?.textContent).toBe('bob:1');
expect(host.querySelector('[data-testid="linked-tool-card"]')?.textContent).toBe(
'tool:task_add_comment'
);
expect(host.textContent?.match(/Added a comment/g)?.length).toBe(1);
await act(async () => {
@ -317,7 +333,7 @@ describe('TaskActivitySection', () => {
await flushMicrotasks();
});
expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull();
expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull();
await act(async () => {
root.unmount();
@ -379,7 +395,76 @@ describe('TaskActivitySection', () => {
expect(host.textContent).toContain('Viewed task');
expect(host.textContent).toContain('task_get');
expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull();
expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('shows a linked tool card for lifecycle activity when shared pipeline returns a renderable tool', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
renderabilityState.toolName = 'task_start';
apiState.getTaskActivity.mockResolvedValue([
makeEntry({
id: 'start-live',
timestamp: '2026-04-13T10:37:00.000Z',
linkKind: 'lifecycle',
action: {
canonicalToolName: 'task_start',
category: 'status',
toolUseId: 'tool-start',
},
source: {
messageUuid: 'start-live-message',
filePath: '/tmp/transcript.jsonl',
toolUseId: 'tool-start',
sourceOrder: 7,
},
}),
]);
apiState.getTaskActivityDetail.mockResolvedValue({
status: 'ok',
detail: {
entryId: 'start-live',
summaryLabel: 'Started work',
actorLabel: 'bob',
timestamp: '2026-04-13T10:37:00.000Z',
contextLines: ['without an active task scope'],
metadataRows: [
{ label: 'Task', value: '#abc12345' },
{ label: 'Tool', value: 'task_start' },
{ label: 'Scope', value: 'idle' },
],
logDetail: {
id: 'activity:start-live',
chunks: [{ id: 'chunk-start' }] as never,
},
},
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TaskActivitySection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
const button = host.querySelector('button');
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flushMicrotasks();
});
expect(host.textContent).toContain('task_start');
expect(host.querySelector('[data-testid="linked-tool-card"]')?.textContent).toBe(
'tool:task_start'
);
await act(async () => {
root.unmount();
@ -449,7 +534,7 @@ describe('TaskActivitySection', () => {
});
expect(host.textContent).toContain('task_add_comment');
expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull();
expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull();
await act(async () => {
root.unmount();
@ -512,7 +597,7 @@ describe('TaskActivitySection', () => {
expect(host.textContent).toContain('Started work');
expect(host.textContent).toContain('task_start');
expect(host.querySelector('[data-testid="member-execution-log"]')).toBeNull();
expect(host.querySelector('[data-testid="linked-tool-card"]')).toBeNull();
await act(async () => {
root.unmount();

View file

@ -0,0 +1,201 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TaskLogsPanel } from '../../../../../src/renderer/components/team/taskLogs/TaskLogsPanel';
import type { TeamTaskWithKanban } from '../../../../../src/shared/types';
const featureGateState = {
activityEnabled: true,
exactLogsEnabled: true,
};
vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskActivitySection', () => ({
TaskActivitySection: () => React.createElement('div', { 'data-testid': 'task-activity' }, 'activity'),
}));
vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskLogStreamSection', () => ({
TaskLogStreamSection: () =>
React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'),
}));
vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({
ExecutionSessionsSection: () =>
React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'),
}));
vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({
isBoardTaskActivityUiEnabled: () => featureGateState.activityEnabled,
isBoardTaskExactLogsUiEnabled: () => featureGateState.exactLogsEnabled,
}));
vi.mock('../../../../../src/renderer/components/ui/tabs', async () => {
const ReactModule = await import('react');
const TabsContext = ReactModule.createContext<{
value: string;
onValueChange: (value: string) => void;
} | null>(null);
return {
Tabs: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
}) =>
ReactModule.createElement(
TabsContext.Provider,
{ value: { value, onValueChange } },
children
),
TabsList: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement('div', null, children),
TabsTrigger: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const context = ReactModule.useContext(TabsContext);
return ReactModule.createElement(
'button',
{
type: 'button',
'data-state': context?.value === value ? 'active' : 'inactive',
onClick: () => context?.onValueChange(value),
},
children
);
},
TabsContent: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
className?: string;
}) => {
const context = ReactModule.useContext(TabsContext);
if (context?.value !== value) {
return null;
}
return ReactModule.createElement('div', { 'data-state': 'active' }, children);
},
};
});
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function findTabButton(host: HTMLElement, label: string): HTMLButtonElement | null {
return (
Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes(label)) ??
null
) as HTMLButtonElement | null;
}
function makeTask(overrides: Partial<TeamTaskWithKanban> = {}): TeamTaskWithKanban {
return {
id: 'task-1',
displayId: 'abc12345',
teamName: 'demo',
subject: 'Test task',
description: '',
status: 'in_progress',
owner: 'bob',
createdAt: '2026-04-13T10:00:00.000Z',
updatedAt: '2026-04-13T10:05:00.000Z',
reviewState: 'none',
reviewNotes: [],
blockedBy: [],
blocks: [],
comments: [],
attachments: [],
workIntervals: [],
kanbanColumnId: null,
...overrides,
} as TeamTaskWithKanban;
}
describe('TaskLogsPanel', () => {
afterEach(() => {
document.body.innerHTML = '';
featureGateState.activityEnabled = true;
featureGateState.exactLogsEnabled = true;
vi.unstubAllGlobals();
});
it('defaults to Task Log Stream and switches between the three tabs', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() }));
await flushMicrotasks();
});
expect(host.textContent).toContain('Task Log Stream');
expect(host.textContent).toContain('Task Activity');
expect(host.textContent).toContain('Execution Sessions');
expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull();
const activityTab = findTabButton(host, 'Task Activity');
expect(activityTab).not.toBeNull();
await act(async () => {
activityTab?.click();
await flushMicrotasks();
});
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
const sessionsTab = findTabButton(host, 'Execution Sessions');
expect(sessionsTab).not.toBeNull();
await act(async () => {
sessionsTab?.click();
await flushMicrotasks();
});
expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="execution-sessions"]')).not.toBeNull();
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('falls back to Task Activity when Task Log Stream is disabled', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
featureGateState.exactLogsEnabled = false;
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() }));
await flushMicrotasks();
});
expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull();
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
expect(host.textContent).not.toContain('Task Log Stream');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildDisplayItemsFromMessages } from '../../../src/renderer/utils/displayItemBuilder';
import {
buildDisplayItems,
buildDisplayItemsFromMessages,
} from '../../../src/renderer/utils/displayItemBuilder';
import type { ParsedMessage } from '../../../src/main/types/messages';
import type { SemanticStep } from '../../../src/main/types/chunks';
import type { AIGroupLastOutput } from '../../../src/renderer/types/groups';
/**
* Helper to create a minimal ParsedMessage for testing.
@ -97,3 +102,53 @@ describe('buildDisplayItemsFromMessages', () => {
});
});
});
describe('buildDisplayItems', () => {
it('keeps the linked tool item when the last output is the paired tool_result', () => {
const steps: SemanticStep[] = [
{
id: 'tool-1',
type: 'tool_call',
startTime: new Date('2025-01-01T00:00:00Z'),
durationMs: 0,
content: {
toolName: 'mcp__agent-teams__task_add_comment',
toolInput: { text: 'hello' },
},
context: 'main',
} as SemanticStep,
{
id: 'tool-1',
type: 'tool_result',
startTime: new Date('2025-01-01T00:00:01Z'),
durationMs: 0,
content: {
toolResultContent: 'comment posted',
isError: false,
},
context: 'main',
} as SemanticStep,
];
const lastOutput: AIGroupLastOutput = {
type: 'tool_result',
toolResult: 'comment posted',
isError: false,
timestamp: new Date('2025-01-01T00:00:01Z'),
};
const items = buildDisplayItems(steps, lastOutput, []);
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'tool',
tool: expect.objectContaining({
id: 'tool-1',
name: 'mcp__agent-teams__task_add_comment',
}),
}),
])
);
});
});