fix(web): remove Ingest source panel from Automations tab (#2711)

* fix(web): remove Ingest source panel from Automations tab

The Automations tab carried a free-form "Ingest source" composer that
let users paste arbitrary content (URL, repo path, connector event,
chat snippet) and turn it into a source packet plus evolution proposals.
The form was confusing next to the routine/template flow on the same
page, exposed an internal canonicalization concept users don't need to
think about, and shipped before the surrounding evolution-proposal flow
was wired into a coherent end-to-end story.

Drop the UI surface only:

- Remove the <section className="automations-ingest"> block, the
  Template / Source / Compression / Connector selects, the title/source
  ref/content fields, the recent-packets list, and the Ingest button.
- Drop the now-dead local state (sourcePackets / sourceForm /
  ingestingSource), the patchSourceForm and submitSourceIngestion
  helpers, the SOURCE_KIND_OPTIONS / COMPRESSION_OPTIONS constants, the
  SourceIngestionForm type and DEFAULT_SOURCE_FORM, the
  /api/automation-source-packets refresh leg, and the sourcePackets
  side-write inside crystallizeRun.
- Remove the matching .automations-ingest / .automation-ingest-* CSS
  block (plus the two responsive overrides) from tasks.css.
- Delete the test case that drove the form in TasksView.templates.test.

Backend stays intact: apps/daemon/src/automation-ingestions.ts, the
POST /api/automation-ingestions route, `od automation ingest` CLI, the
routine-evolution call site, and the AutomationContentPacket /
AutomationSourceKind / AutomationTokenCompressionMode contracts all
remain, since routine scheduling still depends on them.

* fix(web): drop crystallize test assertion on removed packet list

The crystallize test was asserting that the new content packet's title
shows up on the page. That assertion only passed because the daemon
response was being side-written into the deleted sourcePackets state
and rendered in the Ingest source recent-packets strip. With that UI
removed, the packet title has no surface to land on; the proposal title
(`Skill: Artifact polish loop run`) is still asserted and remains the
real signal that crystallize succeeded.
This commit is contained in:
lefarcen 2026-05-22 17:53:27 +08:00 committed by GitHub
parent 10e11531a1
commit 5f939ce601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1 additions and 494 deletions

View file

@ -4,15 +4,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type {
AutomationContentPacket,
AutomationEvolutionProposal,
AutomationEvolutionProposalListResponse,
AutomationSourceIngestionResponse,
AutomationSourceKind,
AutomationSourcePacketListResponse,
AutomationTemplate as ContractAutomationTemplate,
AutomationTemplateListResponse,
AutomationTokenCompressionMode,
ConnectorDetail,
Routine,
RoutineRun,
@ -162,41 +157,6 @@ const TEMPLATE_FILTERS: ReadonlyArray<{ id: TemplateFilter; label: string }> = [
{ id: 'quality', label: 'Quality' },
];
const SOURCE_KIND_OPTIONS: ReadonlyArray<{ id: AutomationSourceKind; label: string }> = [
{ id: 'connector', label: 'Connector' },
{ id: 'url', label: 'URL' },
{ id: 'repo', label: 'Repo' },
{ id: 'artifact', label: 'Artifact' },
{ id: 'chat', label: 'Chat' },
{ id: 'upload', label: 'Upload' },
];
const COMPRESSION_OPTIONS: ReadonlyArray<{ id: AutomationTokenCompressionMode; label: string }> = [
{ id: 'balanced', label: 'Balanced' },
{ id: 'aggressive', label: 'Aggressive' },
{ id: 'off', label: 'Off' },
];
type SourceIngestionForm = {
templateId: string;
sourceKind: AutomationSourceKind;
sourceRef: string;
title: string;
bodyMarkdown: string;
connectorId: string;
tokenCompression: AutomationTokenCompressionMode;
};
const DEFAULT_SOURCE_FORM: SourceIngestionForm = {
templateId: 'ingest-source-memory-tree',
sourceKind: 'connector',
sourceRef: '',
title: '',
bodyMarkdown: '',
connectorId: '',
tokenCompression: 'balanced',
};
function scheduleStatusLabel(routine: Routine): string {
if (!routine.enabled) return 'Paused';
return describeScheduleSummary(routine.schedule);
@ -405,10 +365,7 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
const [templateFilter, setTemplateFilter] = useState<TemplateFilter>('all');
const [automationCatalog, setAutomationCatalog] = useState<ContractAutomationTemplate[]>([]);
const [proposals, setProposals] = useState<AutomationEvolutionProposal[]>([]);
const [sourcePackets, setSourcePackets] = useState<AutomationContentPacket[]>([]);
const [sourceForm, setSourceForm] = useState<SourceIngestionForm>(DEFAULT_SOURCE_FORM);
const [proposalBusyId, setProposalBusyId] = useState<string | null>(null);
const [ingestingSource, setIngestingSource] = useState(false);
const [crystallizingRunId, setCrystallizingRunId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [historyTick, setHistoryTick] = useState(0);
@ -436,18 +393,11 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
return (await res.json()) as AutomationEvolutionProposalListResponse;
})
.catch(() => null);
const sourcePacketRequest = fetch('/api/automation-source-packets?limit=3')
.then(async (res) => {
if (!res.ok) return null;
return (await res.json()) as AutomationSourcePacketListResponse;
})
.catch(() => null);
const [rRes, pRes, tJson, proposalJson, sourcePacketJson] = await Promise.all([
const [rRes, pRes, tJson, proposalJson] = await Promise.all([
fetch('/api/routines'),
fetch('/api/projects'),
templateRequest,
proposalRequest,
sourcePacketRequest,
]);
if (!rRes.ok) throw new Error(`routines: ${rRes.status}`);
const rJson = await rRes.json();
@ -467,9 +417,6 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
if (proposalJson) {
setProposals(Array.isArray(proposalJson.proposals) ? proposalJson.proposals : []);
}
if (sourcePacketJson) {
setSourcePackets(Array.isArray(sourcePacketJson.packets) ? sourcePacketJson.packets : []);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@ -490,61 +437,6 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
const activeCount = routines.filter((routine) => routine.enabled).length;
const pausedCount = routines.length - activeCount;
const sourceIngestionTemplates = useMemo(
() =>
automationCatalog.filter((template) =>
template.stages.some((stage) => stage.kind === 'ingest' || stage.kind === 'propose'),
),
[automationCatalog],
);
const patchSourceForm = (patch: Partial<SourceIngestionForm>) => {
setSourceForm((current) => ({ ...current, ...patch }));
};
const submitSourceIngestion = async () => {
if (!sourceForm.bodyMarkdown.trim()) {
setError('Paste source content before ingesting it.');
return;
}
setIngestingSource(true);
setError(null);
try {
const res = await fetch('/api/automation-ingestions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
templateId: sourceForm.templateId || undefined,
sourceKind: sourceForm.sourceKind,
sourceRef: sourceForm.sourceRef || undefined,
title: sourceForm.title || undefined,
bodyMarkdown: sourceForm.bodyMarkdown,
connectorId:
sourceForm.sourceKind === 'connector' && sourceForm.connectorId
? sourceForm.connectorId
: undefined,
tokenCompression: sourceForm.tokenCompression,
}),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `ingestion failed: ${res.status}`);
}
const json = (await res.json()) as AutomationSourceIngestionResponse;
setSourcePackets((current) => [json.packet, ...current].slice(0, 3));
setSourceForm((current) => ({
...current,
title: '',
sourceRef: '',
bodyMarkdown: '',
}));
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIngestingSource(false);
}
};
const reviewProposal = async (id: string, action: 'apply' | 'reject') => {
setProposalBusyId(id);
@ -607,8 +499,6 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `crystallize failed: ${res.status}`);
}
const json = (await res.json()) as RoutineRunCrystallizeResponse;
setSourcePackets((current) => [json.packet, ...current].slice(0, 3));
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@ -897,138 +787,6 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
</section>
) : null}
<section className="automations-ingest" aria-label="Source ingestion">
<div className="automations-section-head">
<div>
<h2 className="automations-section__label">Ingest source</h2>
<p className="automations-section__sub">
Turn connector, repo, artifact, or chat context into reviewable evolution proposals.
</p>
</div>
<span className="automations-section__meta">{sourcePackets.length} recent</span>
</div>
<div className="automation-ingest-panel">
<div className="automation-ingest-controls">
<label className="automation-ingest-field">
<span>Template</span>
<select
value={sourceForm.templateId}
onChange={(event) => patchSourceForm({ templateId: event.currentTarget.value })}
>
{sourceIngestionTemplates.length === 0 ? (
<option value={sourceForm.templateId}>{sourceForm.templateId}</option>
) : null}
{sourceIngestionTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.title}
</option>
))}
</select>
</label>
<label className="automation-ingest-field">
<span>Source</span>
<select
value={sourceForm.sourceKind}
onChange={(event) =>
patchSourceForm({ sourceKind: event.currentTarget.value as AutomationSourceKind })
}
>
{SOURCE_KIND_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
<label className="automation-ingest-field">
<span>Compression</span>
<select
value={sourceForm.tokenCompression}
onChange={(event) =>
patchSourceForm({
tokenCompression: event.currentTarget.value as AutomationTokenCompressionMode,
})
}
>
{COMPRESSION_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
{sourceForm.sourceKind === 'connector' ? (
<label className="automation-ingest-field">
<span>Connector</span>
<select
value={sourceForm.connectorId}
onChange={(event) => patchSourceForm({ connectorId: event.currentTarget.value })}
>
<option value="">Any connected source</option>
{connectors.map((connector) => (
<option key={connector.id} value={connector.id}>
{connector.name}
{connector.accountLabel ? ` · ${connector.accountLabel}` : ''}
</option>
))}
</select>
</label>
) : null}
</div>
<div className="automation-ingest-fields">
<label className="automation-ingest-field">
<span>Title</span>
<input
value={sourceForm.title}
onChange={(event) => patchSourceForm({ title: event.currentTarget.value })}
placeholder="Decision, brand notes, workflow pattern..."
/>
</label>
<label className="automation-ingest-field">
<span>Source ref</span>
<input
value={sourceForm.sourceRef}
onChange={(event) => patchSourceForm({ sourceRef: event.currentTarget.value })}
placeholder="URL, repo path, connector event id, artifact id..."
/>
</label>
</div>
<label className="automation-ingest-field automation-ingest-field--body">
<span>Content</span>
<textarea
value={sourceForm.bodyMarkdown}
onChange={(event) => patchSourceForm({ bodyMarkdown: event.currentTarget.value })}
placeholder="Paste the content to canonicalize into a source packet and proposals."
/>
</label>
<div className="automation-ingest-footer">
{sourcePackets.length > 0 ? (
<ul className="automation-ingest-recent" aria-label="Recent source packets">
{sourcePackets.map((packet) => (
<li key={packet.id}>
<span>{packet.title}</span>
<small>
{packet.sourceKind} · {packet.tokenStats.originalTokens} tokens
</small>
</li>
))}
</ul>
) : (
<span className="automation-ingest-empty">No source packets yet.</span>
)}
<button
type="button"
className="automations-view__new"
onClick={submitSourceIngestion}
disabled={ingestingSource}
>
<Icon name="sparkles" size={14} />
<span>{ingestingSource ? 'Ingesting' : 'Ingest'}</span>
</button>
</div>
</div>
</section>
<section className="automations-templates" aria-label="Automation templates">
<div className="automations-templates__head">
<div className="automations-templates__head-copy">

View file

@ -567,139 +567,6 @@
cursor: progress;
}
/* ---------- Source ingestion ---------- */
.automations-ingest {
display: flex;
flex-direction: column;
gap: 10px;
}
.automation-ingest-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
}
.automation-ingest-controls,
.automation-ingest-fields {
display: grid;
gap: 10px;
}
.automation-ingest-controls {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.automation-ingest-fields {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
.automation-ingest-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
color: var(--text-muted);
font-size: 11px;
font-weight: 750;
letter-spacing: 0;
text-transform: uppercase;
}
.automation-ingest-field input,
.automation-ingest-field select,
.automation-ingest-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--border);
border-radius: 8px;
background-color: var(--bg-subtle);
color: var(--text);
font: 12.5px/1.4 var(--font, system-ui);
}
.automation-ingest-field input,
.automation-ingest-field select {
height: 34px;
padding: 0 10px;
}
.automation-ingest-field textarea {
min-height: 116px;
padding: 10px;
resize: vertical;
}
.automation-ingest-field input:focus,
.automation-ingest-field select:focus,
.automation-ingest-field textarea:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent, #79a8ff) 44%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #79a8ff) 14%, transparent);
}
.automation-ingest-field--body {
text-transform: none;
}
.automation-ingest-field--body > span {
text-transform: uppercase;
}
.automation-ingest-footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
}
.automation-ingest-recent {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.automation-ingest-recent li {
display: flex;
max-width: 260px;
min-width: 0;
flex-direction: column;
gap: 2px;
padding: 7px 9px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-subtle) 72%, transparent);
}
.automation-ingest-recent span,
.automation-ingest-recent small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.automation-ingest-recent span {
color: var(--text);
font-size: 12px;
font-weight: 650;
}
.automation-ingest-recent small,
.automation-ingest-empty {
color: var(--text-muted);
font-size: 11.5px;
}
/* ---------- Template gallery ---------- */
.automations-templates {
@ -1676,10 +1543,6 @@
}
@media (max-width: 960px) {
.automation-ingest-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.automations-templates__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -1705,12 +1568,6 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.automation-ingest-controls,
.automation-ingest-fields,
.automation-ingest-footer {
grid-template-columns: 1fr;
}
.automations-templates__grid {
grid-template-columns: 1fr;
}

View file

@ -162,113 +162,6 @@ describe('TasksView automation templates', () => {
});
});
it('ingests pasted source content into source packets and proposals', async () => {
const postBodies: unknown[] = [];
let proposals: AutomationEvolutionProposal[] = [];
let packets = [] as Array<{
id: string;
sourceKind: string;
title: string;
capturedAt: string;
tokenStats: { originalTokens: number };
}>;
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' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [daemonTemplate] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-ingestions' && init?.method === 'POST') {
postBodies.push(JSON.parse(String(init.body)));
const packet = {
id: 'packet-1',
sourceKind: 'repo',
sourceRef: 'https://github.com/acme/design',
title: 'Acme source',
capturedAt: '2026-05-18T00:00:00.000Z',
bodyMarkdown: 'Primary color #335CFF',
provenance: [],
attachments: [],
sensitivity: 'workspace',
capabilityHints: [],
tokenStats: { originalTokens: 6 },
candidateSinks: ['memory', 'design-system'],
};
proposals = [{ ...memoryProposal, id: 'proposal-ingested-1', title: 'Memory: Acme source' }];
packets = [packet];
return new Response(JSON.stringify({
packet,
compressionReport: {
mode: 'balanced',
status: 'skipped',
beforeTokens: 6,
afterTokens: 6,
summary: 'Already compact',
preservedSourcePacketId: 'packet-1',
},
proposals,
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
await screen.findByText('Ingest source');
fireEvent.change(screen.getByLabelText('Title'), {
target: { value: 'Acme source' },
});
fireEvent.change(screen.getByLabelText('Source ref'), {
target: { value: 'https://github.com/acme/design' },
});
fireEvent.change(screen.getByLabelText('Content'), {
target: { value: 'Primary color #335CFF' },
});
fireEvent.click(screen.getByRole('button', { name: /^Ingest$/i }));
await waitFor(() => {
expect(postBodies).toHaveLength(1);
expect(postBodies[0]).toMatchObject({
templateId: 'ingest-source-memory-tree',
sourceKind: 'connector',
title: 'Acme source',
sourceRef: 'https://github.com/acme/design',
bodyMarkdown: 'Primary color #335CFF',
});
expect(screen.getByText('Memory: Acme source')).toBeTruthy();
expect(screen.getByText('Acme source')).toBeTruthy();
});
});
it('crystallizes a successful automation run into reviewable proposals', async () => {
const crystallizeCalls: string[] = [];
let proposals: AutomationEvolutionProposal[] = [];
@ -404,7 +297,6 @@ describe('TasksView automation templates', () => {
'/api/routines/routine-1/runs/run-succeeded-1/crystallize',
]);
expect(screen.getByText('Skill: Artifact polish loop run')).toBeTruthy();
expect(screen.getByText('Artifact polish loop run')).toBeTruthy();
});
});
});