Merge remote-tracking branch 'origin/main' into worker-custody-at-creation

This commit is contained in:
Jinwoo-H
2026-09-08 14:09:15 -04:00
10 changed files with 166 additions and 173 deletions
@@ -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(<Harness />)
root.render(
<TooltipProvider>
<ActivityThreadOptionsMenu
compactMode={false}
hasUnreadThreads={false}
onCompactModeChange={onCompactModeChange}
/>
</TooltipProvider>
)
})
const trigger = container.querySelector<HTMLButtonElement>(
@@ -179,19 +188,20 @@ describe('ActivityThreadOptionsMenu', () => {
trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
})
const compactMode = document.querySelector<HTMLElement>('[role="menuitemcheckbox"]')
const compactMode = Array.from(
document.querySelectorAll<HTMLElement>('[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(
<TooltipProvider>
@@ -203,7 +213,7 @@ describe('ActivityThreadOptionsMenu', () => {
showSearch
onShowSearchChange={onShowSearchChange}
unreadOnly={false}
onToggleUnread={onToggleUnread}
onUnreadOnlyChange={onUnreadOnlyChange}
/>
</TooltipProvider>
)
@@ -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(
<TooltipProvider>
@@ -241,7 +251,7 @@ describe('ActivityThreadOptionsMenu', () => {
onCompactModeChange={vi.fn()}
onMarkAllThreadsRead={vi.fn()}
unreadOnly={false}
onToggleUnread={onToggleUnread}
onUnreadOnlyChange={onUnreadOnlyChange}
/>
</TooltipProvider>
)
@@ -254,15 +264,15 @@ describe('ActivityThreadOptionsMenu', () => {
trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))
})
const unreadItem = document.querySelector<HTMLElement>('[role="menuitemcheckbox"]')
const unreadItem = Array.from(
document.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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')
})
})
@@ -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 ? (
<DropdownMenuItem
onSelect={() => {
setAgentsVisibleHostIds(null)
setAgentsFilterRepoIds([])
}}
>
{translate(
'auto.components.activity.ActivityScopeFilterControls.resetScope',
'Show all hosts and projects'
)}
</DropdownMenuItem>
) : null}
{showHostScopeControls ? (
<SidebarHostScopeMenuSection
hostVisibilityLabel={getSidebarHostVisibilityLabel(agentsVisibleHostIds, hostOptions)}
@@ -58,7 +65,19 @@ export function ActivityScopeFilterMenuSections(): React.JSX.Element | null {
filterRepoIds={agentsFilterRepoIds}
setFilterRepoIds={setAgentsFilterRepoIds}
/>
<DropdownMenuSeparator />
{hasScopeFilter ? (
<DropdownMenuItem
onSelect={() => {
setAgentsVisibleHostIds(null)
setAgentsFilterRepoIds([])
}}
>
{translate(
'auto.components.activity.ActivityScopeFilterControls.resetScope',
'Show all hosts and projects'
)}
</DropdownMenuItem>
) : null}
</>
)
}
@@ -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 ? (
<DropdownMenuLabel>
{translate(
'auto.components.activity.ActivityPrototypePage.filtersSection',
'Filters'
)}
</DropdownMenuLabel>
{onUnreadOnlyChange ? (
<DropdownMenuCheckboxItem
checked={showSearch}
className={ALIGNED_CHECKBOX_ITEM_CLASS}
onCheckedChange={(checked) => {
skipCloseAutoFocusRef.current = checked === true
onShowSearchChange(checked === true)
}}
checked={unreadOnly}
onCheckedChange={(checked) => onUnreadOnlyChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
<Search className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.activity.ActivityPrototypePage.showSearch',
'Show search'
)}
</span>
{showSearch ? <Check className="size-3.5" /> : null}
{translate(
'auto.components.activity.ActivityPrototypePage.showUnreadOnly',
'Show unread only'
)}
</DropdownMenuCheckboxItem>
) : null}
{onToggleUnread ? (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuCheckboxItem
checked={unreadOnly}
className={ALIGNED_CHECKBOX_ITEM_CLASS}
onCheckedChange={() => onToggleUnread()}
onSelect={(event) => event.preventDefault()}
>
<ListChecks className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.activity.ActivityPrototypePage.showUnreadOnly',
'Show unread only'
)}
</span>
{unreadOnly ? <Check className="size-3.5" /> : null}
</DropdownMenuCheckboxItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate(
'auto.components.activity.ActivityPrototypePage.unreadOnlyDescription',
'Filters the activity list to show only threads with unread updates.'
)}
</TooltipContent>
</Tooltip>
{onShowChildAgentsChange ? (
<DropdownMenuCheckboxItem
checked={showChildAgents}
onCheckedChange={(checked) => onShowChildAgentsChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
{translate(
'auto.components.activity.ActivityPrototypePage.showChildAgents',
'Show child agents'
)}
</DropdownMenuCheckboxItem>
) : null}
<ActivityScopeFilterMenuItems />
<DropdownMenuSeparator />
</>
) : null}
<ActivityScopeFilterMenuSections />
<DropdownMenuLabel>
{translate('auto.components.activity.ActivityPrototypePage.viewSection', 'View')}
</DropdownMenuLabel>
{groupBy && onGroupByChange ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Layers className="size-3.5 text-muted-foreground" />
<span className="flex flex-1 items-center justify-between gap-2">
<span className="flex flex-1 items-center justify-between gap-3">
<span>
{translate(
'auto.components.activity.ActivityPrototypePage.770d458144',
'Group by'
)}
</span>
<span className="text-[11px] font-medium text-muted-foreground">
<span className="min-w-0 truncate text-[11px] font-medium text-muted-foreground">
{getActivityGroupByLabel(groupBy)}
</span>
</span>
@@ -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) => (
<DropdownMenuRadioItem
key={value}
value={value}
onSelect={(event) => event.preventDefault()}
>
{translate(key, label)}
{getActivityGroupByLabel(value)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuCheckboxItem
checked={compactMode}
className={ALIGNED_CHECKBOX_ITEM_CLASS}
onCheckedChange={(checked) => onCompactModeChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
<Rows3 className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.activity.ActivityPrototypePage.f70e4bec47',
'Compact mode'
)}
</span>
{compactMode ? <Check className="size-3.5" /> : null}
</DropdownMenuCheckboxItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate(
'auto.components.activity.ActivityPrototypePage.compactModeDescription',
'Shows shorter thread rows with one-line titles and two-line status messages.'
)}
</TooltipContent>
</Tooltip>
{onShowChildAgentsChange ? (
<DropdownMenuCheckboxItem
checked={compactMode}
onCheckedChange={(checked) => onCompactModeChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
{translate('auto.components.activity.ActivityPrototypePage.f70e4bec47', 'Compact mode')}
</DropdownMenuCheckboxItem>
{onShowSearchChange ? (
<DropdownMenuCheckboxItem
checked={showChildAgents}
className={ALIGNED_CHECKBOX_ITEM_CLASS}
onCheckedChange={(checked) => onShowChildAgentsChange(checked === true)}
onSelect={(event) => event.preventDefault()}
checked={showSearch}
onCheckedChange={(checked) => {
const show = checked === true
skipCloseAutoFocusRef.current = show
onShowSearchChange(show)
}}
>
<GitFork className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{translate(
'auto.components.activity.ActivityPrototypePage.showChildAgents',
'Show child agents'
)}
</span>
{showChildAgents ? <Check className="size-3.5" /> : null}
{translate('auto.components.activity.ActivityPrototypePage.showSearch', 'Show search')}
</DropdownMenuCheckboxItem>
) : null}
{onMarkAllThreadsRead || onClearCompleted ? (
@@ -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
)
+5 -5
View File
@@ -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",
+3 -3
View File
@@ -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"
+4
View File
@@ -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",
+3 -3
View File
@@ -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": "すべてのホストとプロジェクトを表示"
+3 -3
View File
@@ -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": "모든 호스트 및 프로젝트 표시"
+4 -3
View File
@@ -14266,8 +14266,6 @@
"5651b216c6": "未知项目",
"22b22034bc": "独立终端在活动中不可用。",
"afdc2139a8": "智能体终端关闭。在此工作区中打开一个新终端以继续。",
"compactModeDescription": "以单行标题和两行状态消息显示更短的线程行。",
"unreadOnlyDescription": "将活动列表筛选为仅显示有未读更新的线程。",
"clearCompleted": "清除已完成",
"none": "无",
"search": "搜索",
@@ -14285,8 +14283,11 @@
"idle": "空闲",
"unverifiable": "暂无近期更新",
"permission": "需注意"
}
},
"filtersSection": "筛选",
"viewSection": "视图"
},
"ActivityScopeFilterControls": {
"resetScope": "显示所有主机和项目"
},