Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 46 additions & 35 deletions web/src/embedded/chat/components/messages/activity-center/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,48 +90,59 @@ export const ActivityCenter = ({
}, [environmentId]);

const canAccept = showFiles && !isAccepting && !isDiscarding;
const headerTitle = showFiles
? 'Workspace file changes'
: (visibleTask?.title ?? 'Files');
const headerIcon =
!showFiles && visibleTask ? (
<TaskStatusIcon task={visibleTask} isStreaming={isStreaming} />
) : null;

const fileActions = showFiles ? (
<div className="flex items-center gap-[6px] shrink-0">
<Button
variant="primary"
onClick={e => {
e.stopPropagation();
handleAccept();
}}
disabled={!canAccept || selectedPaths.size === 0}
>
{isAccepting ? (
<Spinner className="text-primary-highlight w-3 h-3" />
) : (
`Accept${selectedPaths.size < allFilePaths.length ? ` (${selectedPaths.size})` : ''}`
)}
</Button>
<Button
onClick={e => {
e.stopPropagation();
handleDiscardAll();
}}
disabled={!canAccept}
>
{isDiscarding ? <Spinner className="w-3 h-3" /> : 'Reject'}
</Button>
</div>
) : null;

const header = (expanded: boolean) => (
<div className="flex items-center justify-between h-full px-[8px]">
<div className="flex items-center min-w-0 flex-1">
<div className="flex h-5 w-5 items-center justify-center hide-if-empty">
{visibleTask && (
<TaskStatusIcon task={visibleTask} isStreaming={isStreaming} />
)}
{headerIcon && (
<div className="flex h-5 w-5 items-center justify-center mr-1">
{headerIcon}
</div>
)}
<div className="flex items-center min-w-0 flex-1 gap-[6px]">
<span className="text-[13px] leading-[19px] text-text-primary font-medium truncate min-w-0">
{headerTitle}
</span>
{fileActions}
</div>
<span className="text-[13px] leading-[19px] text-text-primary font-medium truncate ml-1">
{visibleTask?.title ?? 'Files'}
</span>
</div>

<div className="flex items-center text-text-secondary gap-[6px]">
{showFiles && (
<>
<Button
variant="primary"
onClick={e => {
e.stopPropagation();
handleAccept();
}}
disabled={!canAccept || selectedPaths.size === 0}
>
{isAccepting ? (
<Spinner className="text-primary-highlight w-3 h-3" />
) : (
`Accept${selectedPaths.size < allFilePaths.length ? ` (${selectedPaths.size})` : ''}`
)}
</Button>
<Button
onClick={e => {
e.stopPropagation();
handleDiscardAll();
}}
disabled={!canAccept}
>
{isDiscarding ? <Spinner className="w-3 h-3" /> : 'Reject'}
</Button>
</>
)}
<div className="flex items-center text-text-secondary gap-[6px] shrink-0">
{showTask && visibleTask && (
<div
onClick={async () => {
Expand Down
187 changes: 187 additions & 0 deletions web/tests/embedded/activity-center.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// @vitest-environment jsdom

import React from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
SessionHistoryMessage,
WorkspaceState,
} from '../../src/embedded/chat/types/history';
import { ActivityCenter } from '../../src/embedded/chat/components/messages/activity-center';

vi.mock(
'../../src/embedded/chat/components/messages/activity-center/banner',
async () => {
const ReactModule = await import('react');

return {
ActivityCenterBanner: ({
header,
visible,
className,
}: {
header: (expanded: boolean) => React.ReactNode;
visible: boolean;
className?: string;
}) =>
visible
? ReactModule.createElement(
'div',
{ className },
ReactModule.createElement('header', null, header(true))
)
: null,
};
}
);

vi.mock('../../src/embedded/chat/components/loading/spinner', async () => {
const ReactModule = await import('react');

return {
Spinner: ({ className }: { className?: string }) =>
ReactModule.createElement('span', {
className,
'data-testid': 'spinner',
}),
};
});

const mountedRoots: Root[] = [];

class MockResizeObserver implements ResizeObserver {
readonly observe = vi.fn();
readonly unobserve = vi.fn();
readonly disconnect = vi.fn();
}

function makeWorkspaceState(): WorkspaceState {
return {
sessionId: 'session-1',
environmentId: 'environment-1',
environmentLabel: 'Workspace',
fileDiff: [
{
path: 'src/index.tsx',
mode: 0o100644,
isDir: false,
isUpdated: true,
isDeleted: false,
timestamp: new Date(0).toISOString(),
size: 120,
},
],
};
}

function makeTaskMessage(
taskTitle = 'Workspace file changes'
): SessionHistoryMessage {
return {
id: 'task-start-1',
type: 'task',
role: 'assistant',
timestamp: 1,
taskId: 'task-1',
action: 'start',
taskTitle,
todos: [],
};
}

function renderActivityCenter({ taskTitle }: { taskTitle?: string } = {}) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mountedRoots.push(root);

flushSync(() => {
root.render(
<ActivityCenter
messages={[makeTaskMessage(taskTitle)]}
workspaceState={makeWorkspaceState()}
isStreaming={false}
hasOpenTask
/>
);
});

return container;
}

describe('ActivityCenter', () => {
beforeEach(() => {
globalThis.ResizeObserver = MockResizeObserver;
window.jsb = {
MessagesBridge: {
acceptFiles: vi.fn().mockResolvedValue(undefined),
discardAllChanges: vi.fn().mockResolvedValue(undefined),
cancelTask: vi.fn().mockResolvedValue(undefined),
checkFilesExist: vi.fn().mockResolvedValue({}),
getFileIcon: vi.fn().mockResolvedValue(null),
},
} as unknown as Window['jsb'];
});

afterEach(() => {
for (const root of mountedRoots.splice(0)) {
flushSync(() => {
root.unmount();
});
}
document.body.innerHTML = '';
vi.clearAllMocks();
Reflect.deleteProperty(window, 'jsb');
});

it('keeps file review actions immediately beside the workspace file changes title', () => {
renderActivityCenter();

const header = document.body.querySelector('header');
expect(header).toBeTruthy();

const title = Array.from(header?.querySelectorAll('span') ?? []).find(
element => element.textContent === 'Workspace file changes'
);
const acceptButton = Array.from(
header?.querySelectorAll('button') ?? []
).find(element => element.textContent === 'Accept');
const rejectButton = Array.from(
header?.querySelectorAll('button') ?? []
).find(element => element.textContent === 'Reject');
const expandButton = header?.querySelector('[role="button"]');

expect(title).toBeTruthy();
expect(acceptButton).toBeTruthy();
expect(rejectButton).toBeTruthy();
expect(expandButton).toBeTruthy();

expect(acceptButton?.parentElement?.parentElement).toBe(
title?.parentElement
);
expect(rejectButton?.parentElement?.parentElement).toBe(
title?.parentElement
);
expect(acceptButton?.compareDocumentPosition(rejectButton as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(rejectButton?.compareDocumentPosition(expandButton as Node)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
});

it('uses the file changes heading without duplicating the conversation task title', () => {
renderActivityCenter({ taskTitle: 'Fix activity center alignment' });

const header = document.body.querySelector('header');
expect(header?.textContent).toContain('Workspace file changes');
expect(header?.textContent).not.toContain('Fix activity center alignment');

const title = Array.from(header?.querySelectorAll('span') ?? []).find(
element => element.textContent === 'Workspace file changes'
);

expect(title?.parentElement?.previousElementSibling).toBeNull();
});
});
Loading