Commit 143789ee authored by edy's avatar edy

fix(ui): guard automation run updates

parent 5f36e0b1
Pipeline #18475 failed
......@@ -316,6 +316,23 @@ export function CheckIcon() {
);
}
export function PlusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
);
}
export function EditIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path d="M5 19h4.4L18.2 10.2a2.2 2.2 0 0 0-3.1-3.1L6.3 15.9 5 19Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="m13.7 8.5 3.1 3.1" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
export function TrashIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
......
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type {
AutomationTask,
AutomationTaskRun,
......@@ -10,7 +10,7 @@ import { Button } from "../../components/ui/Button"
import { Panel } from "../../components/ui/Panel"
import { ScrollArea } from "../../components/ui/ScrollArea"
import { StatusChip } from "../../components/ui/StatusChip"
import { RefreshIcon, TrashIcon } from "../../components/icons/AppIcons"
import { EditIcon, PlusIcon, RefreshIcon, TrashIcon } from "../../components/icons/AppIcons"
import { desktopApi } from "../../lib/desktop-api"
import { HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import { renderChatMessageContent } from "../chat/renderChatMessageContent"
......@@ -35,6 +35,7 @@ interface AutomationTaskFormState {
}
const weekdayLabels = ["日", "一", "二", "三", "四", "五", "六"]
const activeRunStatuses = new Set<AutomationTaskRun["status"]>(["queued", "running"])
function toDateTimeLocalValue(date: Date) {
const year = date.getFullYear()
......@@ -168,6 +169,10 @@ function getTaskNextRunLabel(task: AutomationTask) {
return task.enabled ? formatDateTime(task.nextRunAt) : getTaskLifecycleLabel(task)
}
function truncateText(text: string, maxLength: number) {
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text
}
export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
const [tasks, setTasks] = useState<AutomationTask[]>([])
const [runs, setRuns] = useState<AutomationTaskRun[]>([])
......@@ -177,6 +182,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [errorText, setErrorText] = useState("")
const [optimisticRunningTaskIds, setOptimisticRunningTaskIds] = useState<Set<string>>(() => new Set())
const selectedTaskIdRef = useRef("")
const expertOptions = useMemo(() => [
{ id: "", label: "通用助手" },
......@@ -200,7 +207,11 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
return true
}), [filterMode, tasks])
const loadTasks = async () => {
useEffect(() => {
selectedTaskIdRef.current = selectedTask?.id ?? ""
}, [selectedTask?.id])
const loadTasks = useCallback(async () => {
setLoading(true)
setErrorText("")
try {
......@@ -212,34 +223,34 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadTasks()
}, [])
useEffect(() => {
let active = true
if (!selectedTask?.id) {
const loadSelectedTaskRuns = useCallback(async () => {
const taskId = selectedTask?.id
if (!taskId) {
setRuns([])
return
return []
}
void desktopApi.automationTasks.listRuns(selectedTask.id)
.then((nextRuns) => {
if (active) {
try {
const nextRuns = await desktopApi.automationTasks.listRuns(taskId)
if (selectedTaskIdRef.current === taskId) {
setRuns(nextRuns)
}
})
.catch((error) => {
if (active) {
return nextRuns
} catch (error) {
setErrorText(error instanceof Error ? error.message : "运行记录加载失败")
}
})
return () => {
active = false
return []
}
}, [selectedTask?.id])
useEffect(() => {
void loadTasks()
}, [loadTasks])
useEffect(() => {
void loadSelectedTaskRuns()
}, [loadSelectedTaskRuns])
const startCreate = () => {
setForm(createDefaultForm(projects))
}
......@@ -316,15 +327,23 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
}
const runTaskNow = async (taskId: string) => {
setOptimisticRunningTaskIds((current) => new Set(current).add(taskId))
setSaving(true)
setErrorText("")
try {
const run = await desktopApi.automationTasks.runNow(taskId)
if (selectedTaskIdRef.current === taskId) {
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)])
}
await loadTasks()
} catch (error) {
setErrorText(error instanceof Error ? error.message : "立即执行失败")
} finally {
setOptimisticRunningTaskIds((current) => {
const next = new Set(current)
next.delete(taskId)
return next
})
setSaving(false)
}
}
......@@ -333,6 +352,24 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
await navigator.clipboard?.writeText(text)
}
const hasActiveSelectedRun = runs.some((run) => run.taskId === selectedTask?.id && activeRunStatuses.has(run.status))
const hasOptimisticSelectedRun = optimisticRunningTaskIds.has(selectedTask?.id ?? "")
const isRunningSelectedTask = hasActiveSelectedRun || hasOptimisticSelectedRun
const shouldPollSelectedRuns = hasActiveSelectedRun || hasOptimisticSelectedRun
useEffect(() => {
if (!shouldPollSelectedRuns) {
return
}
const pollTimer = window.setInterval(() => {
void loadSelectedTaskRuns()
void loadTasks()
}, 2000)
return () => {
window.clearInterval(pollTimer)
}
}, [shouldPollSelectedRuns, loadSelectedTaskRuns, loadTasks])
return (
<div className="automation-page-stack">
<Panel
......@@ -342,14 +379,16 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
<div className="automation-header">
<div>
<h1>自动化任务</h1>
<p>应用运行时执行,错过的计划会保留为记录。</p>
</div>
<div className="automation-header-actions">
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => void loadTasks()} disabled={loading || saving}>
<RefreshIcon />
<span>刷新</span>
</Button>
<Button size="sm" className="automation-action-button automation-button-primary" onClick={startCreate}>新建任务</Button>
<Button size="sm" className="automation-action-button automation-button-primary" onClick={startCreate}>
<PlusIcon />
<span>新建任务</span>
</Button>
</div>
</div>
)}
......@@ -387,8 +426,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
className="automation-task-select"
onClick={() => setSelectedTaskId(task.id)}
>
<strong>{task.title}</strong>
<small>{task.prompt}</small>
<strong>{truncateText(task.title, 20)}</strong>
<small>{truncateText(task.prompt, 20)}</small>
</button>
</article>
)) : (
......@@ -461,8 +500,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
</label>
</div>
<div className="automation-form-actions">
<Button variant="secondary" size="sm" onClick={() => setForm(null)} disabled={saving}>取消</Button>
<Button size="sm" onClick={() => void submitForm()} disabled={saving}>{saving ? "保存中" : "保存"}</Button>
<Button variant="secondary" size="sm" className="automation-form-button-secondary" onClick={() => setForm(null)} disabled={saving}>取消</Button>
<Button size="sm" className="automation-form-button-primary" onClick={() => void submitForm()} disabled={saving}>{saving ? "保存中" : "保存"}</Button>
</div>
</div>
) : selectedTask ? (
......@@ -471,12 +510,20 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
<div>
<span className="automation-kicker">{selectedTask.expertName || "通用助手"}</span>
<h2>{selectedTask.title}</h2>
<p className="automation-detail-prompt">{selectedTask.prompt}</p>
<p className="automation-detail-prompt">{truncateText(selectedTask.prompt, 30)}</p>
</div>
<div className="automation-detail-actions">
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => startEdit(selectedTask)} disabled={saving}>编辑</Button>
<Button size="sm" className="automation-action-button automation-button-accent" onClick={() => void runTaskNow(selectedTask.id)} disabled={saving}>
{saving ? <span>执行中</span> : <span>立即执行</span>}
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => startEdit(selectedTask)} disabled={saving}>
<EditIcon />
<span>编辑</span>
</Button>
<Button size="sm" className="automation-action-button automation-button-accent" onClick={() => void runTaskNow(selectedTask.id)} disabled={saving || isRunningSelectedTask}>
{isRunningSelectedTask ? (
<>
<span className="automation-button-spinner" aria-hidden="true" />
<span>执行中</span>
</>
) : <span>立即执行</span>}
</Button>
<button
type="button"
......
......@@ -57,6 +57,28 @@
height: 15px;
}
.automation-button-spinner {
width: 14px;
height: 14px;
flex: 0 0 14px;
border: 2px solid rgba(255, 255, 255, 0.46);
border-top-color: #ffffff;
border-radius: 999px;
animation: automation-spin 0.8s linear infinite;
}
@keyframes automation-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.automation-button-spinner {
animation: none;
}
}
.automation-button-primary {
border: 1px solid rgba(37, 99, 235, 0.22);
background: #2563eb;
......@@ -82,6 +104,34 @@
color: #1d4ed8;
}
.automation-form-actions .automation-form-button-secondary {
border: 1px solid rgba(203, 213, 225, 0.92);
background: #ffffff;
color: #1f2937;
box-shadow: none;
}
.automation-form-actions .automation-form-button-secondary:hover:not(:disabled),
.automation-form-actions .automation-form-button-secondary:focus-visible {
border-color: rgba(147, 197, 253, 0.95);
background: #eff6ff;
color: #1d4ed8;
}
.automation-form-actions .automation-form-button-primary {
border: 1px solid #1677FF;
background: #1677FF;
color: #ffffff;
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.18);
}
.automation-form-actions .automation-form-button-primary:hover:not(:disabled),
.automation-form-actions .automation-form-button-primary:focus-visible {
border-color: #0F63D8;
background: #0F63D8;
color: #ffffff;
}
.automation-button-accent {
border: 1px solid rgba(7, 193, 96, 0.42);
background: #07C160;
......@@ -180,6 +230,14 @@
.automation-list-scroll {
min-height: 0;
height: 100%;
overflow: hidden;
}
.automation-list-scroll .scroll-area-content {
height: 100%;
min-height: 0;
overflow-y: auto;
}
.automation-task-list,
......@@ -189,7 +247,8 @@
}
.automation-task-row {
min-height: 70px;
height: 84px;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 12px;
......@@ -205,9 +264,9 @@
.automation-task-select {
display: grid;
gap: 6px;
gap: 5px;
width: 100%;
min-height: 46px;
height: 100%;
min-width: 0;
border: 0;
background: transparent;
......@@ -234,12 +293,11 @@
}
.automation-task-select small {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: #64748b;
font-size: 12px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.automation-detail-pane {
......
......@@ -7,6 +7,8 @@ const sidebarSource = readFileSync(new URL("../src/features/shell/AppSidebar.tsx
const mockApiSource = readFileSync(new URL("../src/lib/mock-desktop-api.ts", import.meta.url), "utf8")
const desktopApiSource = readFileSync(new URL("../src/lib/desktop-api.ts", import.meta.url), "utf8")
const automationStyles = readFileSync(new URL("../src/styles/automation.css", import.meta.url), "utf8")
const automationViewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
const appIconsSource = readFileSync(new URL("../src/components/icons/AppIcons.tsx", import.meta.url), "utf8")
test("automation tasks view is available from the sidebar above settings", () => {
assert.match(appSource, /AutomationTasksView/)
......@@ -24,54 +26,47 @@ test("mock desktop API exposes automation task methods for UI development", () =
})
test("automation task destructive and utility actions are explicit", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /确认删除自动化任务/)
assert.match(viewSource, />刷新</)
assert.match(viewSource, />新建任务</)
assert.match(viewSource, />编辑</)
assert.match(viewSource, />立即执行</)
assert.match(viewSource, />删除</)
assert.doesNotMatch(viewSource, /automation-button-toggle/)
assert.doesNotMatch(viewSource, /aria-label="删除自动化任务" onClick=\{\(\) => void deleteTask/)
assert.match(automationViewSource, /确认删除自动化任务/)
assert.match(automationViewSource, />刷新</)
assert.match(automationViewSource, />新建任务</)
assert.match(automationViewSource, />编辑</)
assert.match(automationViewSource, />立即执行</)
assert.match(automationViewSource, />删除</)
assert.doesNotMatch(automationViewSource, /automation-button-toggle/)
assert.doesNotMatch(automationViewSource, /aria-label="删除自动化任务" onClick=\{\(\) => void deleteTask/)
})
test("automation task view exposes lifecycle text without quick enable and disable controls", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.doesNotMatch(viewSource, /toggleTaskEnabled/)
assert.doesNotMatch(viewSource, /automationTasks\.update\(task\.id,\s*\{\s*enabled: !task\.enabled\s*\}\)/s)
assert.doesNotMatch(viewSource, /automation-task-row-actions/)
assert.doesNotMatch(viewSource, /automation-row-toggle-button/)
assert.match(viewSource, /已完成,已停用/)
assert.match(viewSource, /已错过,已停用/)
assert.match(viewSource, /已停用/)
assert.doesNotMatch(automationViewSource, /toggleTaskEnabled/)
assert.doesNotMatch(automationViewSource, /automationTasks\.update\(task\.id,\s*\{\s*enabled: !task\.enabled\s*\}\)/s)
assert.doesNotMatch(automationViewSource, /automation-task-row-actions/)
assert.doesNotMatch(automationViewSource, /automation-row-toggle-button/)
assert.match(automationViewSource, /已完成,已停用/)
assert.match(automationViewSource, /已错过,已停用/)
assert.match(automationViewSource, /已停用/)
})
test("automation task list shows only task title and prompt", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /<strong>\{task\.title\}<\/strong>/)
assert.match(viewSource, /<small>\{task\.prompt\}<\/small>/)
assert.doesNotMatch(viewSource, /下次执行:\{getTaskNextRunLabel\(task\)\}/)
assert.doesNotMatch(viewSource, /<StatusChip tone=\{getTaskLifecycleTone\(task\)\}>\{getTaskLifecycleLabel\(task\)\}<\/StatusChip>/)
test("automation task list truncates title and prompt source text", () => {
assert.match(automationViewSource, /function truncateText\(text: string, maxLength: number\)/)
assert.match(automationViewSource, /return text\.length > maxLength \? `\$\{text\.slice\(0, maxLength\)\}\.\.\.` : text/)
assert.match(automationViewSource, /<strong>\{truncateText\(task\.title,\s*20\)\}<\/strong>/)
assert.match(automationViewSource, /<small>\{truncateText\(task\.prompt,\s*20\)\}<\/small>/)
assert.match(automationViewSource, /<p className="automation-detail-prompt">\{truncateText\(selectedTask\.prompt,\s*30\)\}<\/p>/)
assert.doesNotMatch(automationViewSource, /下次执行:\{getTaskNextRunLabel\(task\)\}/)
assert.doesNotMatch(automationViewSource, /<StatusChip tone=\{getTaskLifecycleTone\(task\)\}>\{getTaskLifecycleLabel\(task\)\}<\/StatusChip>/)
})
test("automation task edit can clear the selected expert", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /const expertId = form\.expertId \|\| \(form\.id \? null : undefined\)/)
assert.match(viewSource, /const expertName = form\.expertId \? selectedExpert\?\.label : \(form\.id \? null : undefined\)/)
assert.match(automationViewSource, /const expertId = form\.expertId \|\| \(form\.id \? null : undefined\)/)
assert.match(automationViewSource, /const expertName = form\.expertId \? selectedExpert\?\.label : \(form\.id \? null : undefined\)/)
})
test("automation run replies use shared markdown rendering", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /renderChatMessageContent/)
assert.match(viewSource, /markdown-body automation-run-markdown/)
assert.match(viewSource, /className="automation-run-meta"/)
assert.match(viewSource, /run\.replyText/)
assert.doesNotMatch(viewSource, /\{run\.replyText\}<\/p>/)
assert.match(automationViewSource, /renderChatMessageContent/)
assert.match(automationViewSource, /markdown-body automation-run-markdown/)
assert.match(automationViewSource, /className="automation-run-meta"/)
assert.match(automationViewSource, /run\.replyText/)
assert.doesNotMatch(automationViewSource, /\{run\.replyText\}<\/p>/)
assert.match(automationStyles, /\.automation-run-list\s*\{[^}]*overflow:\s*auto/is)
assert.doesNotMatch(automationStyles, /\.automation-run-item\s*>\s*div\s*\{/)
assert.match(automationStyles, /\.automation-detail-pane\s*\{[^}]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\)/is)
......@@ -83,3 +78,78 @@ test("automation run replies use shared markdown rendering", () => {
test("run-now action uses WeChat green accent", () => {
assert.match(automationStyles, /\.automation-button-accent\s*\{[^}]*#07C160/is)
})
test("automation task form actions use scoped DingTalk-style button colors", () => {
assert.match(automationViewSource, /<Button[^]*className="automation-form-button-secondary"[^]*>取消<\/Button>/)
assert.match(automationViewSource, /<Button[^]*className="automation-form-button-primary"[^]*>\{saving \? "保存中" : "保存"\}<\/Button>/)
assert.match(automationStyles, /\.automation-form-button-secondary\s*\{[^}]*background:\s*#ffffff/is)
assert.match(automationStyles, /\.automation-form-button-primary\s*\{[^}]*background:\s*#1677FF/is)
assert.match(automationStyles, /\.automation-form-button-primary:hover:not\(:disabled\)[^{]*\{[^}]*background:\s*#0F63D8/is)
})
test("automation task action buttons use svg icons", () => {
assert.match(appIconsSource, /export function PlusIcon\(\)/)
assert.match(appIconsSource, /export function EditIcon\(\)/)
assert.match(automationViewSource, /import \{ EditIcon, PlusIcon, RefreshIcon, TrashIcon \} from/)
assert.match(automationViewSource, /<PlusIcon \/>\s*<span>新建任务<\/span>/)
assert.match(automationViewSource, /<EditIcon \/>\s*<span>编辑<\/span>/)
})
test("automation task header omits the runtime scheduling helper copy", () => {
assert.doesNotMatch(automationViewSource, /应用运行时执行,错过的计划会保留为记录。/)
})
test("automation task list scrolls inside the left pane", () => {
assert.match(automationStyles, /\.automation-list-pane\s*\{[^}]*grid-template-rows:\s*auto minmax\(0,\s*1fr\)/is)
assert.match(automationStyles, /\.automation-list-scroll\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/is)
assert.match(automationStyles, /\.automation-list-scroll \.scroll-area-content\s*\{[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/is)
})
test("automation task rows use a fixed compact height", () => {
assert.match(automationStyles, /\.automation-task-row\s*\{[^}]*height:\s*84px/is)
assert.match(automationStyles, /\.automation-task-row\s*\{[^}]*overflow:\s*hidden/is)
assert.match(automationStyles, /\.automation-task-select\s*\{[^}]*gap:\s*5px/is)
assert.match(automationStyles, /\.automation-task-select strong\s*\{[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/is)
assert.match(automationStyles, /\.automation-task-select small\s*\{[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/is)
})
test("run-now button derives selected task running state from optimistic state or active run records", () => {
assert.match(automationViewSource, /const activeRunStatuses = new Set<AutomationTaskRun\["status"\]>\(\["queued", "running"\]\)/)
assert.match(automationViewSource, /const \[optimisticRunningTaskIds,\s*setOptimisticRunningTaskIds\] = useState<Set<string>>\(\(\) => new Set\(\)\)/)
assert.doesNotMatch(automationViewSource, /const \[runningTaskId,\s*setRunningTaskId\]/)
assert.doesNotMatch(automationViewSource, /setRunningTaskId/)
assert.match(automationViewSource, /setOptimisticRunningTaskIds\(\(current\) => new Set\(current\)\.add\(taskId\)\)/)
assert.match(automationViewSource, /next\.delete\(taskId\)/)
assert.match(automationViewSource, /const hasActiveSelectedRun = runs\.some\(\(run\) => run\.taskId === selectedTask\?\.id && activeRunStatuses\.has\(run\.status\)\)/)
assert.match(automationViewSource, /const hasOptimisticSelectedRun = optimisticRunningTaskIds\.has\(selectedTask\?\.id \?\? ""\)/)
assert.match(automationViewSource, /const isRunningSelectedTask = hasActiveSelectedRun \|\| hasOptimisticSelectedRun/)
assert.match(automationViewSource, /<span className="automation-button-spinner" aria-hidden="true" \/>\s*<span>执行中<\/span>/)
assert.match(automationViewSource, /disabled=\{saving \|\| isRunningSelectedTask\}/)
})
test("automation task runs poll while a selected task has optimistic state or active run records", () => {
assert.match(automationViewSource, /const loadSelectedTaskRuns = useCallback/)
assert.match(automationViewSource, /const selectedTaskIdRef = useRef\(""\)/)
assert.match(automationViewSource, /const taskId = selectedTask\?\.id/)
assert.match(automationViewSource, /desktopApi\.automationTasks\.listRuns\(taskId\)/)
assert.match(automationViewSource, /if \(selectedTaskIdRef\.current === taskId\) \{\s*setRuns\(nextRuns\)\s*\}/s)
assert.match(automationViewSource, /void loadSelectedTaskRuns\(\)/)
assert.match(automationViewSource, /const hasActiveSelectedRun = runs\.some\(\(run\) => run\.taskId === selectedTask\?\.id && activeRunStatuses\.has\(run\.status\)\)/)
assert.match(automationViewSource, /const shouldPollSelectedRuns = hasActiveSelectedRun \|\| hasOptimisticSelectedRun/)
assert.match(automationViewSource, /if \(!shouldPollSelectedRuns\) \{/)
assert.match(automationViewSource, /window\.setInterval\(\(\) => \{[^}]*void loadSelectedTaskRuns\(\)[^}]*void loadTasks\(\)[^}]*\},\s*2000\)/s)
assert.match(automationViewSource, /window\.clearInterval\(pollTimer\)/)
})
test("run-now only prepends runs while the same task remains selected", () => {
assert.match(automationViewSource, /const runTaskNow = async \(taskId: string\) => \{/)
assert.match(automationViewSource, /const run = await desktopApi\.automationTasks\.runNow\(taskId\)/)
assert.match(automationViewSource, /if \(selectedTaskIdRef\.current === taskId\) \{\s*setRuns\(\(current\) => \[run, \.\.\.current\.filter\(\(item\) => item\.id !== run\.id\)\]\)\s*\}/s)
assert.match(automationViewSource, /await loadTasks\(\)/)
})
test("run-now spinner has accessible reduced-motion styling", () => {
assert.match(automationStyles, /\.automation-button-spinner\s*\{[^}]*border-radius:\s*999px[^}]*animation:\s*automation-spin 0\.8s linear infinite/is)
assert.match(automationStyles, /@keyframes automation-spin\s*\{[^}]*transform:\s*rotate\(360deg\)/is)
assert.match(automationStyles, /@media \(prefers-reduced-motion:\s*reduce\)\s*\{[^}]*\.automation-button-spinner\s*\{[^}]*animation:\s*none/is)
})
......@@ -784,12 +784,11 @@ export class GatewayClient {
}
private async resolveCompletedChatReply(runId: string, payload: Record<string, unknown>): Promise<ChatMessage> {
const reply = this.buildChatMessage(runId, payload);
const pending = this.pendingChatRuns.get(runId);
const reply = this.buildChatMessage(runId, payload, "");
if (reply.content.trim()) {
return reply;
}
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
return reply;
}
......@@ -803,6 +802,10 @@ export class GatewayClient {
} catch {
}
if (pending?.accumulatedText.trim()) {
return this.buildAccumulatedChatMessage(pending.sessionKey, runId, pending.accumulatedText);
}
return reply;
}
......@@ -876,10 +879,12 @@ export class GatewayClient {
pending.reject(error);
}
private buildChatMessage(runId: string, payload: Record<string, unknown>): ChatMessage {
private buildChatMessage(runId: string, payload: Record<string, unknown>, fallbackContent?: string): ChatMessage {
const pending = this.pendingChatRuns.get(runId);
const message = this.findRecordDeep(payload, ["message"]);
const content = this.extractTextCandidate(message) ?? pending?.accumulatedText ?? "";
const completedContent = this.extractTextCandidate(message);
const fallbackText = fallbackContent ?? pending?.accumulatedText ?? "";
const content = completedContent?.trim() ? completedContent : fallbackText;
const timestamp = this.findNumberDeep(message ?? payload, ["timestamp", "createdAt", "created_at"]);
return {
id: `${pending?.sessionKey ?? "session"}:${runId}:final`,
......@@ -890,6 +895,15 @@ export class GatewayClient {
};
}
private buildAccumulatedChatMessage(sessionKey: string, runId: string, content: string): ChatMessage {
return {
id: `${sessionKey}:${runId}:accumulated`,
role: "assistant",
content,
createdAt: new Date().toISOString()
};
}
private normalizeChatRole(role: unknown): MessageRole {
if (role === "system" || role === "user" || role === "assistant" || role === "tool" || role === "toolResult") {
return role;
......
......@@ -14,3 +14,20 @@ test("gateway client only sends cancel RPC when gateway advertises chat cancel",
assert.match(gatewaySource, /availableMethods.*chat\.cancel/s)
assert.match(gatewaySource, /this\.request\("chat\.cancel"/)
})
test("gateway client builds completed messages from payload before stream fallback", () => {
assert.match(gatewaySource, /const completedContent = this\.extractTextCandidate\(message\)/)
assert.match(gatewaySource, /const fallbackText = fallbackContent \?\? pending\?\.accumulatedText \?\? ""/)
assert.match(gatewaySource, /const content = completedContent\?\.trim\(\)\s*\?\s*completedContent\s*:\s*fallbackText/s)
assert.match(gatewaySource, /return reply;/)
})
test("gateway client resolves final replies by payload then history then stream", () => {
assert.match(gatewaySource, /const reply = this\.buildChatMessage\(runId, payload, ""\)/)
assert.match(gatewaySource, /if \(reply\.content\.trim\(\)\) \{\s*return reply;?\s*\}/s)
assert.match(gatewaySource, /const assistant = \[\.\.\.history\]\.reverse\(\)\.find\(\(message\) => message\.role === "assistant" && message\.content\.trim\(\)\)/)
assert.match(gatewaySource, /return assistant/)
assert.match(gatewaySource, /if \(pending\?\.accumulatedText\.trim\(\)\) \{\s*return this\.buildAccumulatedChatMessage\(pending\.sessionKey, runId, pending\.accumulatedText\);?\s*\}/s)
assert.doesNotMatch(gatewaySource, /chooseLongestChatMessage/)
assert.doesNotMatch(gatewaySource, /selectLongestText/)
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment