diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx index 3f753f3d69d..6ba2b7906a4 100644 --- a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -167,9 +167,18 @@ describe('ActivityThreadOptionsMenu', () => { expect(document.body.textContent).toContain('Agent') }) - it('explains compact mode on hover', async () => { + it('updates compact mode without closing the menu', async () => { + const onCompactModeChange = vi.fn() await act(async () => { - root.render() + root.render( + + + + ) }) const trigger = container.querySelector( @@ -179,19 +188,20 @@ describe('ActivityThreadOptionsMenu', () => { trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) }) - const compactMode = document.querySelector('[role="menuitemcheckbox"]') + const compactMode = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).find((item) => item.textContent === 'Compact mode') await act(async () => { - compactMode?.dispatchEvent(new Event('pointermove', { bubbles: true })) + compactMode?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) }) - expect(document.body.textContent).toContain( - 'Shows shorter thread rows with one-line titles and two-line status messages.' - ) + expect(onCompactModeChange).toHaveBeenCalledWith(true) + expect(document.body.textContent).toContain('Compact mode') }) it('puts persisted search visibility and unread actions in the menu', async () => { const onShowSearchChange = vi.fn() - const onToggleUnread = vi.fn() + const onUnreadOnlyChange = vi.fn() await act(async () => { root.render( @@ -203,7 +213,7 @@ describe('ActivityThreadOptionsMenu', () => { showSearch onShowSearchChange={onShowSearchChange} unreadOnly={false} - onToggleUnread={onToggleUnread} + onUnreadOnlyChange={onUnreadOnlyChange} /> ) @@ -230,8 +240,8 @@ describe('ActivityThreadOptionsMenu', () => { expect(onShowSearchChange).toHaveBeenCalledWith(false) }) - it('explains show unread threads only on hover without a second unread state marker', async () => { - const onToggleUnread = vi.fn() + it('updates the unread filter without closing the menu', async () => { + const onUnreadOnlyChange = vi.fn() await act(async () => { root.render( @@ -241,7 +251,7 @@ describe('ActivityThreadOptionsMenu', () => { onCompactModeChange={vi.fn()} onMarkAllThreadsRead={vi.fn()} unreadOnly={false} - onToggleUnread={onToggleUnread} + onUnreadOnlyChange={onUnreadOnlyChange} /> ) @@ -254,15 +264,15 @@ describe('ActivityThreadOptionsMenu', () => { trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) }) - const unreadItem = document.querySelector('[role="menuitemcheckbox"]') + const unreadItem = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).find((item) => item.textContent === 'Show unread only') await act(async () => { - unreadItem?.dispatchEvent(new Event('pointermove', { bubbles: true })) + unreadItem?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) }) - expect(document.body.textContent).toContain( - 'Filters the activity list to show only threads with unread updates.' - ) - expect(document.querySelector('[data-unread-dot]')).toBeNull() + expect(onUnreadOnlyChange).toHaveBeenCalledWith(true) + expect(document.body.textContent).toContain('Show unread only') }) it('renders show child agents checkbox when onShowChildAgentsChange is provided', async () => { @@ -281,6 +291,14 @@ describe('ActivityThreadOptionsMenu', () => { trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) }) + const childAgentsItem = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).find((item) => item.textContent === 'Show child agents') + await act(async () => { + childAgentsItem?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + + expect(onShowChildAgentsChange).toHaveBeenCalledWith(true) expect(document.body.textContent).toContain('Show child agents') }) }) diff --git a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx index a9e75e10483..e90ec1f3322 100644 --- a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx +++ b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useAppStore } from '@/store' -import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' import SidebarRepositoryFilterSection from '@/components/sidebar/SidebarRepositoryFilterSection' import { SidebarHostScopeMenuSection } from '@/components/sidebar/SidebarHostScopeMenuSection' @@ -11,12 +11,30 @@ import { import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options' /** - * Host/project scope controls for the Agents activity surfaces. State is the - * persisted agents-view scope (agentsVisibleHostIds / agentsFilterRepoIds), - * deliberately separate from the workspace-nav filters. + * Whether {@link ActivityScopeFilterMenuItems} renders anything. + * Why exported: the parent owns the Filters label and separator, so it has to + * know whether the section would be empty. */ -export function ActivityScopeFilterMenuSections(): React.JSX.Element | null { +export function useActivityScopeFilterMenuItemsVisible(): boolean { const repos = useAppStore((s) => s.repos) + const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) + const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) + const { hostOptions } = useSidebarHostScopeOptions() + return ( + agentsVisibleHostIds !== null || + agentsFilterRepoIds.length > 0 || + shouldShowHostScopeControls(hostOptions) || + repos.length > 1 + ) +} + +/** + * Host/project scope items for the Agents activity surfaces. State is the + * persisted agents-view scope (agentsVisibleHostIds / agentsFilterRepoIds), + * deliberately separate from the workspace-nav filters. The parent owns the + * Filters label and separator. + */ +export function ActivityScopeFilterMenuItems(): React.JSX.Element | null { const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) const setAgentsVisibleHostIds = useAppStore((s) => s.setAgentsVisibleHostIds) const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) @@ -24,25 +42,14 @@ export function ActivityScopeFilterMenuSections(): React.JSX.Element | null { const { hostOptions } = useSidebarHostScopeOptions() const showHostScopeControls = shouldShowHostScopeControls(hostOptions) const hasScopeFilter = agentsVisibleHostIds !== null || agentsFilterRepoIds.length > 0 + const visible = useActivityScopeFilterMenuItemsVisible() - if (!hasScopeFilter && !showHostScopeControls && repos.length <= 1) { + if (!visible) { return null } + return ( <> - {hasScopeFilter ? ( - { - setAgentsVisibleHostIds(null) - setAgentsFilterRepoIds([]) - }} - > - {translate( - 'auto.components.activity.ActivityScopeFilterControls.resetScope', - 'Show all hosts and projects' - )} - - ) : null} {showHostScopeControls ? ( - + {hasScopeFilter ? ( + { + setAgentsVisibleHostIds(null) + setAgentsFilterRepoIds([]) + }} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.resetScope', + 'Show all hosts and projects' + )} + + ) : null} ) } diff --git a/src/renderer/src/components/activity/activity-thread-options-menu.tsx b/src/renderer/src/components/activity/activity-thread-options-menu.tsx index bd398986bd3..5a9bd3bc59c 100644 --- a/src/renderer/src/components/activity/activity-thread-options-menu.tsx +++ b/src/renderer/src/components/activity/activity-thread-options-menu.tsx @@ -1,21 +1,12 @@ import React from 'react' -import { - Check, - CheckCheck, - GitFork, - Layers, - ListChecks, - ListFilter, - Rows3, - Search, - Trash2 -} from 'lucide-react' +import { CheckCheck, ListFilter, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, @@ -27,12 +18,19 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { - ActivityScopeFilterMenuSections, - useActivityScopeFilterActive + ActivityScopeFilterMenuItems, + useActivityScopeFilterActive, + useActivityScopeFilterMenuItemsVisible } from './activity-scope-filter-controls' import type { ActivityGroupBy } from './activity-thread-types' -const ALIGNED_CHECKBOX_ITEM_CLASS = 'pl-2 [&>span.absolute]:hidden' +const GROUP_BY_OPTIONS = [ + 'none', + 'status', + 'project', + 'worktree', + 'agent' +] as const satisfies readonly ActivityGroupBy[] function getActivityGroupByLabel(groupBy: ActivityGroupBy): string { switch (groupBy) { @@ -63,7 +61,7 @@ export function ActivityThreadOptionsMenu({ showSearch = false, onShowSearchChange, unreadOnly = false, - onToggleUnread + onUnreadOnlyChange }: { groupBy?: ActivityGroupBy onGroupByChange?: (groupBy: ActivityGroupBy) => void @@ -78,10 +76,14 @@ export function ActivityThreadOptionsMenu({ showSearch?: boolean onShowSearchChange?: (showSearch: boolean) => void unreadOnly?: boolean - onToggleUnread?: () => void + onUnreadOnlyChange?: (unreadOnly: boolean) => void }): React.JSX.Element { const skipCloseAutoFocusRef = React.useRef(false) const scopeFilterActive = useActivityScopeFilterActive() + const scopeFilterItemsVisible = useActivityScopeFilterMenuItemsVisible() + const hasFilters = Boolean( + onUnreadOnlyChange || onShowChildAgentsChange || scopeFilterItemsVisible + ) const optionsLabel = scopeFilterActive ? translate( 'auto.components.activity.ActivityPrototypePage.threadListOptionsFiltered', @@ -126,7 +128,7 @@ export function ActivityThreadOptionsMenu({ side="right" align="start" sideOffset={8} - className="w-56" + className="w-60" onCloseAutoFocus={(event) => { if (skipCloseAutoFocusRef.current) { event.preventDefault() @@ -134,70 +136,56 @@ export function ActivityThreadOptionsMenu({ } }} > - {onShowSearchChange || onToggleUnread ? ( + {hasFilters ? ( <> - {onShowSearchChange ? ( + + {translate( + 'auto.components.activity.ActivityPrototypePage.filtersSection', + 'Filters' + )} + + {onUnreadOnlyChange ? ( { - skipCloseAutoFocusRef.current = checked === true - onShowSearchChange(checked === true) - }} + checked={unreadOnly} + onCheckedChange={(checked) => onUnreadOnlyChange(checked === true)} + onSelect={(event) => event.preventDefault()} > - - - {translate( - 'auto.components.activity.ActivityPrototypePage.showSearch', - 'Show search' - )} - - {showSearch ? : null} + {translate( + 'auto.components.activity.ActivityPrototypePage.showUnreadOnly', + 'Show unread only' + )} ) : null} - {onToggleUnread ? ( - - - onToggleUnread()} - onSelect={(event) => event.preventDefault()} - > - - - {translate( - 'auto.components.activity.ActivityPrototypePage.showUnreadOnly', - 'Show unread only' - )} - - {unreadOnly ? : null} - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.unreadOnlyDescription', - 'Filters the activity list to show only threads with unread updates.' - )} - - + {onShowChildAgentsChange ? ( + onShowChildAgentsChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityPrototypePage.showChildAgents', + 'Show child agents' + )} + ) : null} + ) : null} - + + {translate('auto.components.activity.ActivityPrototypePage.viewSection', 'View')} + {groupBy && onGroupByChange ? ( - - + {translate( 'auto.components.activity.ActivityPrototypePage.770d458144', 'Group by' )} - + {getActivityGroupByLabel(groupBy)} @@ -207,73 +195,36 @@ export function ActivityThreadOptionsMenu({ value={groupBy} onValueChange={(value) => onGroupByChange(value as ActivityGroupBy)} > - {[ - ['none', 'None', 'auto.components.activity.ActivityPrototypePage.none'], - ['status', 'Status', 'auto.components.activity.ActivityPrototypePage.4a3986b200'], - [ - 'project', - 'Project', - 'auto.components.activity.ActivityPrototypePage.8c3b621ddf' - ], - [ - 'worktree', - 'Worktree', - 'auto.components.activity.ActivityPrototypePage.b29191b3e0' - ], - ['agent', 'Agent', 'auto.components.activity.ActivityPrototypePage.f6396e1f85'] - ].map(([value, label, key]) => ( + {GROUP_BY_OPTIONS.map((value) => ( event.preventDefault()} > - {translate(key, label)} + {getActivityGroupByLabel(value)} ))} ) : null} - - - onCompactModeChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - - - {translate( - 'auto.components.activity.ActivityPrototypePage.f70e4bec47', - 'Compact mode' - )} - - {compactMode ? : null} - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.compactModeDescription', - 'Shows shorter thread rows with one-line titles and two-line status messages.' - )} - - - {onShowChildAgentsChange ? ( + onCompactModeChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate('auto.components.activity.ActivityPrototypePage.f70e4bec47', 'Compact mode')} + + {onShowSearchChange ? ( onShowChildAgentsChange(checked === true)} - onSelect={(event) => event.preventDefault()} + checked={showSearch} + onCheckedChange={(checked) => { + const show = checked === true + skipCloseAutoFocusRef.current = show + onShowSearchChange(show) + }} > - - - {translate( - 'auto.components.activity.ActivityPrototypePage.showChildAgents', - 'Show child agents' - )} - - {showChildAgents ? : null} + {translate('auto.components.activity.ActivityPrototypePage.showSearch', 'Show search')} ) : null} {onMarkAllThreadsRead || onClearCompleted ? ( diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index 3f22d7fc2da..cd782457d64 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -182,7 +182,7 @@ export default function SidebarAgentsList({ showSearch={showSearch} onShowSearchChange={handleShowSearchChange} unreadOnly={readFilter === 'unread'} - onToggleUnread={() => setReadFilter(readFilter === 'unread' ? 'all' : 'unread')} + onUnreadOnlyChange={(unreadOnly) => setReadFilter(unreadOnly ? 'unread' : 'all')} />, optionsTarget ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8e6d862e3b5..fb8e84b7ef7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16244,8 +16244,6 @@ "5651b216c6": "Unknown project", "22b22034bc": "Standalone terminal unavailable in Activity.", "afdc2139a8": "Agent terminal closed. Open a new terminal in this workspace to continue.", - "compactModeDescription": "Shows shorter thread rows with one-line titles and two-line status messages.", - "unreadOnlyDescription": "Filters the activity list to show only threads with unread updates.", "clearCompleted": "Clear completed", "none": "None", "search": "Search", @@ -16265,7 +16263,9 @@ "idle": "Idle", "unverifiable": "No recent update", "permission": "Needs attention" - } + }, + "filtersSection": "Filters", + "viewSection": "View" }, "clearCompleted": { "clearedOne": "Cleared 1 completed agent", @@ -16282,8 +16282,8 @@ "dc708f3eff": "Close agents" }, "ActivityScopeFilterControls": { - "resetScope": "Show all hosts and projects", - "hiddenCount": "{{value0}} hidden" + "hiddenCount": "{{value0}} hidden", + "resetScope": "Show all hosts and projects" }, "ActivityThreadHoverCard": { "pathCopied": "Path copied to clipboard", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f1539887478..2ee1716ea20 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14188,8 +14188,6 @@ "5651b216c6": "Proyecto desconocido", "22b22034bc": "Terminal independiente no disponible en Actividad.", "afdc2139a8": "Terminal de Agent cerrada. Abre una nueva terminal en este workspace para continuar.", - "compactModeDescription": "Muestra filas de hilo más cortas con títulos de una línea y mensajes de estado de dos líneas.", - "unreadOnlyDescription": "Filtra la lista de actividad para mostrar solo hilos con actualizaciones sin leer.", "clearCompleted": "Borrar completados", "none": "Ninguno", "search": "Buscar", @@ -14207,7 +14205,9 @@ "idle": "Inactivo", "unverifiable": "Sin actualizaciones recientes", "permission": "Requiere atención" - } + }, + "filtersSection": "Filtros", + "viewSection": "Vista" }, "ActivityScopeFilterControls": { "resetScope": "Mostrar todos los hosts y proyectos" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0c5f01f067e..6113d606c90 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -15464,6 +15464,10 @@ "4616ea39fd": "Aller à l'espace de travail", "threadListOptionsFiltered": "Options de la liste des fils, filtres actifs", "showSearch": "Afficher la recherche", + "showUnreadOnly": "Afficher uniquement les fils non lus", + "showChildAgents": "Afficher les agents enfants", + "filtersSection": "Filtres", + "viewSection": "Affichage", "59b131fbd9": "Marquer le fil comme non lu", "beb2c19173": "Non lus", "5651b216c6": "Projet inconnu", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cd9c24ea7d8..f0c1666d911 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14188,8 +14188,6 @@ "5651b216c6": "不明なプロジェクト", "22b22034bc": "スタンドアロンターミナルはアクティビティでは使用できません。", "afdc2139a8": "Agent ターミナルが閉じられました。続行するには、このワークスペースで新規ターミナルを開いてください。", - "compactModeDescription": "1 行のタイトルと 2 行のステータスメッセージで短いスレッド行を表示します。", - "unreadOnlyDescription": "未読の更新があるスレッドのみをアクティビティ一覧に表示します。", "clearCompleted": "完了済みをクリア", "none": "なし", "search": "検索", @@ -14207,7 +14205,9 @@ "idle": "アイドル", "unverifiable": "最近の更新なし", "permission": "要対応" - } + }, + "filtersSection": "フィルター", + "viewSection": "表示" }, "ActivityScopeFilterControls": { "resetScope": "すべてのホストとプロジェクトを表示" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 76d778c1550..42983088062 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14266,8 +14266,6 @@ "5651b216c6": "알 수 없는 프로젝트", "22b22034bc": "활동에서는 독립형 terminal을 사용할 수 없습니다.", "afdc2139a8": "Agent terminal이 닫혔습니다. 계속하려면 이 워크스페이스에서 새 terminal을 여세요.", - "compactModeDescription": "한 줄 제목과 두 줄 상태 메시지로 더 짧은 스레드 행을 표시합니다.", - "unreadOnlyDescription": "읽지 않은 업데이트가 있는 스레드만 활동 목록에 표시합니다.", "clearCompleted": "완료된 항목 지우기", "none": "없음", "search": "검색", @@ -14285,7 +14283,9 @@ "idle": "유휴", "unverifiable": "최근 업데이트 없음", "permission": "주의 필요" - } + }, + "filtersSection": "필터", + "viewSection": "보기" }, "ActivityScopeFilterControls": { "resetScope": "모든 호스트 및 프로젝트 표시" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a267853a78c..09ca1689ea3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14266,8 +14266,6 @@ "5651b216c6": "未知项目", "22b22034bc": "独立终端在活动中不可用。", "afdc2139a8": "智能体终端关闭。在此工作区中打开一个新终端以继续。", - "compactModeDescription": "以单行标题和两行状态消息显示更短的线程行。", - "unreadOnlyDescription": "将活动列表筛选为仅显示有未读更新的线程。", "clearCompleted": "清除已完成", "none": "无", "search": "搜索", @@ -14285,8 +14283,11 @@ "idle": "空闲", "unverifiable": "暂无近期更新", "permission": "需注意" - } + }, + "filtersSection": "筛选", + "viewSection": "视图" }, + "ActivityScopeFilterControls": { "resetScope": "显示所有主机和项目" },