@@ -101,6 +109,15 @@ export function WorkspaceCleanupFilterBar({
)}
+ {/* Why out here and not only in the popover: a filter narrowing the list must be
+ clearable without first discovering which panel it lives in. */}
+ {hasActiveFilters ? (
+
+ ) : null}
+
{gitEvidence.pendingCount > 0 ? (
@@ -111,6 +128,8 @@ export function WorkspaceCleanupFilterBar({
)}
) : null}
+
+
)
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c117b08e172..3600bbeb99b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -16234,7 +16234,53 @@
"selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.",
"selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.",
"selectAllCountOne": "Select 1 safety-checked workspace",
- "selectAllCount": "Select all {{value0}} safety-checked workspaces"
+ "selectAllCount": "Select all {{value0}} safety-checked workspaces",
+ "appliedFilters": "Applied filters",
+ "removeFilter": "Remove filter {{value0}}",
+ "chip": {
+ "idleDays": "Idle {{value0}}d+",
+ "neverVisited": "Never visited",
+ "minSize": "At least {{value0}} MB",
+ "maxSize": "At most {{value0}} MB",
+ "excludesUnsized": "Measured only",
+ "excludesStatusless": "Has a status",
+ "list": "{{value0}}: {{value1}}",
+ "triState": "{{value0}}: {{value1}}",
+ "only": "Only",
+ "exclude": "Excluded",
+ "minAhead": "Ahead {{value0}}+",
+ "minBehind": "Behind {{value0}}+",
+ "branchQuery": "Branch: {{value0}}",
+ "pathPrefix": "Path: {{value0}}",
+ "presence": "{{value0}}: {{value1}}",
+ "has": "Has",
+ "none": "None",
+ "completelyEmpty": "Nothing to lose",
+ "selectableOnly": "Deletable only",
+ "kind": {
+ "status": "Status",
+ "agent": "Agent",
+ "git": "Git",
+ "review": "Review",
+ "reviewState": "Review state",
+ "reviewProvider": "Provider",
+ "ticket": "Ticket",
+ "ticketSource": "Ticket source",
+ "context": "Context",
+ "host": "Host",
+ "repo": "Repo",
+ "blocker": "Blocker",
+ "tier": "Safety",
+ "dismissed": "Ignored",
+ "archived": "Archived",
+ "pinned": "Pinned",
+ "unread": "Unread",
+ "comment": "Comment",
+ "prunable": "Prunable",
+ "locked": "Locked",
+ "retainedAgents": "Finished agents"
+ }
+ }
},
"scan": {
"progress": "Scanning workspaces ({{value0}}/{{value1}})",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2384a7996fc..6164f1707bd 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -14566,6 +14566,54 @@
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
+ },
+ "browse": {
+ "appliedFilters": "적용된 필터",
+ "removeFilter": "{{value0}} 필터 제거",
+ "chip": {
+ "idleDays": "{{value0}}일 이상 유휴",
+ "neverVisited": "방문한 적 없음",
+ "minSize": "{{value0}} MB 이상",
+ "maxSize": "{{value0}} MB 이하",
+ "excludesUnsized": "측정된 항목만",
+ "excludesStatusless": "상태 있음",
+ "list": "{{value0}}: {{value1}}",
+ "triState": "{{value0}}: {{value1}}",
+ "only": "만",
+ "exclude": "제외",
+ "minAhead": "{{value0}}개 이상 앞섬",
+ "minBehind": "{{value0}}개 이상 뒤처짐",
+ "branchQuery": "브랜치: {{value0}}",
+ "pathPrefix": "경로: {{value0}}",
+ "presence": "{{value0}}: {{value1}}",
+ "has": "있음",
+ "none": "없음",
+ "completelyEmpty": "잃을 것 없음",
+ "selectableOnly": "삭제 가능한 항목만",
+ "kind": {
+ "status": "상태",
+ "agent": "에이전트",
+ "git": "Git",
+ "review": "리뷰",
+ "reviewState": "리뷰 상태",
+ "reviewProvider": "제공자",
+ "ticket": "티켓",
+ "ticketSource": "티켓 소스",
+ "context": "컨텍스트",
+ "host": "호스트",
+ "repo": "저장소",
+ "blocker": "차단 요인",
+ "tier": "안전",
+ "dismissed": "무시됨",
+ "archived": "보관됨",
+ "pinned": "고정됨",
+ "unread": "읽지 않음",
+ "comment": "댓글",
+ "prunable": "정리 가능",
+ "locked": "잠김",
+ "retainedAgents": "완료된 에이전트"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index fdd53f16ed9..84c05074bb5 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -14557,6 +14557,54 @@
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
+ },
+ "browse": {
+ "appliedFilters": "已应用的筛选",
+ "removeFilter": "移除筛选 {{value0}}",
+ "chip": {
+ "idleDays": "闲置 {{value0}} 天以上",
+ "neverVisited": "从未访问",
+ "minSize": "至少 {{value0}} MB",
+ "maxSize": "最多 {{value0}} MB",
+ "excludesUnsized": "仅已测量",
+ "excludesStatusless": "有状态",
+ "list": "{{value0}}:{{value1}}",
+ "triState": "{{value0}}:{{value1}}",
+ "only": "仅",
+ "exclude": "排除",
+ "minAhead": "领先 {{value0}} 个以上",
+ "minBehind": "落后 {{value0}} 个以上",
+ "branchQuery": "分支:{{value0}}",
+ "pathPrefix": "路径:{{value0}}",
+ "presence": "{{value0}}:{{value1}}",
+ "has": "有",
+ "none": "无",
+ "completelyEmpty": "没有可丢失的内容",
+ "selectableOnly": "仅可删除",
+ "kind": {
+ "status": "状态",
+ "agent": "代理",
+ "git": "Git",
+ "review": "审查",
+ "reviewState": "审查状态",
+ "reviewProvider": "提供方",
+ "ticket": "工单",
+ "ticketSource": "工单来源",
+ "context": "上下文",
+ "host": "主机",
+ "repo": "仓库",
+ "blocker": "阻塞项",
+ "tier": "安全",
+ "dismissed": "已忽略",
+ "archived": "已归档",
+ "pinned": "已固定",
+ "unread": "未读",
+ "comment": "评论",
+ "prunable": "可修剪",
+ "locked": "已锁定",
+ "retainedAgents": "已完成的代理"
+ }
+ }
}
}
},
diff --git a/src/shared/workspace-cleanup-applied-filters.test.ts b/src/shared/workspace-cleanup-applied-filters.test.ts
new file mode 100644
index 00000000000..9cd9fd4afb8
--- /dev/null
+++ b/src/shared/workspace-cleanup-applied-filters.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest'
+import { createDefaultWorkspaceCleanupFilterState } from './workspace-cleanup-filter-model'
+import { listAppliedWorkspaceCleanupFilters } from './workspace-cleanup-applied-filters'
+
+const FORMAT = {
+ idleDays: (d: number) => `Idle >= ${d}d`,
+ neverVisited: () => 'Never visited',
+ minSize: (b: number) => `At least ${b}B`,
+ maxSize: (b: number) => `At most ${b}B`,
+ excludesUnsized: () => 'Excludes unmeasured',
+ excludesStatusless: () => 'Excludes statusless',
+ list: (kind: string, count: number) => `${kind}:${count}`,
+ triState: (kind: string, mode: string) => `${kind}:${mode}`,
+ minAhead: (n: number) => `Ahead >= ${n}`,
+ minBehind: (n: number) => `Behind >= ${n}`,
+ branchQuery: (v: string) => `Branch ~ ${v}`,
+ pathPrefix: (v: string) => `Path ~ ${v}`,
+ presence: (kind: string, mode: string) => `${kind}:${mode}`,
+ completelyEmpty: () => 'Nothing to lose',
+ selectableOnly: () => 'Deletable only'
+}
+
+const list = (f: ReturnType
) =>
+ listAppliedWorkspaceCleanupFilters(f, FORMAT)
+
+describe('listAppliedWorkspaceCleanupFilters', () => {
+ it('shows nothing for a default profile', () => {
+ expect(list(createDefaultWorkspaceCleanupFilterState())).toEqual([])
+ })
+
+ it('names the reported stuck filter rather than its group', () => {
+ // The bar always read "Showing 546 of 799"; what was missing was which filter.
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+
+ const applied = list(filters)
+
+ expect(applied).toHaveLength(1)
+ expect(applied[0].label).toBe('Idle >= 20d')
+ })
+
+ it('clears one constraint and leaves the rest of its own group alone', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+ filters.activity.neverVisited = true
+
+ const idle = list(filters).find((a) => a.id === 'activity.idleMinDays')
+ const next = idle!.clear(filters)
+
+ expect(next.activity.idleMinDays).toBeNull()
+ expect(next.activity.neverVisited).toBe(true)
+ })
+
+ it('never mutates the filters it is handed', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.location.pathPrefix = '/repos'
+
+ list(filters)[0].clear(filters)
+
+ expect(filters.location.pathPrefix).toBe('/repos')
+ })
+
+ describe('numeric zero', () => {
+ it('hides an inert zero minimum, which matches every row', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 0
+ filters.size.minBytes = 0
+ filters.git.minAhead = 0
+ filters.git.minBehind = 0
+
+ expect(list(filters)).toEqual([])
+ })
+
+ it('shows a zero maximum, which hides every measured non-empty workspace', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.size.maxBytes = 0
+
+ expect(list(filters).map((a) => a.id)).toEqual(['size.maxBytes'])
+ })
+ })
+
+ it('ignores whitespace-only text', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.git.branchQuery = ' '
+ filters.location.pathPrefix = ' '
+
+ expect(list(filters)).toEqual([])
+ })
+
+ it('ignores parameter fields that constrain nothing on their own', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleSignal = 'created'
+ filters.safety.blockerMode = 'any-of'
+
+ expect(list(filters)).toEqual([])
+ })
+
+ it('shows the permissive booleans only when switched off', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.size.includeUnsized = false
+ filters.status.matchStatusless = false
+
+ expect(list(filters).map((a) => a.id).sort()).toEqual([
+ 'size.includeUnsized',
+ 'status.matchStatusless'
+ ])
+ })
+
+ it('gives every constraint a unique, stable id', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+ filters.size.maxBytes = 500
+ filters.status.archived = 'only'
+ filters.git.states = ['dirty']
+ filters.git.locked = 'exclude'
+ filters.location.repoIds = ['repo-1']
+ filters.safety.dismissed = 'only'
+
+ const ids = list(filters).map((a) => a.id)
+
+ expect(new Set(ids).size).toBe(ids.length)
+ expect(ids).toContain('status.archived')
+ expect(ids).toContain('git.locked')
+ })
+
+ it('clearing every chip in turn returns the profile to no constraints', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+ filters.size.maxBytes = 0
+ filters.size.includeUnsized = false
+ filters.status.matchStatusless = false
+ filters.git.branchQuery = 'release'
+ filters.location.pathPrefix = '/repos'
+ filters.safety.selectableOnly = true
+
+ let next = filters
+ for (const applied of list(filters)) {
+ next = applied.clear(next)
+ }
+
+ expect(list(next)).toEqual([])
+ })
+
+ it('leaves the search query alone, which chips do not own', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.query = 'release'
+ filters.activity.idleMinDays = 20
+
+ const next = list(filters)[0].clear(filters)
+
+ expect(next.query).toBe('release')
+ })
+})
diff --git a/src/shared/workspace-cleanup-applied-filters.ts b/src/shared/workspace-cleanup-applied-filters.ts
new file mode 100644
index 00000000000..449a55f088f
--- /dev/null
+++ b/src/shared/workspace-cleanup-applied-filters.ts
@@ -0,0 +1,255 @@
+import {
+ createDefaultWorkspaceCleanupFilterState,
+ type WorkspaceCleanupFilterState
+} from './workspace-cleanup-filter-model'
+
+/**
+ * One constraint the user can see and remove from the filter bar.
+ *
+ * Deliberately per-field, not per-group: "Activity" tells a reader nothing, while
+ * "Idle >= 20d" names the thing that is hiding their workspaces. That naming *is* the
+ * fix — the reported defect was never that the effect was invisible (the bar always
+ * read "Showing 546 of 799"), it was that the cause was.
+ */
+export type WorkspaceCleanupAppliedFilter = {
+ /** Stable across renders and unique per constraint. */
+ id: string
+ /** Rendered on the chip. Already localized by the caller-supplied formatter. */
+ label: string
+ /** Returns `filters` with just this constraint reset to its default. */
+ clear: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
+}
+
+type Formatters = {
+ idleDays: (days: number) => string
+ neverVisited: () => string
+ minSize: (bytes: number) => string
+ maxSize: (bytes: number) => string
+ excludesUnsized: () => string
+ excludesStatusless: () => string
+ list: (kind: string, count: number) => string
+ triState: (kind: string, mode: 'only' | 'exclude') => string
+ minAhead: (count: number) => string
+ minBehind: (count: number) => string
+ branchQuery: (value: string) => string
+ pathPrefix: (value: string) => string
+ presence: (kind: string, mode: string) => string
+ completelyEmpty: () => string
+ selectableOnly: () => string
+}
+
+/**
+ * Every constraint currently narrowing the fleet, in panel order.
+ *
+ * A field counts only when its matcher actually narrows. That is why minimums count
+ * above zero but `size.maxBytes` counts *at* zero: a `0` minimum matches every row,
+ * while "at most 0 MB" hides every measured non-empty workspace.
+ */
+export function listAppliedWorkspaceCleanupFilters(
+ filters: WorkspaceCleanupFilterState,
+ format: Formatters
+): WorkspaceCleanupAppliedFilter[] {
+ const defaults = createDefaultWorkspaceCleanupFilterState()
+ const applied: WorkspaceCleanupAppliedFilter[] = []
+ const add = (
+ id: string,
+ label: string,
+ clear: (f: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
+ ): void => {
+ applied.push({ id, label, clear })
+ }
+
+ const { activity, size, status, agent, git, review, ticket, context, location, safety } = filters
+
+ if (activity.idleMinDays !== null && activity.idleMinDays > 0) {
+ add('activity.idleMinDays', format.idleDays(activity.idleMinDays), (f) => ({
+ ...f,
+ activity: { ...f.activity, idleMinDays: null }
+ }))
+ }
+ if (activity.neverVisited) {
+ add('activity.neverVisited', format.neverVisited(), (f) => ({
+ ...f,
+ activity: { ...f.activity, neverVisited: false }
+ }))
+ }
+
+ if (size.minBytes !== null && size.minBytes > 0) {
+ add('size.minBytes', format.minSize(size.minBytes), (f) => ({
+ ...f,
+ size: { ...f.size, minBytes: null }
+ }))
+ }
+ if (size.maxBytes !== null) {
+ add('size.maxBytes', format.maxSize(size.maxBytes), (f) => ({
+ ...f,
+ size: { ...f.size, maxBytes: null }
+ }))
+ }
+ if (!size.includeUnsized) {
+ add('size.includeUnsized', format.excludesUnsized(), (f) => ({
+ ...f,
+ size: { ...f.size, includeUnsized: true }
+ }))
+ }
+
+ if (status.workspaceStatuses.length > 0) {
+ add('status.workspaceStatuses', format.list('status', status.workspaceStatuses.length), (f) => ({
+ ...f,
+ status: { ...f.status, workspaceStatuses: [] }
+ }))
+ }
+ if (!status.matchStatusless) {
+ add('status.matchStatusless', format.excludesStatusless(), (f) => ({
+ ...f,
+ status: { ...f.status, matchStatusless: true }
+ }))
+ }
+ for (const key of ['archived', 'pinned', 'unread', 'comment'] as const) {
+ const value = status[key]
+ if (value !== 'any') {
+ add(`status.${key}`, format.triState(key, value), (f) => ({
+ ...f,
+ status: { ...f.status, [key]: 'any' }
+ }))
+ }
+ }
+
+ if (agent.states.length > 0) {
+ add('agent.states', format.list('agent', agent.states.length), (f) => ({
+ ...f,
+ agent: { ...f.agent, states: [] }
+ }))
+ }
+ if (agent.retainedDoneAgents !== 'any') {
+ add('agent.retainedDoneAgents', format.triState('retainedAgents', agent.retainedDoneAgents), (f) => ({
+ ...f,
+ agent: { ...f.agent, retainedDoneAgents: 'any' }
+ }))
+ }
+
+ if (git.states.length > 0) {
+ add('git.states', format.list('git', git.states.length), (f) => ({
+ ...f,
+ git: { ...f.git, states: [] }
+ }))
+ }
+ if (git.minAhead !== null && git.minAhead > 0) {
+ add('git.minAhead', format.minAhead(git.minAhead), (f) => ({
+ ...f,
+ git: { ...f.git, minAhead: null }
+ }))
+ }
+ if (git.minBehind !== null && git.minBehind > 0) {
+ add('git.minBehind', format.minBehind(git.minBehind), (f) => ({
+ ...f,
+ git: { ...f.git, minBehind: null }
+ }))
+ }
+ if (git.branchQuery.trim().length > 0) {
+ add('git.branchQuery', format.branchQuery(git.branchQuery.trim()), (f) => ({
+ ...f,
+ git: { ...f.git, branchQuery: '' }
+ }))
+ }
+ for (const key of ['prunable', 'locked'] as const) {
+ const value = git[key]
+ if (value !== 'any') {
+ add(`git.${key}`, format.triState(key, value), (f) => ({
+ ...f,
+ git: { ...f.git, [key]: 'any' }
+ }))
+ }
+ }
+
+ if (review.presence !== 'any') {
+ add('review.presence', format.presence('review', review.presence), (f) => ({
+ ...f,
+ review: { ...f.review, presence: defaults.review.presence }
+ }))
+ }
+ if (review.states.length > 0) {
+ add('review.states', format.list('reviewState', review.states.length), (f) => ({
+ ...f,
+ review: { ...f.review, states: [] }
+ }))
+ }
+ if (review.providers.length > 0) {
+ add('review.providers', format.list('reviewProvider', review.providers.length), (f) => ({
+ ...f,
+ review: { ...f.review, providers: [] }
+ }))
+ }
+
+ if (ticket.presence !== 'any') {
+ add('ticket.presence', format.presence('ticket', ticket.presence), (f) => ({
+ ...f,
+ ticket: { ...f.ticket, presence: defaults.ticket.presence }
+ }))
+ }
+ if (ticket.sources.length > 0) {
+ add('ticket.sources', format.list('ticketSource', ticket.sources.length), (f) => ({
+ ...f,
+ ticket: { ...f.ticket, sources: [] }
+ }))
+ }
+
+ if (context.presence !== 'any') {
+ add('context.presence', format.presence('context', context.presence), (f) => ({
+ ...f,
+ context: { ...f.context, presence: defaults.context.presence }
+ }))
+ }
+ if (context.completelyEmpty) {
+ add('context.completelyEmpty', format.completelyEmpty(), (f) => ({
+ ...f,
+ context: { ...f.context, completelyEmpty: false }
+ }))
+ }
+
+ if (location.hostIds.length > 0) {
+ add('location.hostIds', format.list('host', location.hostIds.length), (f) => ({
+ ...f,
+ location: { ...f.location, hostIds: [] }
+ }))
+ }
+ if (location.repoIds.length > 0) {
+ add('location.repoIds', format.list('repo', location.repoIds.length), (f) => ({
+ ...f,
+ location: { ...f.location, repoIds: [] }
+ }))
+ }
+ if (location.pathPrefix.trim().length > 0) {
+ add('location.pathPrefix', format.pathPrefix(location.pathPrefix.trim()), (f) => ({
+ ...f,
+ location: { ...f.location, pathPrefix: '' }
+ }))
+ }
+
+ if (safety.blockers.length > 0) {
+ add('safety.blockers', format.list('blocker', safety.blockers.length), (f) => ({
+ ...f,
+ safety: { ...f.safety, blockers: [] }
+ }))
+ }
+ if (safety.tiers.length > 0) {
+ add('safety.tiers', format.list('tier', safety.tiers.length), (f) => ({
+ ...f,
+ safety: { ...f.safety, tiers: [] }
+ }))
+ }
+ if (safety.dismissed !== 'any') {
+ add('safety.dismissed', format.triState('dismissed', safety.dismissed), (f) => ({
+ ...f,
+ safety: { ...f.safety, dismissed: 'any' }
+ }))
+ }
+ if (safety.selectableOnly) {
+ add('safety.selectableOnly', format.selectableOnly(), (f) => ({
+ ...f,
+ safety: { ...f.safety, selectableOnly: false }
+ }))
+ }
+
+ return applied
+}