- {f.title}
+ {f.href ? (
+
+ {f.title}
+
+ ) : (
+ {f.title}
+ )}
{formatRelative(new Date(f.at).toISOString())}
diff --git a/frontend/lib/demo/monitor.ts b/frontend/lib/demo/monitor.ts
index 9e50ffca..5e2e8dd8 100644
--- a/frontend/lib/demo/monitor.ts
+++ b/frontend/lib/demo/monitor.ts
@@ -30,6 +30,8 @@ export interface WorkerView {
export interface StreamTask {
id: string
+ /** User-facing destination for the authoritative task context, when available. */
+ href?: string
lane: LaneKind
title: string
/** Collect: source name. Dispatch: destination channel. */
@@ -45,6 +47,8 @@ export interface StreamTask {
export interface FailureItem {
id: string
+ /** User-facing destination for the authoritative task context, when available. */
+ href?: string
lane: LaneKind
title: string
workerName: string
diff --git a/frontend/lib/monitor/task-grouping.ts b/frontend/lib/monitor/task-grouping.ts
new file mode 100644
index 00000000..e6758edf
--- /dev/null
+++ b/frontend/lib/monitor/task-grouping.ts
@@ -0,0 +1,37 @@
+import type { FailureItem, StreamTask } from '@/lib/demo/monitor'
+
+export type GroupedStreamTask = StreamTask & { occurrences: number }
+export type GroupedFailure = FailureItem & { occurrences: number }
+
+export function groupStreamTasks(tasks: StreamTask[]): GroupedStreamTask[] {
+ const grouped = new Map()
+
+ for (const task of tasks) {
+ const key = [task.href ?? '', task.title, task.lane, task.workerName, task.phase].join('\u0000')
+ const existing = grouped.get(key)
+ if (existing) {
+ existing.occurrences += 1
+ existing.records += task.records
+ continue
+ }
+ grouped.set(key, { ...task, occurrences: 1 })
+ }
+
+ return Array.from(grouped.values()).slice(0, 6)
+}
+
+export function groupFailures(failures: FailureItem[]): GroupedFailure[] {
+ const grouped = new Map()
+
+ for (const failure of failures) {
+ const key = [failure.href ?? '', failure.title, failure.workerName, failure.error].join('\u0000')
+ const existing = grouped.get(key)
+ if (existing) {
+ existing.occurrences += 1
+ continue
+ }
+ grouped.set(key, { ...failure, occurrences: 1 })
+ }
+
+ return Array.from(grouped.values()).slice(0, 5)
+}
diff --git a/frontend/lib/tasks/query.ts b/frontend/lib/tasks/query.ts
new file mode 100644
index 00000000..c847ab63
--- /dev/null
+++ b/frontend/lib/tasks/query.ts
@@ -0,0 +1,44 @@
+const TASK_STATUSES = new Set(['running', 'completed', 'failed', 'pending'])
+
+export function normalizeTaskStatus(value: string | null): string {
+ return value && TASK_STATUSES.has(value) ? value : ''
+}
+
+export function normalizeTaskPage(value: string | null): number {
+ const page = Number(value)
+ return Number.isInteger(page) && page > 0 ? page : 1
+}
+
+export function queryForTaskStatus(currentQuery: string, nextStatus: string): string {
+ const params = new URLSearchParams(currentQuery)
+ const status = normalizeTaskStatus(nextStatus)
+ if (status) params.set('status', status)
+ else params.delete('status')
+ params.delete('page')
+ return params.toString()
+}
+
+export function queryForTaskPage(currentQuery: string, nextPage: number): string {
+ const params = new URLSearchParams(currentQuery)
+ const page = Number.isInteger(nextPage) && nextPage > 1 ? nextPage : 1
+ if (page > 1) params.set('page', String(page))
+ else params.delete('page')
+ return params.toString()
+}
+
+export function pathWithQuery(pathname: string, query: string): string {
+ return query ? `${pathname}?${query}` : pathname
+}
+
+export function taskDetailPath(taskId: string, returnTo: string): string {
+ const params = new URLSearchParams({ returnTo: normalizeTaskReturnPath(returnTo) })
+ return `/tasks/${encodeURIComponent(taskId)}?${params.toString()}`
+}
+
+export function normalizeTaskReturnPath(value: string | null): string {
+ if (!value?.startsWith('/')) return '/tasks'
+ const base = new URL('https://opencli.local/tasks')
+ const target = new URL(value, base)
+ if (target.origin !== base.origin || target.pathname !== '/tasks') return '/tasks'
+ return `${target.pathname}${target.search}`
+}
diff --git a/frontend/package.json b/frontend/package.json
index 477be7e3..f50ba369 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
"check:image-studio": "node --test scripts/check-image-studio-contract.mjs",
"check:navigation-transitions": "node --test scripts/check-navigation-transition-regressions.mjs",
"check:control-plane": "node --test scripts/check-control-plane-regressions.mjs scripts/check-dashboard-regressions.mjs scripts/check-inbox-regressions.mjs scripts/check-visualization-regressions.mjs",
+ "check:task-triage": "node --experimental-strip-types --test scripts/task-list-query.test.mjs scripts/task-grouping.test.mjs scripts/check-dashboard-regressions.mjs",
"check:dify-p0": "node --test scripts/check-dify-p0-regressions.mjs",
"check:node-capabilities": "node --test scripts/check-node-capability-catalog-regressions.mjs scripts/check-tool-capability-catalog-regressions.mjs",
"check:record-hygiene": "node --test scripts/check-record-hygiene-regressions.mjs",
diff --git a/frontend/scripts/check-dashboard-regressions.mjs b/frontend/scripts/check-dashboard-regressions.mjs
index 64e1bca3..ac5b4def 100644
--- a/frontend/scripts/check-dashboard-regressions.mjs
+++ b/frontend/scripts/check-dashboard-regressions.mjs
@@ -56,6 +56,7 @@ test('dashboard keeps existing real operational views after the action layer', a
assert.match(dashboard, //)
assert.match(dashboard, / {
+ const [dashboard, taskStream, taskGrouping, tasksPage, taskDetailPage] = await Promise.all([
+ read('app/(app)/dashboard/page.tsx'),
+ read('components/monitor/task-stream.tsx'),
+ read('lib/monitor/task-grouping.ts'),
+ read('app/(app)/tasks/page.tsx'),
+ read('app/(app)/tasks/[id]/page.tsx'),
+ ])
+
+ assert.match(dashboard, /href: `\/tasks\/\$\{r\.task_id\}`/)
+ assert.match(dashboard, /href: task\.href/)
+ assert.match(dashboard, /hasAttention \? '\/tasks\?status=failed' : '\/tasks'/)
+ assert.match(taskStream, /href="\/tasks\?status=failed"/)
+ assert.match(taskStream, /hasUnlistedFailures = totalFailed > 0/)
+ assert.match(taskStream, /这些任务不在最近 10 条运行中/)
+ assert.match(taskStream, /hasUnlistedFailures \? '\/tasks\?status=failed' : '\/tasks'/)
+ assert.match(taskStream, /= totalPages\}/)
+ assert.match(tasksPage, /taskDetailPath\(t\.id, returnTo\)/)
+ assert.match(taskDetailPage, /normalizeTaskReturnPath\(typeof query\.returnTo === 'string' \? query\.returnTo : null\)/)
+ assert.match(taskDetailPage, / {
+ const grouped = groupStreamTasks([
+ streamTask,
+ { ...streamTask, id: 'run-2', href: '/tasks/task-2', records: 2 },
+ ])
+
+ assert.equal(grouped.length, 2)
+ assert.deepEqual(grouped.map((task) => task.href), ['/tasks/task-1', '/tasks/task-2'])
+})
+
+test('stream grouping still combines repeated runs of the same task', () => {
+ const grouped = groupStreamTasks([
+ streamTask,
+ { ...streamTask, id: 'run-2', records: 2 },
+ ])
+
+ assert.equal(grouped.length, 1)
+ assert.equal(grouped[0].occurrences, 2)
+ assert.equal(grouped[0].records, 3)
+ assert.equal(grouped[0].href, '/tasks/task-1')
+})
+
+test('failure grouping preserves distinct task destinations', () => {
+ const failure = {
+ id: 'failure-1',
+ href: '/tasks/task-1',
+ lane: 'collect',
+ title: '同名采集',
+ workerName: 'worker',
+ error: 'timeout',
+ retries: 0,
+ at: 1,
+ }
+ const grouped = groupFailures([
+ failure,
+ { ...failure, id: 'failure-2', href: '/tasks/task-2' },
+ ])
+
+ assert.equal(grouped.length, 2)
+ assert.deepEqual(grouped.map((item) => item.href), ['/tasks/task-1', '/tasks/task-2'])
+})
diff --git a/frontend/scripts/task-list-query.test.mjs b/frontend/scripts/task-list-query.test.mjs
new file mode 100644
index 00000000..ef7d5567
--- /dev/null
+++ b/frontend/scripts/task-list-query.test.mjs
@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+
+import {
+ normalizeTaskPage,
+ normalizeTaskReturnPath,
+ normalizeTaskStatus,
+ pathWithQuery,
+ queryForTaskPage,
+ queryForTaskStatus,
+ taskDetailPath,
+} from '../lib/tasks/query.ts'
+
+test('task filters accept only supported status and positive integer pages', () => {
+ assert.equal(normalizeTaskStatus('failed'), 'failed')
+ assert.equal(normalizeTaskStatus('unknown'), '')
+ assert.equal(normalizeTaskStatus(null), '')
+ assert.equal(normalizeTaskPage('3'), 3)
+ assert.equal(normalizeTaskPage('0'), 1)
+ assert.equal(normalizeTaskPage('2.5'), 1)
+ assert.equal(normalizeTaskPage('not-a-number'), 1)
+})
+
+test('changing status resets pagination while preserving unrelated context', () => {
+ assert.equal(queryForTaskStatus('page=4&source=alpha', 'failed'), 'source=alpha&status=failed')
+ assert.equal(queryForTaskStatus('status=failed&page=2&source=alpha', ''), 'source=alpha')
+ assert.equal(queryForTaskStatus('page=2', 'unknown'), '')
+})
+
+test('changing pages keeps filters and canonicalizes the first page', () => {
+ assert.equal(queryForTaskPage('status=failed&source=alpha', 3), 'status=failed&source=alpha&page=3')
+ assert.equal(queryForTaskPage('status=failed&page=3', 1), 'status=failed')
+ assert.equal(queryForTaskPage('status=failed&page=3', -1), 'status=failed')
+ assert.equal(pathWithQuery('/tasks', 'status=failed&page=3'), '/tasks?status=failed&page=3')
+ assert.equal(pathWithQuery('/tasks', ''), '/tasks')
+})
+
+test('task detail links preserve only a safe task-list return context', () => {
+ const returnTo = '/tasks?status=failed&page=3'
+ const detail = taskDetailPath('task / 1', returnTo)
+ const parsed = new URL(detail, 'https://opencli.local')
+
+ assert.equal(parsed.pathname, '/tasks/task%20%2F%201')
+ assert.equal(parsed.searchParams.get('returnTo'), returnTo)
+ assert.equal(normalizeTaskReturnPath(returnTo), returnTo)
+ assert.equal(normalizeTaskReturnPath('https://evil.example/tasks'), '/tasks')
+ assert.equal(normalizeTaskReturnPath('//evil.example/tasks'), '/tasks')
+ assert.equal(normalizeTaskReturnPath('/settings'), '/tasks')
+ assert.equal(normalizeTaskReturnPath('/tasks/task-1'), '/tasks')
+})