From 60ac066859bbdcf596e9ae30f31839ab6f9b439c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Wed, 23 Sep 2026 01:53:06 +0800 Subject: [PATCH] fix: make task IDs selectable and copyable with feedback --- .../agent-stream/agent-stream.component.html | 2 +- .../agent-stream/agent-stream.component.ts | 3 +- .../chat-interface.component.html | 2 +- .../chat-interface.component.ts | 3 +- .../task-history.component.spec.ts | 20 +++++ .../task-history/task-history.component.ts | 7 +- .../task-id-copy.component.spec.ts | 83 +++++++++++++++++++ .../task-id-copy/task-id-copy.component.ts | 66 +++++++++++++++ 8 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.spec.ts create mode 100644 apps/showcase_ui/src/app/components/task-id-copy/task-id-copy.component.ts 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:`

历史任务 {{total()}} 条

@if(error()){

{{error()}}

} @if(loading()){正在查询…} @for(group of groups(); track group.key){
{{group.title}} 本页 {{group.rows.length}} 条 - @for(row of group.rows; track row.session_id){
} + @for(row of group.rows; track row.session_id){
}
} @if(!loading() && !total()){

没有符合条件的历史任务。

}
{{page}} / {{pages()}}
`, - 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 }} + + + + `, + 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); + } +}