Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/shared/src/chatview/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '缺少参数',
Expand Down Expand Up @@ -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',
Expand Down
218 changes: 218 additions & 0 deletions app/workbench/src/AgentHubWorkbench.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
10 changes: 10 additions & 0 deletions app/workbench/src/AgentHubWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
114 changes: 113 additions & 1 deletion app/workbench/src/ConversationSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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> = {}): 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();
});
});
Loading
Loading