feat: release 1.1.0 with responsive task control

This commit is contained in:
chenmingxuan committed 2026-09-24 03:31:17 +08:00
1 parent 057f0c3050
commit bfcd828fa8
37 files changed
+1601 -151

No files matched your search

@@ -17,7 +17,43 @@ limitations under the License.
<div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream -->
<main class="left-panel glass-panel">
<app-agent-stream></app-agent-stream>
<app-agent-stream>
@if (guidanceStatus(); as status) {
<div task-guidance class="task-guidance-control">
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
<span class="material-symbols-outlined">edit_note</span>
指导任务
</button>
@if (guidanceOpenId() === agentService.currentSessionId()) {
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
<div class="guidance-heading">
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
</div>
<label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
<div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') {
<button type="button" (click)="pauseCurrentTask()"
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
</button>
}
@if (status === 'task_paused') {
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
}
<button type="button" class="guidance-submit" (click)="submitGuidance()"
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
</div>
</section>
}
</div>
}
</app-agent-stream>
<!-- Bottom Floating Command Bar Dock -->
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
}
}
.task-guidance-control {
position: relative;
flex: 0 0 auto;
.guidance-trigger {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(226, 232, 240, .85);
border-radius: 15px;
background: rgba(255, 255, 255, .72);
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
color: #334155;
font: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
.material-symbols-outlined { font-size: 16px; }
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
}
@media (max-width: 520px) {
.guidance-trigger {
justify-content: center;
width: 30px;
padding: 0;
font-size: 0;
.material-symbols-outlined { font-size: 16px; }
}
}
.task-guidance-panel {
position: absolute;
top: calc(100% + 9px);
right: 0;
box-sizing: border-box;
width: min(420px, calc(100vw - 40px));
padding: 15px;
border: 1px solid #dbe4ef;
border-radius: 16px;
background: #fff;
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
color: #1e293b;
font-size: 12px;
.guidance-heading {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
margin-bottom: 10px;
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
}
label { display: block; margin-bottom: 6px; font-weight: 600; }
textarea {
box-sizing: border-box;
width: 100%;
min-height: 62px;
max-height: 160px;
padding: 9px 10px;
resize: vertical;
border: 1px solid #cbd5e1;
border-radius: 9px;
background: #fff;
color: #0f172a;
font: inherit;
font-size: 13px;
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
}
.guidance-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
span { flex: 1 0 100%; color: #64748b; }
button {
min-height: 32px;
padding: 5px 11px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #334155;
font: inherit;
font-weight: 600;
cursor: pointer;
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
&:disabled { opacity: .55; cursor: not-allowed; }
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
}
}
}
}
/* ========================================================================= */
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
/* ========================================================================= */
@@ -0,0 +1,118 @@
import { Component, computed, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { WorkspaceComponent } from './workspace.component';
@Component({ selector: 'app-agent-stream', standalone: true,
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
class StreamStub {}
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
class ChatStub {}
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
class VideoStub {}
describe('Workspace task guidance', () => {
const currentId = signal<string | null>('running-task');
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
const agent = {
currentSessionId: currentId,
currentSession: computed(() => currentId() === session().session_id ? session() : null),
runningSessionId: signal<string | null>('running-task'),
agentStatus: signal('running'),
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
fetchStatus: jasmine.createSpy('fetchStatus'),
fetchSessions: jasmine.createSpy('fetchSessions'),
fetchGuidance: jasmine.createSpy('fetchGuidance')
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
beforeEach(async () => {
currentId.set('running-task');
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
agent.runningSessionId.set('running-task');
http.post.and.returnValue(of({}));
http.post.calls.reset();
await TestBed.configureTestingModule({
imports: [WorkspaceComponent],
providers: [
{ provide: AgentService, useValue: agent },
{ provide: HttpClient, useValue: http }
]
}).overrideComponent(WorkspaceComponent, {
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
add: { imports: [StreamStub, ChatStub, VideoStub] }
}).compileComponents();
});
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('指导当前任务');
expect(panel.textContent).toContain('下方输入框用于开始新任务');
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
pause.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
const input = reopened.querySelector('textarea')!;
input.value = '先确认设置首页';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
submit.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
});
it('continues a paused task without creating a new task', async () => {
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
.find(b => b.textContent?.includes('直接继续'))!;
button.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
});
it('does not offer task resume for a paused model request', () => {
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('模型调用暂停');
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
expect(panel.textContent).not.toContain('直接继续');
expect(panel.textContent).not.toContain('提交并恢复');
});
});
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { AgentService } from '../../services/agent.service';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-workspace',
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
private http = inject(HttpClient);
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
public selectedProfile = signal<'flash' | 'pro'>('flash');
private guidanceDrafts = new Map<string, string>();
public guidanceBusy = signal(false);
public guidanceOpenId = signal<string | null>(null);
public pauseRequestId = signal<string | null>(null);
public get taskGuidance(): string {
const id = this.agentService.currentSessionId();
return id ? this.guidanceDrafts.get(id) || '' : '';
}
public set taskGuidance(value: string) {
const id = this.agentService.currentSessionId();
if (id) this.guidanceDrafts.set(id, value);
}
public guidanceStatus = computed(() => {
const id = this.agentService.currentSessionId();
if (!id) return null;
const status = this.agentService.currentSession()?.status
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
});
public toggleGuidance(): void {
const id = this.agentService.currentSessionId();
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
}
// Expand States (Signals for 0-latency reactivity)
public isHoveringCard = signal<boolean>(false);
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
return this.agentService.isCurrentSessionRunning();
});
public async pauseCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
this.pauseRequestId.set(id);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async submitGuidance(): Promise<void> {
const id = this.agentService.currentSessionId();
const instruction = this.taskGuidance.trim();
const status = this.guidanceStatus();
if (!id || !status || !instruction || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
this.guidanceDrafts.delete(id);
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchGuidance(id);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async continueCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
private showGuidanceError(error: unknown): void {
const response = error as { error?: { detail?: string }; message?: string };
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
}
/**
* Dynamic tooltip and label indicating which task will be stopped
*/