feat(graph-controls): integrate tooltip support, refine button styles, and enhance task creation functionality
This commit is contained in:
parent
ce0eb75429
commit
a0c8db4771
16 changed files with 989 additions and 239 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
201
test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts
Normal file
201
test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue