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
7 changes: 7 additions & 0 deletions apps/cli/src/backends/claude/utils/claudeEffort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ describe('buildClaudeEffortCliArgs', () => {
expect(buildClaudeEffortCliArgs({ modelId: 'claude-opus-5', effort: 'xhigh' })).toEqual(['--effort', 'xhigh']);
});

it('uses Sonnet 5 effort tiers rather than the older Sonnet 4.6 substring match', () => {
expect(buildClaudeEffortCliArgs({ modelId: 'claude-sonnet-5', effort: 'high' })).toEqual([]);
expect(buildClaudeEffortCliArgs({ modelId: 'claude-sonnet-5', effort: 'xhigh' })).toEqual(['--effort', 'xhigh']);
expect(buildClaudeEffortCliArgs({ modelId: 'claude-sonnet-5', effort: 'max' })).toEqual(['--effort', 'max']);
expect(resolveClaudeDefaultEffortForModel('claude-sonnet-5')).toBe('high');
});

it('treats the generic opus alias as the current flagship Claude model for default effort resolution', () => {
expect(buildClaudeEffortCliArgs({ modelId: 'opus', effort: 'high' })).toEqual([]);
expect(buildClaudeEffortCliArgs({ modelId: 'opus', effort: 'xhigh' })).toEqual(['--effort', 'xhigh']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => {
description: expect.any(String),
contextWindowTokens: 1_000_000,
}),
expect.objectContaining({
id: 'claude-sonnet-5',
name: 'Sonnet 5',
description: expect.any(String),
contextWindowTokens: 1_000_000,
}),
expect.objectContaining({
id: 'claude-opus-4-8',
name: 'Opus 4.8',
Expand Down Expand Up @@ -200,6 +206,10 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => {
expect(fable?.modelOptions?.[0]?.currentValue).toBe('high');
expect(fable?.modelOptions?.[0]?.options?.some((opt) => opt.value === 'xhigh')).toBe(true);
expect(fable?.modelOptions?.[0]?.options?.some((opt) => opt.value === 'max')).toBe(true);
const sonnet = res.availableModels.find((model) => model.id === 'claude-sonnet-5') ?? null;
expect(sonnet?.modelOptions?.[0]?.currentValue).toBe('high');
expect(sonnet?.modelOptions?.[0]?.options?.some((opt) => opt.value === 'xhigh')).toBe(true);
expect(sonnet?.modelOptions?.[0]?.options?.some((opt) => opt.value === 'max')).toBe(true);
expect(createCatalogAcpBackendMock).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
SessionListSelectionProvider,
useSessionListSelectionActions,
} from './selection/SessionListSelectionContext';
import { SESSION_ACTION_RENAME_ID } from '@/components/sessions/actions/sessionActionIds';
import {
SESSION_ACTION_EDIT_TAGS_ID,
SESSION_ACTION_PIN_ID,
SESSION_ACTION_RENAME_ID,
} from '@/components/sessions/actions/sessionActionIds';

(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -77,6 +81,22 @@ function hasCopyDebugInformationMenuItem(items: unknown): boolean {
});
}

function hasCopySessionIdMenuItem(items: unknown): boolean {
if (!Array.isArray(items)) return false;
return items.some((item: unknown) => {
if (!item || typeof item !== 'object') return false;
return (item as { id?: unknown }).id === 'session.copyId';
});
}

function hasMenuItem(items: unknown, id: string): boolean {
if (!Array.isArray(items)) return false;
return items.some((item: unknown) => {
if (!item || typeof item !== 'object') return false;
return (item as { id?: unknown }).id === id;
});
}

function SelectionModeControls() {
const actions = useSessionListSelectionActions();
return React.createElement('SelectionModeControls', {
Expand Down Expand Up @@ -250,6 +270,51 @@ describe('SessionItem context menu press suppression', () => {
expect(hasRenameMenuItem(menus[0].props.items)).toBe(true);
});

it('opens a web right-click menu with Copy Session ID, Tags, and Pin', async () => {
platformOs = 'web';
const session = createSessionFixture({
id: 'sess_web_context_menu',
active: false,
metadata: null,
});

const screen = await renderScreen(
<SessionItem
session={session}
serverId="server_a"
selected={false}
isFirst={true}
isLast={true}
isSingle={true}
variant="default"
compact={false}
tagsEnabled
tags={[]}
allKnownTags={[]}
onSetTags={() => {}}
onTogglePinned={() => {}}
/>,
);

const row = screen.findByTestId('session-list-item-sess_web_context_menu');
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
expect(typeof row.props.onContextMenu).toBe('function');

await act(async () => {
row.props.onContextMenu({ preventDefault, stopPropagation });
});

expect(preventDefault).toHaveBeenCalledTimes(1);
expect(stopPropagation).toHaveBeenCalledTimes(1);
expect(navigateToSessionSpy).not.toHaveBeenCalled();
const menu = screen.findByType('DropdownMenu' as React.ElementType);
expect(hasCopySessionIdMenuItem(menu.props.items)).toBe(true);
expect(hasMenuItem(menu.props.items, SESSION_ACTION_EDIT_TAGS_ID)).toBe(true);
expect(hasMenuItem(menu.props.items, SESSION_ACTION_PIN_ID)).toBe(true);
expect(hasRenameMenuItem(menu.props.items)).toBe(true);
});

it('shows the copy information context menu item in developer-mode builds', async () => {
const session = createSessionFixture({
id: 'sess_debug_menu',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';

import { renderScreen, standardCleanup } from '@/dev/testkit';
import { createSessionItemTestRowModel, installSessionShellCommonModuleMocks } from './sessionShellTestHelpers';
import { resolveSessionTagChipColors } from './sessionTagColors';

(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -224,6 +225,32 @@ describe('SessionItem tags (layout)', () => {
expect(styleArray.some((s: any) => typeof s === 'object' && s?.paddingVertical === 10)).toBe(false);
});

it('renders tags with their stable muted color', async () => {
const screen = await renderScreen(
<SessionItem
session={createSession()}
serverId="server_a"
selected={false}
isFirst={true}
isLast={true}
isSingle={true}
variant="default"
compact={false}
tagsEnabled={true}
tags={['focus']}
allKnownTags={['focus']}
onSetTags={vi.fn()}
/>,
);

const tagText = screen.findAllByType('Text').find((node) => node.props.children === 'focus');
const tagChip = tagText?.parent;
const colors = resolveSessionTagChipColors('focus', false, false);

expect(tagChip?.props.style).toContainEqual({ backgroundColor: colors.backgroundColor, borderColor: colors.borderColor });
expect(tagText?.props.style).toContainEqual({ color: colors.color });
});

it('keeps narrow tags in the trailing metadata cluster', async () => {
const screen = await renderScreen(
<SessionItem
Expand Down Expand Up @@ -251,7 +278,7 @@ describe('SessionItem tags (layout)', () => {
expect(screen.findByTestId('session-item-tags-below-sess_1')).toBeNull();
});

it('shows shortest narrow tags inline with an overflow chip instead of wrapping', async () => {
it('keeps narrow tags inline without moving them below the session', async () => {
const screen = await renderScreen(
<SessionItem
session={createSession()}
Expand All @@ -276,8 +303,7 @@ describe('SessionItem tags (layout)', () => {
const rightAreaText = rightArea?.findAllByType('Text').map((node) => node.props.children).join(' ');
expect(rightAreaText).toContain('tag');
expect(rightAreaText).toContain('tag 3');
expect(rightAreaText).toContain('+1');
expect(rightAreaText).not.toContain('tag 12');
expect(rightAreaText).toContain('tag 12');
expect(screen.findByTestId('session-item-tags-below-sess_1')).toBeNull();
});

Expand Down
91 changes: 61 additions & 30 deletions apps/ui/sources/components/sessions/shell/SessionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from './sessionListRowHeights';
import { shouldUseReadableNativePhoneMinimalSessionRow } from './sessionListRowDensity';
import { planSessionTagDisplay } from './sessionTagPlacement';
import { resolveSessionTagChipColors } from './sessionTagColors';
import { useIsTablet } from '@/utils/platform/responsive';
import type { SessionStatus } from '@/utils/sessions/sessionUtils';
import { useSessionRowActionMenu } from './row/actionMenu/useSessionRowActionMenu';
Comment on lines 48 to 54
Expand All @@ -69,6 +70,8 @@ import {
} from '@/components/sessions/debug/sessionDebugInformation';
import { copySessionDebugInformationToClipboard } from '@/components/sessions/debug/sessionDebugClipboard';
import { Icon } from '@/components/ui/icons/Icon';
import { Modal } from '@/modal';
import { setClipboardStringSafe } from '@/utils/ui/clipboard';
import {
createCopySessionDebugInformationMenuItem,
SESSION_COPY_DEBUG_INFORMATION_MENU_ITEM_ID,
Expand All @@ -88,6 +91,7 @@ const SESSION_IDENTITY_SKELETON_ANIMATION_MS = 900;
const SESSION_FOLDER_ROW_CHROME_INDENT_BASE = 38;
const SESSION_FOLDER_ROW_CHROME_INDENT_STEP = 12;
const SESSION_FOLDER_ROW_INDENT_CAP = 3;
const SESSION_COPY_ID_MENU_ITEM_ID = 'session.copyId';

type SessionItemActivityTimeMode = 'meaningful' | 'updatedAt';
type SessionItemIdentityDisplay = 'avatar' | 'agentLogo' | 'none';
Expand Down Expand Up @@ -469,7 +473,7 @@ const stylesheet = StyleSheet.create((theme) => ({
alignItems: 'center',
marginTop: 0,
marginRight: 4,
maxWidth: 82,
maxWidth: 180,
},
tagChip: {
borderRadius: 999,
Expand All @@ -491,7 +495,7 @@ const stylesheet = StyleSheet.create((theme) => ({
maxWidth: 96,
},
tagChipInline: {
maxWidth: 74,
maxWidth: 140,
},
tagChipText: {
fontSize: 10,
Expand Down Expand Up @@ -758,13 +762,27 @@ const SessionItemContent = React.memo(
providerSessionId,
});
}, [resolvedSession]);
const leadingMenuItems = React.useMemo(
() => devModeEnabled
? [createCopySessionDebugInformationMenuItem({ iconColor: rowActionIconColor })]
: [],
[devModeEnabled, rowActionIconColor],
);
const leadingMenuItems = React.useMemo(() => {
const items: DropdownMenuItem[] = [{
id: SESSION_COPY_ID_MENU_ITEM_ID,
title: `${t('common.copy')} ${t('sessionInfo.happySessionId')}`,
icon: <Icon name="copy" size={16} color={rowActionIconColor} />,
}];
if (devModeEnabled) {
items.push(createCopySessionDebugInformationMenuItem({ iconColor: rowActionIconColor }));
}
return items;
}, [devModeEnabled, rowActionIconColor]);
const handleSelectLeadingMenuItem = React.useCallback(async (itemId: string) => {
if (itemId === SESSION_COPY_ID_MENU_ITEM_ID) {
const copied = await setClipboardStringSafe(resolvedSession.id);
if (copied) {
copyFeedback.markCopied(resolvedSession.id);
return;
}
Modal.alert(t('common.error'), t('sessionInfo.failedToCopySessionId'));
return;
}
if (itemId !== SESSION_COPY_DEBUG_INFORMATION_MENU_ITEM_ID) return;
const copied = await copySessionDebugInformationToClipboard(resolveSessionDebugInformation());
if (copied) {
Expand Down Expand Up @@ -1081,6 +1099,12 @@ const SessionItemContent = React.memo(
suppressNextPressRef.current = true;
setContextMenuOpen(true);
}, [clearContextMenuPressInTimer, enableLongPressContextMenu, setContextMenuOpen]);
const handleWebContextMenu = React.useCallback((event: unknown) => {
if (!isWeb || contextMenuItems.length === 0) return;
stopRowPressPropagation(event);
suppressNextRowPressTemporarily();
setContextMenuOpen(true);
}, [contextMenuItems.length, isWeb, setContextMenuOpen, stopRowPressPropagation, suppressNextRowPressTemporarily]);

const shouldRenderAvatarMonochrome = resolvedSession.active !== true || !sessionStatus.isConnected;
const avatarSize = isMinimal
Expand Down Expand Up @@ -1131,28 +1155,33 @@ const SessionItemContent = React.memo(
isMinimal ? styles.tagsRowMinimal : null,
]}
>
{tagChips.map((tag) => (
<View
key={tag.key}
style={[
styles.tagChip,
tagChipDensity === 'compact' ? styles.tagChipCompact : null,
tagChipDensity === 'minimal' ? styles.tagChipMinimal : null,
placement === 'inline' ? styles.tagChipInline : null,
]}
>
<Text
{tagChips.map((tag) => {
const colors = resolveSessionTagChipColors(tag.label, tag.isOverflow, theme.dark);
return (
<View
key={tag.key}
style={[
styles.tagChipText,
tagChipDensity === 'compact' ? styles.tagChipTextCompact : null,
tagChipDensity === 'minimal' ? styles.tagChipTextMinimal : null,
styles.tagChip,
tagChipDensity === 'compact' ? styles.tagChipCompact : null,
tagChipDensity === 'minimal' ? styles.tagChipMinimal : null,
placement === 'inline' ? styles.tagChipInline : null,
{ backgroundColor: colors.backgroundColor, borderColor: colors.borderColor },
]}
numberOfLines={1}
>
{tag.label}
</Text>
</View>
))}
<Text
style={[
styles.tagChipText,
tagChipDensity === 'compact' ? styles.tagChipTextCompact : null,
tagChipDensity === 'minimal' ? styles.tagChipTextMinimal : null,
{ color: colors.color },
]}
numberOfLines={1}
>
{tag.label}
</Text>
</View>
);
})}
</View>
);

Expand All @@ -1174,6 +1203,8 @@ const SessionItemContent = React.memo(
embedded && !embeddedIsLast ? styles.embeddedSeparator : null,
]}
onPress={handleRowPress}
// @ts-expect-error - React Native types omit this web-only event.
onContextMenu={isWeb ? (handleWebContextMenu as any) : undefined}
onPressIn={enableLongPressContextMenu ? () => {
clearContextMenuPressInTimer();
contextMenuPressInTimerRef.current = setTimeout(() => {
Expand Down Expand Up @@ -1552,11 +1583,11 @@ const SessionItemContent = React.memo(
: null,
];

const shouldRenderNativeContextMenu = isNativeMobile && contextMenuOpen && contextMenuItems.length > 0;
const shouldRenderContextMenu = contextMenuOpen && contextMenuItems.length > 0;
const shouldRenderNativeTagMenu = isNativeMobile && supportsTag && tagMenuOpen;
const menuNodes = shouldRenderNativeContextMenu || shouldRenderNativeTagMenu ? (
const menuNodes = shouldRenderContextMenu || shouldRenderNativeTagMenu ? (
<>
{shouldRenderNativeContextMenu ? (
{shouldRenderContextMenu ? (
<ContextMenu
open={contextMenuOpen}
onOpenChange={setContextMenuOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,6 @@ export function useSessionRowActionMenu(params: Readonly<{
]);

const contextMenuItems = React.useMemo((): DropdownMenuItem[] => {
if (!params.isNativeMobile) return [];
const items: DropdownMenuItem[] = [];
if (params.selectionModeAvailable === true && typeof params.onEnterSelectionMode === 'function') {
items.push({
Expand Down
22 changes: 22 additions & 0 deletions apps/ui/sources/components/sessions/shell/sessionTagColors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';

import { resolveSessionTagChipColors, resolveSessionTagColorRole } from './sessionTagColors';

describe('resolveSessionTagColorRole', () => {
it('assigns each tag a stable muted color role', () => {
expect(resolveSessionTagColorRole('focus')).toBe(resolveSessionTagColorRole('focus'));
expect(resolveSessionTagColorRole('focus')).not.toBe('neutral');
expect(new Set(['focus', 'later', 'urgent', 'review'].map(resolveSessionTagColorRole)).size).toBeGreaterThan(1);
});

it('keeps overflow chips neutral', () => {
expect(resolveSessionTagColorRole('+2', true)).toBe('neutral');
});

it('gives distinct tag labels distinct muted dark colors', () => {
const backgrounds = ['Phone Farming', 'Outlandish', 'Happier', 'Hermes']
.map((label) => resolveSessionTagChipColors(label, false, true).backgroundColor);

expect(new Set(backgrounds)).toHaveLength(4);
});
});
Loading