Skip to content
Merged
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
211 changes: 129 additions & 82 deletions apps/agor-ui/src/components/App/App.tsx

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion apps/agor-ui/src/components/App/boardPanelActions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { getShowCommentsPanelState, getToggleBoardPanelState } from './boardPanelActions';
import {
getSelectAssistantPanelTabState,
getShowCommentsPanelState,
getToggleBoardPanelState,
} from './boardPanelActions';

describe('board panel navbar actions', () => {
it('opens a closed panel on the Comments tab from Show comments tab', () => {
Expand Down Expand Up @@ -29,4 +33,15 @@ describe('board panel navbar actions', () => {
activeTab: 'comments',
});
});

it.each([
'assistant',
'all-sessions',
'comments',
] as const)('opens a closed panel on the %s tab when its rail button is clicked', (tab) => {
expect(getSelectAssistantPanelTabState(tab)).toEqual({
collapsed: false,
activeTab: tab,
});
});
});
9 changes: 9 additions & 0 deletions apps/agor-ui/src/components/App/boardPanelActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ export const getToggleBoardPanelState = (state: BoardLeftPanelState): BoardLeftP
collapsed: true,
};
};

// Used by every AssistantPanelRail button when the panel is collapsed:
// expand onto whichever tab was clicked.
export const getSelectAssistantPanelTabState = (
tab: BoardAssistantPanelTab
): BoardLeftPanelState => ({
collapsed: false,
activeTab: tab,
});
95 changes: 95 additions & 0 deletions apps/agor-ui/src/components/App/panelSizing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
capSessionSizeForCanvasMin,
getContentPanelWidthPercent,
toContentRelativePercent,
toViewportRelativePercent,
} from './panelSizing';

describe('getContentPanelWidthPercent', () => {
it('subtracts the rail width from the viewport when collapsed', () => {
expect(getContentPanelWidthPercent(true, 4, 24)).toBe(96);
});

it('subtracts the expanded left panel width when open', () => {
expect(getContentPanelWidthPercent(false, 4, 24)).toBe(76);
});
});

describe('viewport <-> content relative percent conversion', () => {
it('converts a viewport-relative size into a larger content-relative size when the content panel is narrower than the viewport', () => {
// Session panel wants 30% of the viewport; content panel is only 76%
// of the viewport (left panel expanded to 24%) — so within its own
// parent, the session panel must claim a bigger share.
expect(toContentRelativePercent(30, 76)).toBeCloseTo(39.4736, 3);
});

it('round-trips back to the original viewport-relative percent', () => {
const contentPanelWidthPercent = 76;
const viewportRelativePercent = 30;
const contentRelativePercent = toContentRelativePercent(
viewportRelativePercent,
contentPanelWidthPercent
);
expect(toViewportRelativePercent(contentRelativePercent, contentPanelWidthPercent)).toBeCloseTo(
viewportRelativePercent,
5
);
});

it('keeps the absolute (viewport-relative) session panel size constant when the left panel collapses to a rail', () => {
// Left panel expanded (24% of viewport) vs collapsed to a 4%-wide rail.
// A session panel pinned at 30% of the viewport should convert to two
// different content-relative percentages that both resolve back to the
// same 30% of the viewport — i.e. the same absolute pixel width.
const viewportRelativePercent = 30;
const expandedContentWidth = getContentPanelWidthPercent(false, 4, 24);
const collapsedContentWidth = getContentPanelWidthPercent(true, 4, 24);

const expandedContentRelative = toContentRelativePercent(
viewportRelativePercent,
expandedContentWidth
);
const collapsedContentRelative = toContentRelativePercent(
viewportRelativePercent,
collapsedContentWidth
);

expect(toViewportRelativePercent(expandedContentRelative, expandedContentWidth)).toBeCloseTo(
viewportRelativePercent,
5
);
expect(toViewportRelativePercent(collapsedContentRelative, collapsedContentWidth)).toBeCloseTo(
viewportRelativePercent,
5
);
});

it('returns 0 instead of NaN/Infinity when the content panel has no width', () => {
expect(toContentRelativePercent(30, 0)).toBe(0);
});

it('clamps content-relative percent to [0, 100]', () => {
expect(toContentRelativePercent(90, 10)).toBe(100);
});

it('clamps viewport-relative percent to [0, 100]', () => {
expect(toViewportRelativePercent(150, 90)).toBe(100);
});
});

describe('capSessionSizeForCanvasMin', () => {
it('caps the session panel so the canvas panel keeps its minSize when the left panel is expanded', () => {
// Chat pinned at 75% of the viewport (its max); left panel expanded to
// 24%, leaving a 76%-wide content panel — converting to content-relative
// asks for ~98.7%, which would starve the canvas below its 20% minimum.
const contentPanelWidthPercent = getContentPanelWidthPercent(false, 4, 24);
const sessionContentRelativePercent = toContentRelativePercent(75, contentPanelWidthPercent);

expect(capSessionSizeForCanvasMin(sessionContentRelativePercent, 20)).toBeLessThanOrEqual(80);
});

it('passes the size through unchanged when it already leaves the canvas its minimum', () => {
expect(capSessionSizeForCanvasMin(50, 20)).toBe(50);
});
});
44 changes: 44 additions & 0 deletions apps/agor-ui/src/components/App/panelSizing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Pure width math for the resizable App layout panels. Extracted so it's
// unit-testable without mounting the full App component tree.

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

// Width of the middle "content" panel (canvas + session panel) as a
// percentage of the full viewport — whatever's left once the left
// assistant/comments panel (rail or fully expanded) takes its share.
export const getContentPanelWidthPercent = (
leftPanelCollapsed: boolean,
leftPanelCollapsedSize: number,
leftPanelExpandedSize: number
) => 100 - (leftPanelCollapsed ? leftPanelCollapsedSize : leftPanelExpandedSize);

// The session/chat panel's persisted size is expressed as a percentage of
// the full viewport (so its absolute pixel width doesn't change when the
// left panel toggles between rail and expanded), but react-resizable-panels
// needs sizes expressed relative to each panel's own immediate parent — the
// content panel. These two functions convert between the two frames. Both
// clamp to [0, 100] and guard the divide so a momentarily-zero content width
// doesn't produce NaN/Infinity.
export const toContentRelativePercent = (
viewportRelativePercent: number,
contentPanelWidthPercent: number
) =>
contentPanelWidthPercent > 0
? clamp((viewportRelativePercent / contentPanelWidthPercent) * 100, 0, 100)
: 0;

export const toViewportRelativePercent = (
contentRelativePercent: number,
contentPanelWidthPercent: number
) => clamp((contentRelativePercent * contentPanelWidthPercent) / 100, 0, 100);

// The session panel shares the content panel with the canvas, which enforces
// its own `minSize`. A content-relative session size only bounded to [0, 100]
// can ask for more room than leaves the canvas its minimum; react-resizable-
// panels then clamps the layout and the session panel visibly jumps when the
// left panel toggles. Cap the session panel's content-relative percentage so
// the canvas always keeps at least `canvasMinPercent`.
export const capSessionSizeForCanvasMin = (
sessionContentRelativePercent: number,
canvasMinPercent: number
) => Math.min(sessionContentRelativePercent, 100 - canvasMinPercent);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AssistantPanelRail } from './AssistantPanelRail';

describe('AssistantPanelRail', () => {
it('renders one button per tab, fully visible (no clipped floating knob)', () => {
render(<AssistantPanelRail onSelectTab={vi.fn()} />);

expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sessions' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Comments' })).toBeInTheDocument();
});

it('opens the Assistant tab when its button is clicked', () => {
const onSelectTab = vi.fn();
render(<AssistantPanelRail onSelectTab={onSelectTab} />);

fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));

expect(onSelectTab).toHaveBeenCalledExactlyOnceWith('assistant');
});

it('opens the All sessions tab when its button is clicked', () => {
const onSelectTab = vi.fn();
render(<AssistantPanelRail onSelectTab={onSelectTab} />);

fireEvent.click(screen.getByRole('button', { name: 'Sessions' }));

expect(onSelectTab).toHaveBeenCalledExactlyOnceWith('all-sessions');
});

it('opens the Comments tab when its button is clicked', () => {
const onSelectTab = vi.fn();
render(<AssistantPanelRail onSelectTab={onSelectTab} />);

fireEvent.click(screen.getByRole('button', { name: 'Comments' }));

expect(onSelectTab).toHaveBeenCalledExactlyOnceWith('comments');
});

it('shows no unread badge on Comments when the count is zero', () => {
render(<AssistantPanelRail onSelectTab={vi.fn()} unreadCommentsCount={0} />);

expect(screen.queryByText('0')).not.toBeInTheDocument();
});

it('shows the unread badge on Comments when there are unread comments', () => {
render(<AssistantPanelRail onSelectTab={vi.fn()} unreadCommentsCount={3} />);

expect(screen.getByText('3')).toBeInTheDocument();
});
});
103 changes: 103 additions & 0 deletions apps/agor-ui/src/components/BoardAssistantPanel/AssistantPanelRail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { CommentOutlined, RobotOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Badge, theme } from 'antd';
import type React from 'react';
import { memo } from 'react';
import type { BoardAssistantPanelTab } from './BoardAssistantPanel';

interface RailItem {
key: BoardAssistantPanelTab;
label: string;
icon: React.ReactNode;
}

const RAIL_ITEMS: RailItem[] = [
{ key: 'assistant', label: 'Assistant', icon: <RobotOutlined /> },
{ key: 'all-sessions', label: 'Sessions', icon: <UnorderedListOutlined /> },
{ key: 'comments', label: 'Comments', icon: <CommentOutlined /> },
];

export interface AssistantPanelRailProps {
onSelectTab: (tab: BoardAssistantPanelTab) => void;
unreadCommentsCount?: number;
hasUserMentions?: boolean;
}

// Collapsed-state replacement for the old floating reopen knob (issue #123):
// a persistent, always-fully-visible icon rail rather than a half-clipped,
// low-contrast circle floating at the panel edge.
const AssistantPanelRailComponent: React.FC<AssistantPanelRailProps> = ({
onSelectTab,
unreadCommentsCount = 0,
hasUserMentions = false,
}) => {
const { token } = theme.useToken();

return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
paddingTop: 12,
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
}}
>
{RAIL_ITEMS.map((item) => {
const button = (
<button
key={item.key}
type="button"
aria-label={item.label}
onClick={() => onSelectTab(item.key)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
width: 48,
padding: '8px 2px',
border: 0,
borderRadius: token.borderRadius,
background: 'transparent',
color: token.colorText,
cursor: 'pointer',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = token.colorFillTertiary;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
<span style={{ fontSize: 18, lineHeight: 1 }}>{item.icon}</span>
<span style={{ fontSize: 10, lineHeight: 1.2 }}>{item.label}</span>
</button>
);

if (item.key !== 'comments') return button;

return (
<Badge
key={item.key}
count={unreadCommentsCount}
offset={[-10, 10]}
style={{
backgroundColor: hasUserMentions ? token.colorError : token.colorPrimaryBgHover,
}}
>
{button}
</Badge>
);
})}
</div>
);
};

export const AssistantPanelRail = memo(AssistantPanelRailComponent);

export default AssistantPanelRail;
1 change: 1 addition & 0 deletions apps/agor-ui/src/components/BoardAssistantPanel/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { AssistantPanelRail } from './AssistantPanelRail';
export type { BoardAssistantPanelTab } from './BoardAssistantPanel';
export { BoardAssistantPanel, default } from './BoardAssistantPanel';