Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,6 @@ const CreateEvaluator = ({
}, 100)
}, [])

const normalizeTags = (input: unknown): string[] | undefined => {
if (input == null) return undefined
if (Array.isArray(input)) return []
if (typeof input === "object") return Object.keys(input as Record<string, unknown>)
return []
}

const toMetrics = (formMetrics: MetricFormData[]): HumanEvaluatorMetric[] =>
formMetrics.map(({name, type, optional, minimum, maximum, ...rest}) => ({
name,
Expand Down Expand Up @@ -206,7 +199,8 @@ const CreateEvaluator = ({
description: values.evaluatorDescription,
metrics,
meta: evaluatorWithMeta.meta as Record<string, unknown> | undefined,
tags: normalizeTags(evaluatorWithMeta.tags),
// The tag map as stored: the edit replaces it, and it is a map, not a list.
tags: evaluatorWithMeta.tags,
})
message.success("Evaluator updated successfully")
await onSuccess?.(values.evaluatorSlug)
Expand Down
13 changes: 8 additions & 5 deletions web/packages/agenta-entities/src/workflow/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,7 @@ export interface CreateWorkflowPayload {
name: string
description?: string | null
flags?: WorkflowRoleFlags
tags?: string[] | null
tags?: Workflow["tags"]
meta?: Record<string, unknown> | null
/** Commit message for the initial revision */
message?: string | null
Expand Down Expand Up @@ -1036,7 +1036,7 @@ export interface UpdateWorkflowPayload {
name?: string | null
description?: string | null
flags?: WorkflowRoleFlags
tags?: string[] | null
tags?: Workflow["tags"]
meta?: Record<string, unknown> | null
/** Commit message for the new revision */
message?: string | null
Expand All @@ -1057,10 +1057,13 @@ export async function updateWorkflow(
projectId: string,
payload: UpdateWorkflowPayload,
): Promise<Workflow> {
// Update workflow metadata if non-data fields changed. Description is checked for presence,
// not truth: an empty string is how a description is cleared.
// Update workflow metadata if non-data fields changed. Description and tags are checked for
// presence, not truth: an empty string clears a description, `null` clears the tags.
const hasMetadataChanges =
payload.name || payload.description !== undefined || payload.flags || payload.tags
payload.name ||
payload.description !== undefined ||
payload.flags ||
payload.tags !== undefined
if (hasMetadataChanges) {
await axios.put(
`${getAgentaApiUrl()}/workflows/${payload.id}`,
Expand Down
5 changes: 4 additions & 1 deletion web/packages/agenta-entities/src/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,12 @@ export {
workflowAppTypeAtomFamily,
workflowLatestRevisionQueryAtomFamily,
agTypeSchemaAtomFamily,
// Agent icon (per-agent glyph + colour, persisted client-side)
// Agent icon (per-agent glyph + colour, stored on the artifact's tags)
agentIconAtomFamily,
readAgentIconTag,
withAgentIconTag,
type AgentIconRecord,
type AgentIconSetting,
readPersistedAgentType,
// Artifact (workflow-level container — entity display name)
workflowArtifactQueryAtomFamily,
Expand Down
277 changes: 192 additions & 85 deletions web/packages/agenta-entities/src/workflow/state/agentIcon.ts
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])

Copy link
Copy Markdown
Contributor

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:

sed -n '40,220p' web/packages/agenta-entities/src/workflow/state/agentIcon.ts
sed -n '150,210p' web/packages/agenta-entities/tests/unit/agent-icon.test.ts

Repository: Agenta-AI/agenta

Length of output: 10204


🏁 Script executed:

sed -n '1,170p' web/packages/agenta-entities/tests/unit/agent-icon.test.ts
printf '\n--- legacy references ---\n'
rg -n -C 5 'legacy|localStorage|agent-icon:1|clear.*icon|icon.*clear' web/packages/agenta-entities/tests web/packages/agenta-entities/src/workflow/state/agentIcon.ts

Repository: Agenta-AI/agenta

Length of output: 50372


Persist an explicit clear or migration marker.

When a successful null write removes @ag.icon, agentIconSettingAtomFamily falls back to readLegacySetting. 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.

}),
)

/** 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})
Comment thread
ashrafchowdury marked this conversation as resolved.
Comment thread
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"]})
},
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,7 @@ export interface UpdateHumanEvaluatorParams {
/** Existing meta to preserve */
meta?: Record<string, unknown>
/** Existing tags to preserve */
tags?: string[]
tags?: Workflow["tags"]
}

/**
Expand Down
Loading
Loading