diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.test.ts b/apps/cli/src/backends/claude/utils/claudeEffort.test.ts index 43e1b04e18..e3a810337c 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.test.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.test.ts @@ -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']); diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts index 82d9aaf714..35d69db417 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts @@ -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', @@ -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(); }); diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.contextMenu.suppressPress.test.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.contextMenu.suppressPress.test.tsx index 3f882e01a9..42d8c067ce 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.contextMenu.suppressPress.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.contextMenu.suppressPress.test.tsx @@ -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; @@ -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', { @@ -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( + {}} + 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', diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx index 3e4c3afc69..3203334e96 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.tags.layout.test.tsx @@ -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; @@ -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( + , + ); + + 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( { 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( { 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(); }); diff --git a/apps/ui/sources/components/sessions/shell/SessionItem.tsx b/apps/ui/sources/components/sessions/shell/SessionItem.tsx index 2d5836635f..57d000dd11 100644 --- a/apps/ui/sources/components/sessions/shell/SessionItem.tsx +++ b/apps/ui/sources/components/sessions/shell/SessionItem.tsx @@ -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'; @@ -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, @@ -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'; @@ -469,7 +473,7 @@ const stylesheet = StyleSheet.create((theme) => ({ alignItems: 'center', marginTop: 0, marginRight: 4, - maxWidth: 82, + maxWidth: 180, }, tagChip: { borderRadius: 999, @@ -491,7 +495,7 @@ const stylesheet = StyleSheet.create((theme) => ({ maxWidth: 96, }, tagChipInline: { - maxWidth: 74, + maxWidth: 140, }, tagChipText: { fontSize: 10, @@ -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: , + }]; + 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) { @@ -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 @@ -1131,28 +1155,33 @@ const SessionItemContent = React.memo( isMinimal ? styles.tagsRowMinimal : null, ]} > - {tagChips.map((tag) => ( - - { + const colors = resolveSessionTagChipColors(tag.label, tag.isOverflow, theme.dark); + return ( + - {tag.label} - - - ))} + + {tag.label} + + + ); + })} ); @@ -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(() => { @@ -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 ? ( { - if (!params.isNativeMobile) return []; const items: DropdownMenuItem[] = []; if (params.selectionModeAvailable === true && typeof params.onEnterSelectionMode === 'function') { items.push({ diff --git a/apps/ui/sources/components/sessions/shell/sessionTagColors.test.ts b/apps/ui/sources/components/sessions/shell/sessionTagColors.test.ts new file mode 100644 index 0000000000..9f19d9bb07 --- /dev/null +++ b/apps/ui/sources/components/sessions/shell/sessionTagColors.test.ts @@ -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); + }); +}); diff --git a/apps/ui/sources/components/sessions/shell/sessionTagColors.ts b/apps/ui/sources/components/sessions/shell/sessionTagColors.ts new file mode 100644 index 0000000000..67aa39c9b7 --- /dev/null +++ b/apps/ui/sources/components/sessions/shell/sessionTagColors.ts @@ -0,0 +1,26 @@ +export type SessionTagColorRole = 'slate' | 'blue' | 'green' | 'amber' | 'violet' | 'rose' | 'teal' | 'neutral'; +export type SessionTagChipColors = Readonly<{ backgroundColor: string; borderColor: string; color: string }>; + +const TAG_COLOR_ROLES: readonly Exclude[] = ['slate', 'blue', 'green', 'amber', 'violet', 'rose', 'teal']; + +// Tags need more variety than status colours provide. This domain palette stays quiet in both themes. +const LIGHT_TAG_COLORS: Readonly> = { + slate: { backgroundColor: '#F1F4F6', borderColor: '#D9E0E5', color: '#60707A' }, blue: { backgroundColor: '#F0F5FC', borderColor: '#D3DFEF', color: '#607AA6' }, green: { backgroundColor: '#F0F7F1', borderColor: '#D4E5D7', color: '#5C8065' }, amber: { backgroundColor: '#FCF7ED', borderColor: '#E9DEC5', color: '#8B7345' }, violet: { backgroundColor: '#F5F2F9', borderColor: '#E0D8EC', color: '#77658E' }, rose: { backgroundColor: '#FBF2F3', borderColor: '#ECD6DA', color: '#946773' }, teal: { backgroundColor: '#EEF7F6', borderColor: '#D0E5E1', color: '#5C807B' }, neutral: { backgroundColor: '#F5F5F5', borderColor: '#E2E2E2', color: '#777777' }, +}; +const DARK_TAG_COLORS: Readonly> = { + slate: { backgroundColor: '#22282A', borderColor: '#303A3D', color: '#A0AFB5' }, blue: { backgroundColor: '#202938', borderColor: '#303F56', color: '#9AADD1' }, green: { backgroundColor: '#202D25', borderColor: '#304035', color: '#98B8A0' }, amber: { backgroundColor: '#302B22', borderColor: '#433B2D', color: '#C1AD81' }, violet: { backgroundColor: '#292430', borderColor: '#3B3345', color: '#B3A2C5' }, rose: { backgroundColor: '#312427', borderColor: '#453136', color: '#C29AA4' }, teal: { backgroundColor: '#202C2B', borderColor: '#30403E', color: '#97B9B4' }, neutral: { backgroundColor: '#252323', borderColor: '#343131', color: '#A49D98' }, +}; + +export function resolveSessionTagColorRole(label: string, isOverflow = false): SessionTagColorRole { + if (isOverflow) return 'neutral'; + + let hash = 5381; + for (const char of label.trim().toLocaleLowerCase()) { + hash = (hash * 33 + char.charCodeAt(0)) >>> 0; + } + return TAG_COLOR_ROLES[hash % TAG_COLOR_ROLES.length]; +} + +export function resolveSessionTagChipColors(label: string, isOverflow: boolean, isDark: boolean): SessionTagChipColors { + return (isDark ? DARK_TAG_COLORS : LIGHT_TAG_COLORS)[resolveSessionTagColorRole(label, isOverflow)]; +} diff --git a/apps/ui/sources/components/sessions/shell/sessionTagPlacement.test.ts b/apps/ui/sources/components/sessions/shell/sessionTagPlacement.test.ts index 25802f907a..21a3b5b7f6 100644 --- a/apps/ui/sources/components/sessions/shell/sessionTagPlacement.test.ts +++ b/apps/ui/sources/components/sessions/shell/sessionTagPlacement.test.ts @@ -23,17 +23,17 @@ describe('resolveSessionTagPlacement', () => { })).toBe('inline'); }); - it('keeps compact tags below when their combined labels exceed the inline budget', () => { + it('keeps compact tags in the right area when their combined labels exceed the inline budget', () => { expect(resolveSessionTagPlacement({ density: 'compact', tags: [{ label: 'tag' }, { label: 'tag 12' }, { label: 'tag 3' }], rowWidth: null, hasTrailingMeta: true, hasRowActions: false, - })).toBe('below'); + })).toBe('inline'); }); - it('keeps cozy tags below when the leading identity leaves only a small inline budget', () => { + it('keeps cozy tags in the right area with a leading identity', () => { expect(resolveSessionTagPlacement({ density: 'compact', tags: [{ label: 'tag' }, { label: 'tag 2' }], @@ -41,7 +41,7 @@ describe('resolveSessionTagPlacement', () => { hasTrailingMeta: true, hasRowActions: false, hasLeadingIdentity: true, - })).toBe('below'); + })).toBe('inline'); }); it('places the same cozy tags inline when the leading identity is hidden', () => { @@ -55,24 +55,24 @@ describe('resolveSessionTagPlacement', () => { })).toBe('inline'); }); - it('keeps compact tags below when actions own the trailing area', () => { + it('does not render tags while actions own the trailing area', () => { expect(resolveSessionTagPlacement({ density: 'compact', tags: [{ label: 'v2' }], rowWidth: null, hasTrailingMeta: false, hasRowActions: true, - })).toBe('below'); + })).toBe('inline'); }); - it('keeps compact tags below when measured width would leave too little title room', () => { + it('keeps compact tags in the right area when measured width is narrow', () => { expect(resolveSessionTagPlacement({ density: 'compact', tags: [{ label: 'v2' }], rowWidth: 170, hasTrailingMeta: true, hasRowActions: false, - })).toBe('below'); + })).toBe('inline'); }); it('keeps short compact tags inline even when they take modest title space', () => { @@ -85,13 +85,13 @@ describe('resolveSessionTagPlacement', () => { })).toBe('inline'); }); - it('does not place default-density tags inline', () => { + it('places default-density tags in the right area', () => { expect(resolveSessionTagPlacement({ density: 'default', tags: [{ label: 'v2' }], rowWidth: 360, hasTrailingMeta: true, hasRowActions: false, - })).toBe('below'); + })).toBe('inline'); }); }); diff --git a/apps/ui/sources/components/sessions/shell/sessionTagPlacement.ts b/apps/ui/sources/components/sessions/shell/sessionTagPlacement.ts index bd447369af..33dd9d85d1 100644 --- a/apps/ui/sources/components/sessions/shell/sessionTagPlacement.ts +++ b/apps/ui/sources/components/sessions/shell/sessionTagPlacement.ts @@ -26,19 +26,11 @@ export type SessionTagDisplayPlan = Readonly<{ chips: readonly SessionTagDisplayChip[]; }>; -const NARROW_INLINE_MAX_TOTAL_LABEL_LENGTH = 10; -const COMPACT_INLINE_MAX_TOTAL_LABEL_LENGTH = 10; -const COMPACT_INLINE_MAX_TOTAL_LABEL_LENGTH_WITH_IDENTITY = 5; -const COMPACT_INLINE_MAX_TAG_WIDTH = 96; -const ESTIMATED_TAG_CHARACTER_WIDTH = 5.5; -const ESTIMATED_TAG_HORIZONTAL_CHROME = 16; -const COMPACT_ROW_NON_TITLE_CHROME = 102; -const TRAILING_META_WIDTH = 28; -const COMPACT_TITLE_MIN_WIDTH = 92; +const INLINE_MAX_TOTAL_LABEL_LENGTH = 18; export function resolveSessionTagPlacement(input: ResolveSessionTagPlacementInput): SessionTagPlacement { - if (input.hasRowActions) return 'below'; - return planSessionTagDisplay(input).placement; + void input; + return 'inline'; } export function planSessionTagDisplay(input: ResolveSessionTagPlacementInput): SessionTagDisplayPlan { @@ -49,57 +41,7 @@ export function planSessionTagDisplay(input: ResolveSessionTagPlacementInput): S }; } - if (input.density === 'minimal') { - return { - placement: 'inline', - chips: createBudgetedInlineTagChips(input.tags, NARROW_INLINE_MAX_TOTAL_LABEL_LENGTH), - }; - } - - const allChips = createTagChips(input.tags); - if (input.density === 'default') { - return { - placement: 'below', - chips: allChips, - }; - } - - const maxTotalLabelLength = input.hasLeadingIdentity === true - ? COMPACT_INLINE_MAX_TOTAL_LABEL_LENGTH_WITH_IDENTITY - : COMPACT_INLINE_MAX_TOTAL_LABEL_LENGTH; - if (getTotalLabelLength(input.tags) > maxTotalLabelLength) { - return { - placement: 'below', - chips: allChips, - }; - } - - const tagWidth = estimateInlineTagWidth(input.tags); - if (tagWidth > COMPACT_INLINE_MAX_TAG_WIDTH) { - return { - placement: 'below', - chips: allChips, - }; - } - - if (input.rowWidth == null) { - return { - placement: 'inline', - chips: allChips, - }; - } - - const trailingMetaWidth = input.hasTrailingMeta ? TRAILING_META_WIDTH : 0; - const availableTagWidth = input.rowWidth - COMPACT_ROW_NON_TITLE_CHROME - trailingMetaWidth - COMPACT_TITLE_MIN_WIDTH; - return tagWidth <= availableTagWidth - ? { - placement: 'inline', - chips: allChips, - } - : { - placement: 'below', - chips: allChips, - }; + return { placement: 'inline', chips: createBudgetedInlineTagChips(input.tags, INLINE_MAX_TOTAL_LABEL_LENGTH) }; } function createBudgetedInlineTagChips( @@ -135,10 +77,6 @@ function createBudgetedInlineTagChips( ]; } -function createTagChips(tags: readonly SessionTagPlacementChip[]): readonly SessionTagDisplayChip[] { - return tags.map((tag, index) => createTagChip(tag, index)); -} - function createTagChip(tag: SessionTagPlacementChip, index: number): SessionTagDisplayChip { return { key: tag.key ?? `${tag.label}:${index}`, @@ -146,16 +84,3 @@ function createTagChip(tag: SessionTagPlacementChip, index: number): SessionTagD isOverflow: false, }; } - -function getTotalLabelLength(tags: readonly SessionTagPlacementChip[]): number { - return tags.reduce((sum, tag) => sum + tag.label.length, 0); -} - -function estimateInlineTagWidth(tags: readonly SessionTagPlacementChip[]): number { - if (tags.length === 0) return 0; - const chipWidths = tags.map((tag) => - Math.ceil(tag.label.length * ESTIMATED_TAG_CHARACTER_WIDTH) + ESTIMATED_TAG_HORIZONTAL_CHROME - ); - const gapWidth = Math.max(0, tags.length - 1) * 4; - return chipWidths.reduce((sum, width) => sum + width, 0) + gapWidth; -} diff --git a/packages/agents/src/models.test.ts b/packages/agents/src/models.test.ts index c4eb5785c8..d48a8711ba 100644 --- a/packages/agents/src/models.test.ts +++ b/packages/agents/src/models.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from 'vitest'; import { AGENT_IDS } from './types.js'; import type { AgentId } from './types.js'; import { AGENT_MODEL_CONFIG, getAgentModelConfig, getAgentStaticModels } from './models.js'; +import { + resolveClaudeDefaultEffortLevelForModelId, + resolveClaudeEffortLevelsForModelId, +} from './providers/claude/effort.js'; import { CURRENT_FLAGSHIP_CLAUDE_MODEL_ID } from './providers/claude/flagshipModel.js'; const cursorAgentId = 'cursor' as AgentId; @@ -20,6 +24,17 @@ describe('agent model config', () => { expect(claudeModels.some((model) => model.id === CURRENT_FLAGSHIP_CLAUDE_MODEL_ID)).toBe(true); }); + it('keeps every effort-capable advertised Claude model fully registered', () => { + const noEffortSupport = new Set(['claude-haiku-4-5', 'claude-sonnet-4-5']); + + for (const model of getAgentStaticModels('claude')) { + if (noEffortSupport.has(model.id)) continue; + + expect(resolveClaudeEffortLevelsForModelId(model.id), model.id).not.toEqual([]); + expect(resolveClaudeDefaultEffortLevelForModelId(model.id), model.id).not.toBeNull(); + } + }); + it('uses the same name and description contract for static models as dynamic models', () => { const claude = getAgentModelConfig('claude'); const gemini = getAgentModelConfig('gemini'); @@ -58,6 +73,22 @@ describe('agent model config', () => { }), ]), }); + expect(claude.staticModels?.find((model) => model.id === 'claude-sonnet-5')).toMatchObject({ + id: 'claude-sonnet-5', + name: 'Sonnet 5', + description: expect.any(String), + contextWindowTokens: 1_000_000, + modelOptions: expect.arrayContaining([ + expect.objectContaining({ + id: 'reasoning_effort', + currentValue: 'high', + options: expect.arrayContaining([ + expect.objectContaining({ value: 'xhigh' }), + expect.objectContaining({ value: 'max' }), + ]), + }), + ]), + }); expect(claude.staticModels?.find((model) => model.id === 'claude-opus-4-8')).toMatchObject({ id: 'claude-opus-4-8', name: 'Opus 4.8', @@ -111,7 +142,7 @@ describe('agent model config', () => { const optionIdsFor = (modelId: string): string[] => claudeModels.find((model) => model.id === modelId)?.modelOptions?.map((option) => option.id) ?? []; - for (const modelId of ['claude-opus-5', 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7']) { + for (const modelId of ['claude-opus-5', 'claude-fable-5', 'claude-sonnet-5', 'claude-opus-4-8', 'claude-opus-4-7']) { expect(optionIdsFor(modelId)).toContain('ultracode'); const ultracode = claudeModels .find((model) => model.id === modelId)?.modelOptions?.find((option) => option.id === 'ultracode'); @@ -138,6 +169,7 @@ describe('agent model config', () => { // Always-1M on the API: no opt-in toggle surfaced. expect(variantFor('claude-opus-5')).toBeUndefined(); expect(variantFor('claude-fable-5')).toBeUndefined(); + expect(variantFor('claude-sonnet-5')).toBeUndefined(); expect(variantFor('claude-opus-4-8')).toBeUndefined(); expect(variantFor('claude-opus-4-7')).toBeUndefined(); expect(variantFor('claude-haiku-4-5')).toBeUndefined(); diff --git a/packages/agents/src/models.ts b/packages/agents/src/models.ts index af6b02a00d..cdd49b81e5 100644 --- a/packages/agents/src/models.ts +++ b/packages/agents/src/models.ts @@ -148,6 +148,12 @@ const CLAUDE_STATIC_MODELS = Object.freeze(([ description: 'Newest highest-capability generally available Claude model for the hardest coding and reasoning tasks.', contextWindowTokens: 1_000_000, }, + { + id: 'claude-sonnet-5', + name: 'Sonnet 5', + description: 'Balanced Claude 5 model for everyday coding, editing, and analysis at high capability.', + contextWindowTokens: 1_000_000, + }, { id: 'claude-opus-4-8', name: 'Opus 4.8', diff --git a/packages/agents/src/providers/claude/contextWindow.test.ts b/packages/agents/src/providers/claude/contextWindow.test.ts index cf26638b87..c00ef7296d 100644 --- a/packages/agents/src/providers/claude/contextWindow.test.ts +++ b/packages/agents/src/providers/claude/contextWindow.test.ts @@ -17,6 +17,7 @@ describe('claude 1m context facts', () => { it('marks 1M-capable models as supported (incl. [1m] variants)', () => { expect(isClaude1mContextSupportedModelId('claude-fable-5')).toBe(true); expect(isClaude1mContextSupportedModelId('claude-opus-5')).toBe(true); + expect(isClaude1mContextSupportedModelId('claude-sonnet-5')).toBe(true); expect(isClaude1mContextSupportedModelId('claude-opus-4-8')).toBe(true); expect(isClaude1mContextSupportedModelId('claude-opus-4-7')).toBe(true); expect(isClaude1mContextSupportedModelId('claude-opus-4-6')).toBe(true); @@ -34,6 +35,7 @@ describe('claude 1m context facts', () => { it('marks Fable 5 / Opus 4.8 / Opus 4.7 as always-1M (no opt-in toggle)', () => { expect(isClaude1mAlwaysOnModelId('claude-fable-5')).toBe(true); expect(isClaude1mAlwaysOnModelId('claude-opus-5')).toBe(true); + expect(isClaude1mAlwaysOnModelId('claude-sonnet-5')).toBe(true); expect(isClaude1mAlwaysOnModelId('claude-opus-4-8')).toBe(true); expect(isClaude1mAlwaysOnModelId('claude-opus-4-7')).toBe(true); expect(isClaude1mAlwaysOnModelId('claude-opus-4-6')).toBe(false); @@ -71,6 +73,7 @@ describe('resolveClaudeContextWindowTokensForModelId', () => { it('resolves 1M for always-1M models even with a BASE id (Unified hook/JSONL model is the base id)', () => { expect(resolveClaudeContextWindowTokensForModelId('claude-fable-5')).toBe(1_000_000); expect(resolveClaudeContextWindowTokensForModelId('claude-opus-5')).toBe(1_000_000); + expect(resolveClaudeContextWindowTokensForModelId('claude-sonnet-5')).toBe(1_000_000); expect(resolveClaudeContextWindowTokensForModelId('claude-opus-4-8')).toBe(1_000_000); expect(resolveClaudeContextWindowTokensForModelId('claude-opus-4-7')).toBe(1_000_000); }); diff --git a/packages/agents/src/providers/claude/contextWindow.ts b/packages/agents/src/providers/claude/contextWindow.ts index fdd97bed56..e8e466c98a 100644 --- a/packages/agents/src/providers/claude/contextWindow.ts +++ b/packages/agents/src/providers/claude/contextWindow.ts @@ -17,6 +17,7 @@ export const CLAUDE_1M_SUFFIX = '[1m]'; const CLAUDE_1M_CONTEXT_MODEL_IDS: ReadonlySet = new Set([ 'claude-opus-5', 'claude-fable-5', + 'claude-sonnet-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', @@ -26,6 +27,7 @@ const CLAUDE_1M_CONTEXT_MODEL_IDS: ReadonlySet = new Set([ const CLAUDE_1M_ALWAYS_ON_MODEL_IDS: ReadonlySet = new Set([ 'claude-opus-5', 'claude-fable-5', + 'claude-sonnet-5', 'claude-opus-4-8', 'claude-opus-4-7', ]); diff --git a/packages/agents/src/providers/claude/effort.test.ts b/packages/agents/src/providers/claude/effort.test.ts index eb3bd8cd18..dc0cc1d9f6 100644 --- a/packages/agents/src/providers/claude/effort.test.ts +++ b/packages/agents/src/providers/claude/effort.test.ts @@ -23,6 +23,13 @@ describe('claude effort support', () => { expect(resolveClaudeDefaultEffortLevelForModelId('claude-opus-5')).toBe('high'); }); + it('marks Sonnet 5 as effort+max capable with xhigh support and high default effort', () => { + expect(isClaudeEffortSupportedModelId('claude-sonnet-5')).toBe(true); + expect(isClaudeEffortMaxSupportedModelId('claude-sonnet-5')).toBe(true); + expect(resolveClaudeEffortLevelsForModelId('claude-sonnet-5')).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); + expect(resolveClaudeDefaultEffortLevelForModelId('claude-sonnet-5')).toBe('high'); + }); + it('marks Opus 4.8 as effort+max capable with xhigh support and high default effort', () => { expect(isClaudeEffortSupportedModelId('claude-opus-4-8')).toBe(true); expect(isClaudeEffortMaxSupportedModelId('claude-opus-4-8')).toBe(true); @@ -56,6 +63,7 @@ describe('claude effort support', () => { it('resolves effort levels for [1m]-suffixed model ids the same as the bare id (lookup-only strip)', () => { expect(resolveClaudeEffortLevelsForModelId('claude-fable-5[1m]')).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); + expect(resolveClaudeEffortLevelsForModelId('claude-sonnet-5[1m]')).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); expect(resolveClaudeEffortLevelsForModelId('claude-sonnet-4-6[1m]')).toEqual(['low', 'medium', 'high']); expect(resolveClaudeEffortLevelsForModelId('Claude-Opus-4-7[1M] ')).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); }); @@ -75,6 +83,7 @@ describe('claude ultracode support', () => { it('marks xhigh-capable models as ultracode-capable (incl. [1m] variants)', () => { expect(isClaudeUltracodeSupportedModelId('claude-fable-5')).toBe(true); expect(isClaudeUltracodeSupportedModelId('claude-opus-5')).toBe(true); + expect(isClaudeUltracodeSupportedModelId('claude-sonnet-5')).toBe(true); expect(isClaudeUltracodeSupportedModelId('claude-opus-4-8')).toBe(true); expect(isClaudeUltracodeSupportedModelId('claude-opus-4-7')).toBe(true); expect(isClaudeUltracodeSupportedModelId('claude-fable-5[1m]')).toBe(true); diff --git a/packages/agents/src/providers/claude/effort.ts b/packages/agents/src/providers/claude/effort.ts index a432694475..b834bf918a 100644 --- a/packages/agents/src/providers/claude/effort.ts +++ b/packages/agents/src/providers/claude/effort.ts @@ -5,6 +5,7 @@ export type ClaudeEffortLevel = (typeof CLAUDE_EFFORT_LEVELS)[number]; const CLAUDE_EFFORT_LEVELS_BY_MODEL_ID: ReadonlyMap = new Map([ ['claude-opus-5', ['low', 'medium', 'high', 'xhigh', 'max']], ['claude-fable-5', ['low', 'medium', 'high', 'xhigh', 'max']], + ['claude-sonnet-5', ['low', 'medium', 'high', 'xhigh', 'max']], ['claude-opus-4-8', ['low', 'medium', 'high', 'xhigh', 'max']], ['claude-opus-4-7', ['low', 'medium', 'high', 'xhigh', 'max']], ['claude-opus-4-6', ['low', 'medium', 'high', 'max']],