diff --git a/app/shared/src/chatview/i18n/resources.ts b/app/shared/src/chatview/i18n/resources.ts index 531a707ab..99e73b178 100644 --- a/app/shared/src/chatview/i18n/resources.ts +++ b/app/shared/src/chatview/i18n/resources.ts @@ -758,6 +758,12 @@ export const chatviewResources = { 'prompt.peerContext.conflict': '如果需要在共享文件上工作...', 'prompt.peerContext.modifySame': '如果两个 Agent 修改同一个文件...', + // ═══ Task queue / task-conversation deep links (#1963) ═══ + 'taskQueue.title': '任务队列', + 'taskQueue.openConversation': '进入承载会话', + 'taskQueue.backToTask': '返回任务', + 'taskQueue.backToConversation': '返回会话', + // ═══ OIDC pages (for Go template rendering) ═══ 'oidc.missingParams.title': 'AgentHub — 缺少参数', 'oidc.missingParams.heading': '缺少参数', @@ -1512,6 +1518,12 @@ export const chatviewResources = { 'prompt.peerContext.conflict': 'If you need to work on shared files...', 'prompt.peerContext.modifySame': 'If two agents modify the same file...', + // ═══ Task queue / task-conversation deep links (#1963) ═══ + 'taskQueue.title': 'Task queue', + 'taskQueue.openConversation': 'Open hosting conversation', + 'taskQueue.backToTask': 'Back to task', + 'taskQueue.backToConversation': 'Back to conversation', + // ═══ OIDC pages (for Go template rendering) ═══ 'oidc.missingParams.title': 'AgentHub — Missing Parameters', 'oidc.missingParams.heading': 'Missing Parameters', diff --git a/app/workbench/src/AgentHubWorkbench.module.css b/app/workbench/src/AgentHubWorkbench.module.css index 18611df16..d1f731a52 100644 --- a/app/workbench/src/AgentHubWorkbench.module.css +++ b/app/workbench/src/AgentHubWorkbench.module.css @@ -3739,3 +3739,221 @@ button.mainchainAttentionChip[data-attention-kind='awaiting']:hover { animation: none; } } + +/* ═══ Task ↔ conversation deep links (#1963) ═══ */ + +/* Column host for the tasks route: the deep-link bar stacks above TasksPage + without clipping its height:100% page section. */ +.tasksDeepLinkHost { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.tasksDeepLinkHost > section { + flex: 1; + min-height: 0; + height: auto; +} + +/* Deep-link action bar above the tasks page: back-to-conversation and + open-hosting-conversation affordances. */ +.tasksDeepLinkBar { + display: flex; + align-items: center; + gap: var(--sp-xxs); + padding: var(--td-space-1) var(--sp-10); +} + +.tasksDeepLinkButton { + display: inline-flex; + align-items: center; + gap: var(--sp-xxs); + padding: 5px 10px; + border: 1px solid var(--td-line); + border-radius: var(--r-md); + background: var(--td-surface-2); + color: var(--td-ink-subtle); + font: 500 0.75rem var(--td-font); + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease), + border-color var(--td-dur-normal) var(--td-ease); +} + +.tasksDeepLinkButton:hover { + background: var(--td-tint); + border-color: var(--td-plum); + color: var(--td-plum); +} + +.tasksDeepLinkButton:focus-visible { + border-color: var(--td-focus); + box-shadow: var(--focus-ring); + outline: none; +} + +/* Sidebar 任务队列 collapsible group (#1963). Mirrors the archiveFilterToggle + chrome so the group reads as a sidebar-native filter section. */ +.taskQueueGroup { + margin: 0 var(--sp-xxs) var(--td-space-1); +} + +.taskQueueHeader { + display: flex; + align-items: center; + gap: var(--sp-xxs); + width: 100%; + padding: 5px 10px; + border: 1px solid var(--td-line); + border-radius: var(--r-md); + background: var(--td-surface-2); + color: var(--td-ink-subtle); + font: 500 0.75rem var(--td-font); + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease); +} + +.taskQueueHeader:hover { + background: var(--td-surface-3); + color: var(--td-ink-muted); +} + +.taskQueueHeader:focus-visible { + border-color: var(--td-focus); + box-shadow: var(--focus-ring); + outline: none; +} + +.taskQueueHeaderIcon { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.taskQueueTitle { + flex: 1; + min-width: 0; + text-align: left; +} + +.taskQueueCount { + min-width: 16px; + height: 16px; + padding: 0 var(--td-space-1); + border-radius: var(--r-full); + background: var(--td-plum); + color: var(--text-inv); + font: 600 0.625rem var(--td-font); + display: flex; + align-items: center; + justify-content: center; +} + +.taskQueueChevron { + display: flex; + align-items: center; + flex-shrink: 0; + transition: transform var(--td-dur-normal) var(--td-ease); +} + +.taskQueueChevronCollapsed { + transform: rotate(-90deg); +} + +.taskQueueList { + margin: var(--sp-xxs) 0 0; + padding: 0; + list-style: none; + max-height: 180px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +.taskQueueItem { + display: flex; + align-items: center; + gap: var(--sp-xxs); + width: 100%; + padding: 5px 10px; + border: none; + border-radius: var(--r-md); + background: transparent; + color: var(--td-ink-muted); + font: 500 0.75rem var(--td-font); + text-align: left; + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease); +} + +.taskQueueItem:hover { + background: var(--td-surface-2); + color: var(--td-ink); +} + +.taskQueueItem:focus-visible { + box-shadow: var(--focus-ring); + outline: none; +} + +.taskQueueItemTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskQueueItemStatus { + flex-shrink: 0; + color: var(--td-ink-subtle); + font: 500 0.625rem var(--td-font); +} + +/* Back chip shown on the conversation a task deep link opened (#1963). */ +.taskDeepLinkChip { + margin: 0 var(--sp-10) var(--sp-xxs); +} + +.taskDeepLinkChipButton { + display: flex; + align-items: center; + gap: var(--sp-xxs); + width: 100%; + padding: 5px 10px; + border: 1px solid var(--td-plum); + border-radius: var(--r-md); + background: var(--td-tint); + color: var(--td-plum); + font: 500 0.75rem var(--td-font); + cursor: pointer; + transition: + background var(--td-dur-normal) var(--td-ease), + color var(--td-dur-normal) var(--td-ease); +} + +.taskDeepLinkChipButton:hover { + background: color-mix(in oklch, var(--td-plum) 16%, transparent); +} + +.taskDeepLinkChipButton:focus-visible { + border-color: var(--td-focus); + box-shadow: var(--focus-ring); + outline: none; +} + +.taskDeepLinkChipTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--td-ink-muted); +} diff --git a/app/workbench/src/AgentHubWorkbench.tsx b/app/workbench/src/AgentHubWorkbench.tsx index a44da1fef..e35831526 100644 --- a/app/workbench/src/AgentHubWorkbench.tsx +++ b/app/workbench/src/AgentHubWorkbench.tsx @@ -10,6 +10,7 @@ import { useWorkbenchProfileChrome } from './useWorkbenchProfileChrome'; import { useWorkbenchSessionChrome } from './useWorkbenchSessionChrome'; import { useWorkbenchTranscriptChrome } from './useWorkbenchTranscriptChrome'; import { useWorkbenchGlobalShortcuts } from './useWorkbenchGlobalShortcuts'; +import { useWorkbenchTaskDeepLinks } from './useWorkbenchTaskDeepLinks'; import { GlobalSearchDialog } from './GlobalSearchDialog'; import { WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT } from './desktopChromeEvents'; import { isEditableKeyboardTarget } from './workbenchSessionChromeHelpers'; @@ -56,6 +57,15 @@ export function AgentHubWorkbench(props: AgentHubWorkbenchProps): React.ReactEle // #1822: Ctrl/⌘+K global search dialog (conversation switcher). const [globalSearchOpen, setGlobalSearchOpen] = useState(false); + // #1963: task ↔ conversation deep links — intents queued by the tasks + // route view / conversation sidebar become page + conversation navigation + // here (and the back trips go through the same store). + useWorkbenchTaskDeepLinks({ + setActivePage, + onActiveConversationChange: props.onActiveConversationChange, + dataMode: props.workbenchStatus?.dataMode, + }); + // Global '?' opens/closes the keyboard-shortcuts help overlay (#8). // Reuses getResolvedShortcutGroups() — the same data source as the // Settings ShortcutsPane. Skips editable targets and modifier chords. diff --git a/app/workbench/src/ConversationSidebar.test.tsx b/app/workbench/src/ConversationSidebar.test.tsx index 29cc2b433..232b978c2 100644 --- a/app/workbench/src/ConversationSidebar.test.tsx +++ b/app/workbench/src/ConversationSidebar.test.tsx @@ -1,9 +1,18 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReactNode } from 'react'; import type { WorkbenchConversation } from '@shared/platform'; import { ConversationSidebar } from './ConversationSidebar'; +import type { TaskItem } from './pages'; +import { + backFromTaskDeepLink, + consumeWorkbenchTaskDeepLinkIntent, + getWorkbenchTaskDeepLinkSnapshot, + openConversationForTask, + publishWorkbenchTaskQueue, + resetWorkbenchTaskDeepLinksForTest, +} from './workbenchTaskDeepLinks'; /* ────────────────────────────────────────────────────────────────────── ConversationSidebar actions tests (#1508): rename / copy link / delete. @@ -336,3 +345,106 @@ describe('ConversationSidebar live status dots (F1)', () => { expect(container.querySelector('[data-live-status]')).toBeNull(); }); }); + +/* ── Task queue group + task deep links (#1963) ─────────────────────── */ + +function makeQueueTask(overrides: Partial = {}): TaskItem { + return { + id: 'sqlite-plan', + title: 'B0 SQLite 迁移方案', + project: '前端重构任务', + assignee: 'Builder', + startTime: '今天 14:49', + dueDate: '明天 18:00', + creator: 'demo-user', + status: '进行中', + ...overrides, + }; +} + +describe('ConversationSidebar task queue group (#1963)', () => { + beforeEach(() => { + resetWorkbenchTaskDeepLinksForTest(); + }); + + afterEach(() => { + resetWorkbenchTaskDeepLinksForTest(); + }); + + it('hides the task queue group when there are no active tasks', () => { + renderSidebar(); + expect(screen.queryByText('Task queue')).not.toBeInTheDocument(); + }); + + it('renders the group expanded by default when active tasks exist', () => { + publishWorkbenchTaskQueue([ + makeQueueTask(), + makeQueueTask({ id: 'embedded-docs', title: '云文档内嵌子页对齐', status: '待评审' }), + ]); + renderSidebar(); + + const header = screen.getByRole('button', { name: /Task queue/ }); + expect(header).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('B0 SQLite 迁移方案')).toBeInTheDocument(); + expect(screen.getByText('云文档内嵌子页对齐')).toBeInTheDocument(); + }); + + it('collapses and re-expands the group through the header toggle', () => { + publishWorkbenchTaskQueue([makeQueueTask()]); + renderSidebar(); + + const header = screen.getByRole('button', { name: /Task queue/ }); + fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('B0 SQLite 迁移方案')).not.toBeInTheDocument(); + + fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('B0 SQLite 迁移方案')).toBeInTheDocument(); + }); + + it('queues a conversation→task deep link when a queue entry is clicked', () => { + publishWorkbenchTaskQueue([makeQueueTask()]); + renderSidebar(); + + fireEvent.click(screen.getByText('B0 SQLite 迁移方案').closest('button')!); + + const { pending } = getWorkbenchTaskDeepLinkSnapshot(); + expect(pending?.type).toBe('open'); + if (pending?.type !== 'open') return; + expect(pending.link.direction).toBe('conversation-to-task'); + expect(pending.link.taskId).toBe('sqlite-plan'); + }); + + it('shows a back chip on the conversation a task deep link opened', () => { + // Simulate the shell applying a task→conversation deep link onto c1. + openConversationForTask(makeQueueTask({ conversationId: 'c1' })); + consumeWorkbenchTaskDeepLinkIntent(); + renderSidebar(); + + const chip = screen.getByRole('button', { name: /Back to task/ }); + expect(within(chip).getByText('B0 SQLite 迁移方案')).toBeInTheDocument(); + + fireEvent.click(chip); + const { pending } = getWorkbenchTaskDeepLinkSnapshot(); + expect(pending?.type).toBe('back'); + if (pending?.type !== 'back') return; + expect(pending.link.direction).toBe('task-to-conversation'); + }); + + it('hides the back chip once the user moves to another conversation', () => { + openConversationForTask(makeQueueTask({ conversationId: 'c1' })); + consumeWorkbenchTaskDeepLinkIntent(); + renderSidebar({ activeConversationId: 'c2' }); + expect(screen.queryByRole('button', { name: /Back to task/ })).not.toBeInTheDocument(); + }); + + it('hides the back chip after the back trip has been applied', () => { + openConversationForTask(makeQueueTask({ conversationId: 'c1' })); + consumeWorkbenchTaskDeepLinkIntent(); + backFromTaskDeepLink(); + consumeWorkbenchTaskDeepLinkIntent(); + renderSidebar(); + expect(screen.queryByRole('button', { name: /Back to task/ })).not.toBeInTheDocument(); + }); +}); diff --git a/app/workbench/src/ConversationSidebar.tsx b/app/workbench/src/ConversationSidebar.tsx index 685e1fe09..eaba449de 100644 --- a/app/workbench/src/ConversationSidebar.tsx +++ b/app/workbench/src/ConversationSidebar.tsx @@ -8,6 +8,11 @@ import { CHATVIEW_I18N_NAMESPACE } from '@shared/chatview/i18n/resources'; import Modal from '@shared/ui/Modal'; import { Tooltip } from '@shared/ui/Tooltip'; import type { ConversationLiveStatus } from './workbenchAttentionModel'; +import { + backFromTaskDeepLink, + openTaskDetailForConversation, + useWorkbenchTaskDeepLinkSnapshot, +} from './workbenchTaskDeepLinks'; import styles from './AgentHubWorkbench.module.css'; const SORT_STORAGE_KEY = 'agenthub.conversationSort'; @@ -142,6 +147,23 @@ export function ConversationSidebar({ */ const cancelRenameRef = useRef(false); + // ── #1963 task queue group + deep-link back chip ────────────────────── + // Queue entries are published by the tasks route (live inventory) and the + // shell hook (demo seed while the route is unmounted); the sidebar only + // renders the group, it never owns task data. + const deepLinkSnapshot = useWorkbenchTaskDeepLinkSnapshot(); + const taskQueue = deepLinkSnapshot.taskQueue; + // The back chip belongs to the conversation the task deep link opened; it + // hides as soon as the user moves to another conversation. + const appliedDeepLink = deepLinkSnapshot.applied; + const showBackToTask = appliedDeepLink !== null + && appliedDeepLink.direction === 'task-to-conversation' + && appliedDeepLink.conversationId === activeConversationId; + // The queue only ever holds active tasks, so presence = "有活跃任务": + // the group renders expanded by default and the user may collapse it. + const [taskQueueCollapsed, setTaskQueueCollapsed] = useState(false); + const taskQueueOpen = !taskQueueCollapsed; + useEffect(() => { if (typeof window !== 'undefined') { window.localStorage.setItem(SORT_STORAGE_KEY, sortBy); @@ -384,6 +406,20 @@ export function ConversationSidebar({ )} + {showBackToTask && appliedDeepLink && ( +
+ +
+ )}