Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ import { SessionGroupSection } from './SessionGroupSection';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

describe('SessionGroupSection', () => {
it('renders a supplied source icon instead of a color dot', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionGroupSection
id="scheduled-task:task-1"
label="Review PRs"
count={1}
expanded
icon={<svg data-testid="scheduled-task-icon" />}
onToggle={() => {}}
>
<div>Run 1</div>
</SessionGroupSection>
</I18nProvider>,
);
});

expect(
container.querySelector('[data-testid="scheduled-task-icon"]'),
).not.toBeNull();
expect(container.querySelector('[class*="sessionGroupDot"]')).toBeNull();
act(() => root.unmount());
container.remove();
});

it('shows five sessions and resets Show all after collapsing', () => {
const container = document.createElement('div');
document.body.appendChild(container);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SessionGroupSectionProps {
count: number;
expanded: boolean;
color?: DaemonSessionGroupColor;
icon?: ReactNode;
children: ReactNode;
onToggle: () => void;
onRename?: () => void;
Expand All @@ -37,6 +38,7 @@ export function SessionGroupSection({
count,
expanded,
color,
icon,
children,
onToggle,
onRename,
Expand Down Expand Up @@ -71,11 +73,17 @@ export function SessionGroupSection({
aria-expanded={expanded}
onClick={onToggle}
>
<span
className={`${styles.sessionGroupDot} ${colorClass}`}
style={dotStyle}
aria-hidden="true"
/>
{icon ? (
<span className={styles.sessionGroupIcon} aria-hidden="true">
{icon}
</span>
) : (
<span
className={`${styles.sessionGroupDot} ${colorClass}`}
style={dotStyle}
aria-hidden="true"
/>
)}
<span className={styles.sessionGroupTitle}>{label}</span>
<span className={styles.sessionGroupCount}>· {count}</span>
<span className={styles.sessionGroupChevron} aria-hidden="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,26 @@
background: var(--muted-foreground);
}

.sessionGroupIcon {
width: 14px;
height: 14px;
flex: 0 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
}

.sessionGroupIcon svg {
width: 14px;
height: 14px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}

.sessionGroupTitle {
min-width: 0;
overflow: hidden;
Expand Down
51 changes: 35 additions & 16 deletions packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ import {
replaceOwnedCollapsedSessionSectionIds,
} from './collapsedSessionSections';
import { measureSessionTitleScroll } from './sessionTitleScroll';
import {
collectScheduledTaskSession,
getScheduledTaskSessionGroup,
type ScheduledTaskSessionSection,
} from './scheduled-task-session-groups';
import {
SESSION_LIST_PAGE_SIZE,
SESSION_ORGANIZATION_FEATURE,
Expand Down Expand Up @@ -149,11 +154,6 @@ const SESSION_MENU_PORTAL_STYLE: CSSProperties = {
const GROUP_MENU_MARGIN = 8;
const CUSTOM_GROUP_COLOR_OPTION = '__custom__';
const DEFAULT_CUSTOM_GROUP_COLOR: DaemonSessionGroupHexColor = '#416ef5';
// Mirrors `SCHEDULED_TASK_RUN_SOURCE_ID_PREFIX` in acp-bridge/session-source.ts
// (the client cannot import that package). Per-run scheduled task children keep
// the `default` source type so they list with ordinary conversations.
const SCHEDULED_TASK_RUN_SOURCE_ID_PREFIX = 'scheduled_task_run:';

type SidebarSessionSource = 'default' | 'channel';

function matchesSessionSource(
Expand All @@ -170,9 +170,7 @@ function matchesSessionSource(
function isScheduledTaskSession(session: DaemonSessionSummary): boolean {
return (
session.sourceType === 'scheduled_task' ||
(session.sourceType === 'default' &&
session.sourceId?.startsWith(SCHEDULED_TASK_RUN_SOURCE_ID_PREFIX) ===
true)
getScheduledTaskSessionGroup(session) !== undefined
);
}

Expand Down Expand Up @@ -329,7 +327,7 @@ const SESSION_GROUP_COLORS: DaemonSessionGroupPresetColor[] = [

type GroupEditorMode = 'create' | 'edit';

type SessionSectionKind = 'color' | 'group' | 'recent';
type SessionSectionKind = 'color' | 'group' | 'scheduled-task' | 'recent';

interface SessionSection {
id: string;
Expand Down Expand Up @@ -1275,6 +1273,12 @@ export function WebShellSidebar({
if (prevOrganizationEnabled !== organizationEnabled) {
setPrevOrganizationEnabled(organizationEnabled);
setGroupsCatalogReady(!organizationEnabled);
if (organizationEnabled) {
// An org-disabled settle may already have consumed the latch against
// scheduled-task sections alone; restore it so the first organized
// settle registers manual groups as initial instead of collapsing them.
awaitingInitialSessionCatalogBySourceRef.current[sessionSource] = true;
}
}
const [sidebarWidth, setSidebarWidth] = useState(readSidebarWidth);
const [projectExpanded, setProjectExpanded] = useState(() =>
Expand Down Expand Up @@ -3755,14 +3759,17 @@ export function WebShellSidebar({
);

const sessionSections = useMemo<SessionSection[]>(() => {
if (!organizationEnabled) return [];
const searching = searchQuery.trim().length > 0;
const validGroupIds = new Set(groups.map((group) => group.id));
const sessionsByColor = new Map<
DaemonSessionGroupPresetColor,
DaemonSessionSummary[]
>();
const sessionsByGroupId = new Map<string, DaemonSessionSummary[]>();
const scheduledTaskSections = new Map<
string,
ScheduledTaskSessionSection
>();
for (const group of groups) {
sessionsByGroupId.set(group.id, []);
}
Expand All @@ -3778,7 +3785,11 @@ export function WebShellSidebar({
for (const session of searchedSessions) {
// Color takes precedence: the picker keeps color and group mutually
// exclusive, but stay defensive if a store somehow carries both.
if (session.color && SESSION_GROUP_COLORS.includes(session.color)) {
if (
organizationEnabled &&
session.color &&
SESSION_GROUP_COLORS.includes(session.color)
) {
const bucket = sessionsByColor.get(session.color) ?? [];
bucket.push(session);
sessionsByColor.set(session.color, bucket);
Expand All @@ -3792,6 +3803,9 @@ export function WebShellSidebar({
groupSessions.push(session);
continue;
}
if (collectScheduledTaskSession(scheduledTaskSections, session)) {
continue;
}
// On sources with a Pinned section, a pinned session without a
// (renderable) group stays Pinned-section-only; it never spills into
// Ungrouped. The channel source has no Pinned section, so its pinned
Expand Down Expand Up @@ -3828,6 +3842,9 @@ export function WebShellSidebar({
sessions: groupSessions,
});
}
for (const section of scheduledTaskSections.values()) {
sections.push({ ...section, kind: 'scheduled-task' });
}
if (recentSessions.length > 0 && sections.length > 0) {
sections.push({
id: RECENT_SESSION_SECTION_ID,
Expand Down Expand Up @@ -3855,8 +3872,8 @@ export function WebShellSidebar({
// until it settles; wait for a page fetched for the channel source.
if (settledSessionsSourceRef.current !== 'channel') return;
} else {
if (!organizationEnabled) return;
if (!groupsCatalogReady || !sessionsCatalogReady) return;
if (!sessionsCatalogReady) return;
if (organizationEnabled && !groupsCatalogReady) return;
}
const unseenIds = activeSections
.map((section) => section.id)
Expand Down Expand Up @@ -4686,9 +4703,6 @@ export function WebShellSidebar({
if (selectedSessionSource === 'channel') {
return renderFlatSessions();
}
if (!organizationEnabled) {
return renderFlatSessions();
}
if (sessionSections.length === 0) {
return renderFlatSessions();
}
Expand All @@ -4703,6 +4717,11 @@ export function WebShellSidebar({
label={section.label}
count={section.sessions.length}
color={section.color}
icon={
section.kind === 'scheduled-task' ? (
<CalendarClockIcon data-web-shell-scheduled-task-group />
) : undefined
}
limitSessions={editingSessionIdentity === null && !searchQuery.trim()}
expanded={expanded}
onToggle={() => toggleSessionSection(section.id)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,7 @@ describe('WebShellSidebar workspace removal', () => {
});
expect(secondaryArchive).toHaveBeenCalledWith(['other-secondary']);
expect(primaryArchive).not.toHaveBeenCalled();
});
}, 15000);

it('shows rename for current and non-current locked-secondary sessions', async () => {
connection.sessionId = 'locked-current';
Expand Down Expand Up @@ -1501,7 +1501,7 @@ describe('WebShellSidebar workspace removal', () => {
expect(primaryDelete).not.toHaveBeenCalled();
expect(primaryArchive).not.toHaveBeenCalled();
expect(primaryOrganization).not.toHaveBeenCalled();
});
}, 15000);

it('requires workspace_qualified_rest_core for locked secondary destructive and organization controls', async () => {
connection.capabilities = {
Expand Down Expand Up @@ -4349,21 +4349,45 @@ describe('WebShellSidebar session source switch', () => {
expect(container.textContent).toContain('Channel session');
});

it('renders scheduled-task runs with a flat source icon', async () => {
it('groups scheduled-task runs under the task title and source icon', async () => {
const scheduledRun: DaemonSessionSummary = {
sessionId: 'scheduled-run',
displayName: 'Hourly review',
displayName: 'Hourly review · 08-31 09:30',
workspaceCwd: '/tmp/project',
sourceType: 'default',
sourceId: 'scheduled_task_run:task-1',
};
active.sessions.push(scheduledRun);
active.sessions.push(scheduledRun, {
...scheduledRun,
sessionId: 'scheduled-run-2',
displayName: 'Hourly review · 08-31 08:30',
});
renderSidebar();
await ensureWorkspaceExpanded('project');

const group = container.querySelector(
'section[aria-label="Hourly review"]',
);
expect(group).not.toBeNull();
expect(
group?.querySelector('[data-web-shell-scheduled-task-group]'),
).not.toBeNull();
expect(
group?.querySelectorAll('[data-web-shell-session-title]'),
).toHaveLength(2);
expect(
Array.from(
container.querySelectorAll('[data-web-shell-session-title]'),
).filter((candidate) =>
candidate.textContent?.startsWith('Hourly review ·'),
),
).toHaveLength(2);

const title = Array.from(
container.querySelectorAll('[data-web-shell-session-title]'),
).find((candidate) => candidate.textContent === 'Hourly review');
).find(
(candidate) => candidate.textContent === 'Hourly review · 08-31 09:30',
);
const row = title?.closest('[role="button"]');
expect(row).toBeTruthy();
const sourceIcon = row?.querySelector(
Expand All @@ -4384,7 +4408,9 @@ describe('WebShellSidebar session source switch', () => {
const completedRow = Array.from(
container.querySelectorAll('[data-web-shell-session-title]'),
)
.find((candidate) => candidate.textContent === 'Hourly review')
.find(
(candidate) => candidate.textContent === 'Hourly review · 08-31 09:30',
)
?.closest('[role="button"]');
expect(
completedRow?.querySelector('[data-web-shell-scheduled-task-session]'),
Expand Down Expand Up @@ -5189,6 +5215,54 @@ describe('WebShellSidebar session source switch', () => {
window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '',
).not.toContain('channel-type:');
});

it('registers manual groups as initial when organization lands after a scheduled-task settle', async () => {
active.sessions.push({
sessionId: 'scheduled-run',
displayName: 'Hourly review · 08-31 09:30',
workspaceCwd: '/tmp/project',
sourceType: 'default',
sourceId: 'scheduled_task_run:task-1',
});

renderSidebar();
await ensureWorkspaceExpanded('project');

// Organization is disabled, so the flat settle carries only the
// scheduled-task section. Nothing may be persisted as collapsed yet.
expect(
container.querySelector('section[aria-label="Hourly review"]'),
).not.toBeNull();
expect(
window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '',
).not.toContain('scheduled-task:');

// The session_organization capability lands mid-session and the groups
// catalog settles with a manual group.
const organized = {
...capabilities,
features: [...capabilities.features, 'session_organization'],
};
connection.capabilities = organized;
workspace.capabilities = organized;
workspaceActions.listSessionGroups.mockResolvedValue({
groups: [{ id: 'manual-group', name: 'Manual group', color: 'blue' }],
colorOptions: ['blue'],
});
renderSidebar();
await settleGroupsCatalog();

const manualGroup = container.querySelector(
'section[aria-label="Manual group"]',
);
expect(manualGroup).not.toBeNull();
expect(
manualGroup!.querySelector('button[aria-expanded="true"]'),
).not.toBeNull();
expect(
window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '',
).not.toContain('group:');
});
});

describe('WebShellSidebar session list notices', () => {
Expand Down
Loading
Loading