diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
index 3cf0e3d..40008a8 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
@@ -124,7 +124,7 @@ limitations under the License.
{{ session.initial_goal || '未命名任务' }}
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
index edbb89c..7e8813b 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
@@ -1,3 +1,4 @@
+import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
@@ -236,7 +237,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
- imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
+ imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html
index eeb472a..103400e 100644
--- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html
+++ b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html
@@ -122,7 +122,7 @@ limitations under the License.
{{ session.initial_goal || '未命名任务' }}
diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts
index eb1c818..8246fea 100644
--- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts
+++ b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts
@@ -1,3 +1,4 @@
+import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
@@ -30,7 +31,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
- imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
+ imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts b/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts
index 98fdd14..9e43f84 100644
--- a/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts
+++ b/apps/showcase_ui/src/app/components/task-history/task-history.component.spec.ts
@@ -1,4 +1,5 @@
import { signal } from '@angular/core';
+import { Clipboard } from '@angular/cdk/clipboard';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
@@ -14,6 +15,25 @@ describe('历史任务检索', () => {
});
afterEach(() => http.verify());
+ it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
+ spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
+ const fixture = TestBed.createComponent(TaskHistoryComponent);
+ http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
+ {session_id:'completed-task', name:'已完成任务', status:'completed', start_time:Date.now()/1000}
+ ]});
+ const copy = spyOn(TestBed.inject(Clipboard), 'copy').and.returnValue(true);
+ fixture.detectChanges();
+ const id: HTMLElement = fixture.nativeElement.querySelector('app-task-id-copy [role="button"]');
+ expect(id.closest('button.row')).toBeNull();
+ id.click();
+ await Promise.resolve();
+ expect(copy).toHaveBeenCalledOnceWith('completed-task');
+ expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
+ fixture.nativeElement.querySelector('button.row').click();
+ expect(TestBed.inject(AgentService).selectSession).toHaveBeenCalledWith('completed-task', true);
+ fixture.destroy();
+ });
+
it('迟到的旧查询不会覆盖最新搜索结果', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const first = http.expectOne(r => r.url === '/api/automation/history');
diff --git a/apps/showcase_ui/src/app/components/task-history/task-history.component.ts b/apps/showcase_ui/src/app/components/task-history/task-history.component.ts
index 8bdcf12..d837f52 100644
--- a/apps/showcase_ui/src/app/components/task-history/task-history.component.ts
+++ b/apps/showcase_ui/src/app/components/task-history/task-history.component.ts
@@ -1,3 +1,4 @@
+import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -5,19 +6,19 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@Component({
- selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule],
+ selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent],
template:``,
- styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
+ styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
diff --git a/apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.spec.ts b/apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.spec.ts
new file mode 100644
index 0000000..a99088b
--- /dev/null
+++ b/apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.spec.ts
@@ -0,0 +1,83 @@
+import { Clipboard } from '@angular/cdk/clipboard';
+import { TestBed } from '@angular/core/testing';
+import { TaskIdCopyComponent } from './task-id-copy.component';
+
+describe('任务 ID 复制', () => {
+ let clipboard: jasmine.SpyObj;
+ beforeEach(() => {
+ spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
+ clipboard = jasmine.createSpyObj('Clipboard', ['copy']);
+ TestBed.configureTestingModule({ providers: [{ provide: Clipboard, useValue: clipboard }] });
+ });
+ afterEach(() => window.getSelection()?.removeAllRanges());
+
+ function setup() {
+ const fixture = TestBed.createComponent(TaskIdCopyComponent);
+ fixture.componentRef.setInput('taskId', 'task-123');
+ fixture.detectChanges();
+ const target: HTMLElement = fixture.nativeElement.querySelector('[role="button"]');
+ return { fixture, target };
+ }
+
+ it('点击复制完整 ID,并提示成功,不触发父卡片', async () => {
+ clipboard.copy.and.returnValue(true);
+ const { fixture, target } = setup();
+ const parentClick = jasmine.createSpy('parentClick');
+ fixture.nativeElement.addEventListener('click', parentClick);
+ target.click();
+ await Promise.resolve();
+ fixture.detectChanges();
+ expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
+ expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已复制');
+ expect(parentClick).not.toHaveBeenCalled();
+ fixture.destroy();
+ });
+
+ it('优先使用浏览器剪贴板,并在写入完成后提示成功', async () => {
+ const write = navigator.clipboard.writeText as jasmine.Spy;
+ let complete!: () => void;
+ write.and.returnValue(new Promise(resolve => complete = resolve));
+ const { fixture, target } = setup();
+ target.click();
+ expect(fixture.componentInstance.feedback()).toBe('');
+ complete();
+ await Promise.resolve();
+ expect(write).toHaveBeenCalledOnceWith('task-123');
+ expect(clipboard.copy).not.toHaveBeenCalled();
+ expect(fixture.componentInstance.feedback()).toBe('任务 ID 已复制');
+ fixture.destroy();
+ });
+
+ it('复制失败不会提示成功,ID 仍可手动选择', async () => {
+ clipboard.copy.and.returnValue(false);
+ const { fixture, target } = setup();
+ target.click();
+ await Promise.resolve();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('复制失败,请选中任务 ID 手动复制');
+ expect(getComputedStyle(target.querySelector('.value')!).userSelect).toBe('text');
+ fixture.destroy();
+ });
+
+ it('拖选 ID 后的点击不会覆盖剪贴板或清空选区', () => {
+ const { fixture, target } = setup();
+ const range = document.createRange();
+ range.selectNodeContents(target.querySelector('.value')!);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ target.click();
+ expect(clipboard.copy).not.toHaveBeenCalled();
+ expect(selection.toString()).toContain('task-123');
+ fixture.destroy();
+ });
+
+ it('支持键盘 Enter 复制', async () => {
+ clipboard.copy.and.returnValue(true);
+ const { fixture, target } = setup();
+ target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ await Promise.resolve();
+ expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
+ fixture.destroy();
+ });
+});
diff --git a/apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.ts b/apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.ts
new file mode 100644
index 0000000..4c86b0e
--- /dev/null
+++ b/apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.ts
@@ -0,0 +1,66 @@
+import { Clipboard } from '@angular/cdk/clipboard';
+import { Component, DestroyRef, Input, inject, signal } from '@angular/core';
+
+@Component({
+ selector: 'app-task-id-copy',
+ standalone: true,
+ template: `
+
+ ID: {{ taskId }}
+ content_copy
+
+ {{ feedback() }}
+ `,
+ styles: [`
+ :host { display: block; min-width: 0; font-size: 11px; color: inherit; user-select: text; }
+ .copy-target { display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
+ padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; }
+ .value { min-width: 0; overflow-wrap: anywhere; user-select: text; }
+ .material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; }
+ .copy-target:hover { color: #2563eb; }
+ .copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
+ .feedback { display: block; line-height: 1.6; user-select: text; }
+ .feedback:empty { display: none; }
+ `]
+})
+export class TaskIdCopyComponent {
+ @Input({ required: true }) taskId = '';
+ readonly feedback = signal('');
+ private readonly clipboard = inject(Clipboard);
+ private readonly destroyRef = inject(DestroyRef);
+ private timer: ReturnType | undefined;
+
+ constructor() {
+ this.destroyRef.onDestroy(() => clearTimeout(this.timer));
+ }
+
+ async copy(event: Event): Promise {
+ event.stopPropagation();
+ // A drag selection must remain available for the browser's normal copy action.
+ const selection = window.getSelection();
+ if (event.type === 'click' && selection && !selection.isCollapsed
+ && (event.currentTarget as HTMLElement).contains(selection.anchorNode)) return;
+ event.preventDefault();
+ let copied = false;
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(this.taskId);
+ copied = true;
+ } else {
+ copied = this.clipboard.copy(this.taskId);
+ }
+ } catch {
+ try {
+ copied = this.clipboard.copy(this.taskId);
+ } catch {
+ // Keep manual selection available when the browser blocks both clipboard APIs.
+ }
+ }
+ if (this.destroyRef.destroyed) return;
+ this.feedback.set(copied ? '任务 ID 已复制' : '复制失败,请选中任务 ID 手动复制');
+ clearTimeout(this.timer);
+ this.timer = setTimeout(() => this.feedback.set(''), 3000);
+ }
+}