fix(web): focus newly created automations after save (#3035)

Expand and briefly highlight the saved routine row so users can
review it immediately. Extract newest-first sort helper and add
regression tests for list ordering and post-create focus.
This commit is contained in:
吴杨帆 2026-05-27 12:44:38 +08:00 committed by GitHub
parent 8264a7c4b1
commit 17c78f64a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 242 additions and 5 deletions

View file

@ -2,7 +2,7 @@
// and live artifact refreshers. The daemon still stores these as routines;
// the UI presents them as scheduled agent conversations.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
AutomationContentPacket,
AutomationEvolutionProposal,
@ -411,6 +411,8 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
const [ingestingSource, setIngestingSource] = useState(false);
const [crystallizingRunId, setCrystallizingRunId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [focusRoutineId, setFocusRoutineId] = useState<string | null>(null);
const routineRowRefs = useRef<Record<string, HTMLLIElement | null>>({});
const [historyTick, setHistoryTick] = useState(0);
const templates = useMemo(
@ -490,10 +492,18 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
// Sort routines by creation time, newest first
const sortedRoutines = useMemo(
() => [...routines].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
() => sortRoutinesNewestFirst(routines),
[routines],
);
useEffect(() => {
if (!focusRoutineId) return;
const node = routineRowRefs.current[focusRoutineId];
node?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
const timer = window.setTimeout(() => setFocusRoutineId(null), 4000);
return () => window.clearTimeout(timer);
}, [focusRoutineId, sortedRoutines]);
const activeCount = sortedRoutines.filter((routine) => routine.enabled).length;
const pausedCount = sortedRoutines.length - activeCount;
const sourceIngestionTemplates = useMemo(
@ -730,7 +740,11 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
return (
<li
key={r.id}
className={`automation-row${r.enabled ? '' : ' is-paused'}`}
ref={(node) => {
routineRowRefs.current[r.id] = node;
}}
data-testid={`automation-row-${r.id}`}
className={`automation-row${r.enabled ? '' : ' is-paused'}${focusRoutineId === r.id ? ' is-focused' : ''}`}
>
<div className="automation-row__main">
<span className="automation-row__icon">
@ -1124,14 +1138,22 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
skills={skills}
connectors={connectors}
onClose={() => setModal(null)}
onSaved={() => {
void refresh();
onSaved={(routine) => {
void (async () => {
await refresh();
setExpandedId(routine.id);
setFocusRoutineId(routine.id);
})();
}}
/>
</section>
);
}
export function sortRoutinesNewestFirst(routines: Routine[]): Routine[] {
return [...routines].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="automations-metric">

View file

@ -287,6 +287,13 @@
opacity: 0.74;
}
.automation-row.is-focused {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent),
var(--shadow-xs);
}
.automation-row__main {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);

View file

@ -0,0 +1,208 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Routine } from '@open-design/contracts';
import { sortRoutinesNewestFirst, TasksView } from '../../src/components/TasksView';
const originalFetch = globalThis.fetch;
function makeRoutine(overrides: Partial<Routine> & Pick<Routine, 'id' | 'name'>): Routine {
return {
prompt: 'Run scheduled work.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: null,
lastRun: null,
createdAt: 1000,
updatedAt: 1000,
...overrides,
};
}
vi.mock('../../src/components/NewAutomationModal', () => ({
NewAutomationModal: ({
open,
onSaved,
}: {
open: boolean;
onSaved: (routine: Routine) => void;
}) => {
if (!open) return null;
return (
<button
type="button"
data-testid="mock-save-routine"
onClick={() =>
onSaved(
makeRoutine({
id: 'routine-new',
name: 'Fresh automation',
createdAt: 9000,
updatedAt: 9000,
}),
)
}
>
Mock save
</button>
);
},
describeScheduleSummary: () => 'Daily at 9:00',
}));
function mockTasksFetch(routines: Routine[]) {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates') {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review') {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3') {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-new/runs?limit=10') {
return new Response(JSON.stringify({ runs: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
}
describe('TasksView routine ordering and focus', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('sortRoutinesNewestFirst orders by createdAt descending', () => {
const older = makeRoutine({ id: 'older', name: 'Older', createdAt: 1000 });
const newer = makeRoutine({ id: 'newer', name: 'Newer', createdAt: 5000 });
expect(sortRoutinesNewestFirst([older, newer]).map((routine) => routine.id)).toEqual([
'newer',
'older',
]);
});
it('renders saved automations newest first', async () => {
mockTasksFetch([
makeRoutine({ id: 'older', name: 'Older automation', createdAt: 1000 }),
makeRoutine({ id: 'newer', name: 'Newer automation', createdAt: 5000 }),
]);
render(<TasksView />);
const rows = await screen.findAllByRole('listitem');
expect(rows[0]?.textContent).toContain('Newer automation');
expect(rows[1]?.textContent).toContain('Older automation');
});
it('focuses and expands a routine after create save', async () => {
const existingRoutine = makeRoutine({
id: 'routine-existing',
name: 'Existing automation',
createdAt: 1000,
});
const savedRoutine = makeRoutine({
id: 'routine-new',
name: 'Fresh automation',
createdAt: 9000,
updatedAt: 9000,
});
let routinesFetchCount = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
routinesFetchCount += 1;
const routines =
routinesFetchCount >= 2 ? [savedRoutine, existingRoutine] : [existingRoutine];
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates') {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review') {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3') {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-new/runs?limit=10') {
return new Response(JSON.stringify({ runs: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
await screen.findByText('Existing automation');
fireEvent.click(screen.getByTestId('automations-new'));
fireEvent.click(screen.getByTestId('mock-save-routine'));
const focusedRow = await waitFor(() => {
const row = screen.getByTestId('automation-row-routine-new');
expect(row.className).toContain('is-focused');
return row;
});
expect(focusedRow.textContent).toContain('Fresh automation');
expect(screen.getByRole('button', { name: 'Hide history' })).toBeTruthy();
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
});
});