-
Notifications
You must be signed in to change notification settings - Fork 675
[AGE-4141] feat(frontend): Persist an agent's icon on its workflow artifact #6922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ashrafchowdury
merged 3 commits into
release/v0.118.5
from
feat/persist-agent-icon-on-artifact
Sep 17, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
4fa6f05
feat(entities): an agent's icon lives on its workflow artifact, under…
ashrafchowdury 174ee4a
fix(entities): a catalog chunk that fails to load leaves agents on th…
ashrafchowdury eac62be
fix(entities): a pick refuses to save without its artifact, and refil…
ashrafchowdury File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
277 changes: 192 additions & 85 deletions
277
web/packages/agenta-entities/src/workflow/state/agentIcon.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,119 +1,226 @@ | ||
| /** | ||
| * Per-agent icon and colour, chosen from the picker and persisted client-side. | ||
| * Per-agent icon and colour, stored on the workflow ARTIFACT as `tags["@ag"].icon = {name, color}` | ||
| * so every device and teammate sees one identity. Only name and colour travel; the glyph is | ||
| * looked up by name in the generated catalog, so no SVG markup is stored or rendered from data. | ||
| * | ||
| * Client-side only for now: the backend home is the workflow ARTIFACT's `meta`, but writing it | ||
| * needs the update guard in `workflow/api/api.ts` fixed first (it ignores meta-only changes). Until | ||
| * then localStorage keeps the choice, keyed by workflow id. | ||
| * | ||
| * `agentIconAtomFamily` is the ONLY public seam. Keep the storage map private — it is a localStorage | ||
| * shape with no meaning once the data lives on the artifact, and exporting it would be a second seam | ||
| * the backend swap has to preserve. | ||
| * `agentIconAtomFamily` is the ONLY public seam. Its write is optimistic and never rejects, so a | ||
| * call site can hand it straight to the picker's `onChange`. | ||
| */ | ||
| import {isHexColor, loadAgentIconCatalog} from "@agenta/ui/agent-icon" | ||
| import {message} from "@agenta/ui/app-message" | ||
| import {atom} from "jotai" | ||
| import {atomFamily, atomWithStorage} from "jotai/utils" | ||
| import {atomWithRefresh, atomWithStorage, unwrap} from "jotai/utils" | ||
| import {atomFamily} from "jotai-family" | ||
| import {queryClientAtom} from "jotai-tanstack-query" | ||
|
|
||
| import {queryWorkflows, updateWorkflow} from "../api" | ||
| import type {Workflow} from "../core" | ||
|
|
||
| import {writeBounded} from "./boundedMap" | ||
| import { | ||
| appWorkflowsListQueryAtom, | ||
| patchWorkflowArtifactCaches, | ||
| workflowArtifactQueryAtomFamily, | ||
| workflowProjectIdAtom, | ||
| } from "./store" | ||
|
|
||
| /** What the surfaces consume. `path` is the glyph's inner SVG, resolved from the catalog. */ | ||
| export interface AgentIconRecord { | ||
| /** Kebab-case Phosphor name. */ | ||
| icon: string | ||
| color: string | ||
| /** Inner SVG markup for the glyph, cached from the catalog so a cold render paints at once. */ | ||
| path: string | ||
| } | ||
|
|
||
| const STORAGE_KEY = "agenta:agent-icon:1" | ||
| /** Each record carries ~0.5 KB of SVG, and this is re-serialized on every write — bound it to a | ||
| * realistic number of agents rather than a round one. */ | ||
| const MAX_ENTRIES = 50 | ||
|
|
||
| /** The shapes the generator emits, and only quoted attributes after the tag name. */ | ||
| const SVG_SHAPE = | ||
| /^<(?:path|circle|rect|line|polyline|polygon|ellipse|g)((?:\s+[a-zA-Z-]+=(?:"[^"]*"|'[^']*'))*)\s*\/?>$/ | ||
| const SVG_ATTR = /\s+([a-zA-Z-]+)=(?:"[^"]*"|'[^']*')/g | ||
|
|
||
| /** Geometry and presentation only. The allowlist is what keeps `onload` and friends out. */ | ||
| const SVG_ATTRS = new Set([ | ||
| "d", | ||
| "cx", | ||
| "cy", | ||
| "r", | ||
| "rx", | ||
| "ry", | ||
| "x", | ||
| "y", | ||
| "x1", | ||
| "y1", | ||
| "x2", | ||
| "y2", | ||
| "width", | ||
| "height", | ||
| "points", | ||
| "transform", | ||
| "fill", | ||
| "fill-rule", | ||
| "fill-opacity", | ||
| "clip-rule", | ||
| "opacity", | ||
| "stroke", | ||
| "stroke-width", | ||
| "stroke-linecap", | ||
| "stroke-linejoin", | ||
| ]) | ||
|
|
||
| const isSvgShape = (tag: string): boolean => { | ||
| const match = SVG_SHAPE.exec(tag) | ||
| if (!match) return false | ||
| return [...match[1].matchAll(SVG_ATTR)].every(([, name]) => SVG_ATTRS.has(name)) | ||
| /** What is stored on the artifact, under `tags["@ag"].icon`. */ | ||
| export interface AgentIconSetting { | ||
| /** Kebab-case Phosphor name. */ | ||
| name: string | ||
| color: string | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // TAG SHAPE | ||
| // ============================================================================ | ||
|
|
||
| const AG_TAG = "@ag" | ||
| const ICON_KEY = "icon" | ||
|
|
||
| type TagMap = NonNullable<Workflow["tags"]> | ||
|
|
||
| const asRecord = (value: unknown): Record<string, unknown> | null => | ||
| value && typeof value === "object" && !Array.isArray(value) | ||
| ? (value as Record<string, unknown>) | ||
| : null | ||
|
|
||
| const toSetting = (name: unknown, color: unknown): AgentIconSetting | null => | ||
| typeof name === "string" && name && typeof color === "string" && isHexColor(color) | ||
| ? {name, color} | ||
| : null | ||
|
|
||
| /** The icon on a tag map, or null when there is none or it is malformed. */ | ||
| export const readAgentIconTag = (tags: Workflow["tags"]): AgentIconSetting | null => { | ||
| const icon = asRecord(asRecord(tags?.[AG_TAG])?.[ICON_KEY]) | ||
| return icon ? toSetting(icon.name, icon.color) : null | ||
| } | ||
|
|
||
| /** | ||
| * localStorage is outside the trust boundary and `path` reaches `dangerouslySetInnerHTML`, so an | ||
| * entry is validated element by element and attribute by attribute. | ||
| * The tag map with `@ag.icon` set, or removed for `null`. Every other key — outside and inside | ||
| * `@ag` — is kept: the artifact edit REPLACES tags, so the whole map goes back. An emptied map | ||
| * is `{}`, never null: the edit drops a null field and would leave the old tags in place. | ||
| */ | ||
| export const isAgentIconPath = (path: string): boolean => { | ||
| if (!path.startsWith("<")) return false | ||
| const tags = path.match(/<[^>]*>/g) | ||
| // Only whitespace may sit between shapes; any other text means it is not a generated glyph. | ||
| if (!tags || tags.join("") !== path.replace(/>\s+</g, "><")) return false | ||
| return tags.every(isSvgShape) | ||
| export const withAgentIconTag = ( | ||
| tags: Workflow["tags"], | ||
| setting: AgentIconSetting | null, | ||
| ): TagMap => { | ||
| const {[AG_TAG]: ag, ...rest} = tags ?? {} | ||
| const {[ICON_KEY]: _icon, ...agRest} = asRecord(ag) ?? {} | ||
| const nextAg = setting ? {...agRest, [ICON_KEY]: {...setting}} : agRest | ||
| return Object.keys(nextAg).length > 0 ? {...rest, [AG_TAG]: nextAg} : {...rest} | ||
| } | ||
|
|
||
| /** Exported for its own tests — a validator, not a second storage seam. */ | ||
| export const isAgentIconRecord = (value: unknown): value is AgentIconRecord => { | ||
| const r = value as AgentIconRecord | null | ||
| return ( | ||
| !!r && | ||
| typeof r === "object" && | ||
| typeof r.icon === "string" && | ||
| typeof r.color === "string" && | ||
| typeof r.path === "string" && | ||
| isAgentIconPath(r.path) | ||
| ) | ||
| } | ||
| // ============================================================================ | ||
| // GLYPHS | ||
| // ============================================================================ | ||
|
|
||
| const NO_GLYPHS: ReadonlyMap<string, string> = new Map() | ||
|
|
||
| /** Name → inner SVG, read only once a custom icon is on screen. Refreshable: see the write below. */ | ||
| const glyphsSourceAtom = atomWithRefresh(async (): Promise<ReadonlyMap<string, string>> => { | ||
| // A chunk that fails to load leaves every agent on its fallback glyph, not in an error boundary. | ||
| const catalog = await loadAgentIconCatalog().catch(() => []) | ||
| return new Map(catalog.map((entry) => [entry.name, entry.path] as const)) | ||
| }) | ||
|
|
||
| const agentIconMapAtom = atomWithStorage<Record<string, AgentIconRecord>>( | ||
| STORAGE_KEY, | ||
| /** Unwrapped so a read never suspends; a refresh keeps the last map until the new one lands. */ | ||
| const glyphsAtom = unwrap(glyphsSourceAtom, (prev) => prev ?? NO_GLYPHS) | ||
|
|
||
| // ============================================================================ | ||
| // LEGACY BROWSER VALUE | ||
| // ============================================================================ | ||
|
|
||
| /** Where the choice lived before it moved to the artifact. Read as a fallback, never written. */ | ||
| const legacyIconMapAtom = atomWithStorage<Record<string, unknown>>( | ||
| "agenta:agent-icon:1", | ||
| {}, | ||
| undefined, | ||
| {getOnInit: true}, | ||
| ) | ||
|
|
||
| const readLegacySetting = (value: unknown): AgentIconSetting | null => { | ||
| const record = asRecord(value) | ||
| return record ? toSetting(record.icon, record.color) : null | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // THE SEAM | ||
| // ============================================================================ | ||
|
|
||
| /** | ||
| * The pick in flight, per agent, read ahead of the caches. Synchronous on purpose: the query | ||
| * observer notifies a tick late, and a colour picked in that tick would build on the old icon. | ||
| */ | ||
| const pendingIconAtom = atom<Record<string, {seq: number; setting: AgentIconSetting | null}>>({}) | ||
| let pendingSeq = 0 | ||
|
|
||
| /** One save at a time per agent, so two quick picks reach the server in the order made. */ | ||
| const saveChain = new Map<string, Promise<unknown>>() | ||
|
|
||
| /** The stored icon: the apps list, then a by-id fetch for an agent it lacks, then the legacy value. */ | ||
| const agentIconSettingAtomFamily = atomFamily((workflowId: string) => | ||
| atom((get): AgentIconSetting | null => { | ||
| const pending = get(pendingIconAtom)[workflowId] | ||
| if (pending) return pending.setting | ||
| const list = get(appWorkflowsListQueryAtom) | ||
| const ref = list.data?.refs.find((candidate) => candidate.id === workflowId) | ||
| let tags: Workflow["tags"] | ||
| if (ref) { | ||
| tags = ref.tags | ||
| } else if (list.isPending) { | ||
| // Wait for the list rather than fire a by-id fetch per agent on first paint. | ||
| return null | ||
| } else { | ||
| tags = get(workflowArtifactQueryAtomFamily(workflowId)).data?.tags | ||
| } | ||
| return readAgentIconTag(tags) ?? readLegacySetting(get(legacyIconMapAtom)?.[workflowId]) | ||
| }), | ||
| ) | ||
|
|
||
| /** Read/write one agent's icon. Writing `null` clears it back to the default chrome. */ | ||
| export const agentIconAtomFamily = atomFamily((workflowId: string) => | ||
| atom( | ||
| (get) => { | ||
| (get): AgentIconRecord | null => { | ||
| if (!workflowId) return null | ||
| // A literal `null` in localStorage reaches us as null, not the default. | ||
| const stored = get(agentIconMapAtom)?.[workflowId] | ||
| return isAgentIconRecord(stored) ? stored : null | ||
| const setting = get(agentIconSettingAtomFamily(workflowId)) | ||
| if (!setting) return null | ||
| const path = get(glyphsAtom).get(setting.name) | ||
| return path ? {icon: setting.name, color: setting.color, path} : null | ||
| }, | ||
| (get, set, next: AgentIconRecord | null) => { | ||
| if (!workflowId) return | ||
| set( | ||
| agentIconMapAtom, | ||
| writeBounded(get(agentIconMapAtom) ?? {}, workflowId, next, MAX_ENTRIES), | ||
| async (get, set, next: AgentIconRecord | null) => { | ||
| const projectId = get(workflowProjectIdAtom) | ||
| if (!workflowId || !projectId) return | ||
| const setting = next ? {name: next.icon, color: next.color} : null | ||
|
|
||
| const seq = ++pendingSeq | ||
| set(pendingIconAtom, (all) => ({...all, [workflowId]: {seq, setting}})) | ||
| // Only the latest pick lifts the overlay; an earlier one landing must not expose a stale cache. | ||
| const settle = () => { | ||
| if (get(pendingIconAtom)[workflowId]?.seq !== seq) return | ||
| set(pendingIconAtom, ({[workflowId]: _mine, ...rest}) => rest) | ||
| } | ||
|
|
||
| const save = async (): Promise<TagMap> => { | ||
| // Merge into the LATEST tags, not the cached ones: the edit replaces the whole map. | ||
| const latest = await queryWorkflows({ | ||
| projectId, | ||
| workflowRefs: [{id: workflowId}], | ||
| includeArchived: true, | ||
| }) | ||
| const current = latest.workflows?.find((workflow) => workflow.id === workflowId) | ||
| // Without the artifact there is nothing to merge into: writing an icon-only map | ||
| // would replace every other tag. Let the catch path roll the pick back. | ||
| if (!current) throw new Error(`[agentIcon] workflow ${workflowId} not found`) | ||
| const tags = withAgentIconTag(current.tags, setting) | ||
| await updateWorkflow(projectId, {id: workflowId, tags}) | ||
|
ashrafchowdury marked this conversation as resolved.
ashrafchowdury marked this conversation as resolved.
|
||
| return tags | ||
| } | ||
| const run = (saveChain.get(workflowId) ?? Promise.resolve()).then(save, save) | ||
| const link = run | ||
| .catch(() => undefined) | ||
| .then(() => { | ||
| if (saveChain.get(workflowId) === link) saveChain.delete(workflowId) | ||
| }) | ||
| saveChain.set(workflowId, link) | ||
|
|
||
| let tags: TagMap | ||
| try { | ||
| tags = await run | ||
| } catch { | ||
| // Lifting the overlay is the rollback: the caches still hold the value before the pick. | ||
| settle() | ||
| message.error("Couldn't save the agent icon") | ||
| return | ||
| } | ||
| // The server's watch event refetches these lists; the patch covers the gap until it lands. | ||
| patchWorkflowArtifactCaches( | ||
| get(queryClientAtom), | ||
| projectId, | ||
| workflowId, | ||
| (workflow) => ({ | ||
| ...workflow, | ||
| tags, | ||
| }), | ||
| ) | ||
| // The picker just loaded the catalog to make this pick, so a map left empty by an | ||
| // earlier failed load can fill in now instead of staying empty for the session. | ||
| if (get(glyphsAtom).size === 0) set(glyphsSourceAtom) | ||
| settle() | ||
| const legacy = asRecord(get(legacyIconMapAtom)) ?? {} | ||
| if (workflowId in legacy) { | ||
| const {[workflowId]: _legacy, ...rest} = legacy | ||
| set(legacyIconMapAtom, rest) | ||
| } | ||
| // The mobile archived list reads its own query; let it catch up on its own. | ||
| void get(queryClientAtom).invalidateQueries({queryKey: ["agent-workflows"]}) | ||
| }, | ||
| ), | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: Agenta-AI/agenta
Length of output: 10204
🏁 Script executed:
Repository: Agenta-AI/agenta
Length of output: 50372
Persist an explicit clear or migration marker.
When a successful
nullwrite removes@ag.icon,agentIconSettingAtomFamilyfalls back toreadLegacySetting. The write removes the legacy entry only from the current browser. Another browser with the old localStorage value can render the stale icon because the artifact has no marker that distinguishes an explicit clear from an unmigrated workflow.Use the legacy value only when the artifact has no migration marker. Persist that marker when the icon is cleared.