Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between.
17 files+102−560/17 viewed
| 127 | 127 | conversation in a direct message or private channel, the General space in | |
| 128 | 128 | a public channel. | |
| 129 | 129 | - **With a doc named**, it attaches the file to that doc. The person who | |
| 130 | − | asked must be able to edit it. | |
| 130 | + | asked must be able to edit it; whether the space lets agents edit or only | |
| 131 | + | suggest makes no difference, since a file beside the doc changes nothing | |
| 132 | + | in it. | |
| 131 | 133 | ||
| 132 | 134 | Either way, the doc gets a link to the file, and the agent answers with the | |
| 133 | 135 | file's link. Files are served from the usercontent address |
| 180 | 180 | const installed = loader.data?.apps; | |
| 181 | 181 | const wanted = query.trim().toLowerCase(); | |
| 182 | 182 | const apps = (installed ?? []).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted)); | |
| 183 | + | const loading = installed == null && !loader.data; | |
| 184 | + | const failed = installed == null && !!loader.data; | |
| 183 | 185 | return ( | |
| 184 | 186 | <div className="flex flex-col gap-2"> | |
| 185 | − | {installed == null && loader.data ? ( | |
| 187 | + | {/* The search is there from the first frame, so the launcher keeps its shape while the apps are read. */} | |
| 188 | + | {(loading || (installed && installed.length > 0)) && ( | |
| 189 | + | <Card asChild tone="bg" radius="lg" className="flex h-9 items-center gap-2 px-2.5 text-sm focus-within:border-line-strong"> | |
| 190 | + | <label> | |
| 191 | + | <Search size={15} className="shrink-0 text-faint" /> | |
| 192 | + | <input | |
| 193 | + | value={query} | |
| 194 | + | onChange={(event) => setQuery(event.target.value)} | |
| 195 | + | placeholder="Find an app" | |
| 196 | + | aria-label="Find an app" | |
| 197 | + | autoComplete="off" | |
| 198 | + | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 199 | + | /> | |
| 200 | + | </label> | |
| 201 | + | </Card> | |
| 202 | + | )} | |
| 203 | + | {failed ? ( | |
| 186 | 204 | <p className="px-2 py-6 text-center text-sm text-muted">Apps can't be listed right now. Try again in a moment.</p> | |
| 187 | − | ) : installed == null ? ( | |
| 188 | − | <div className="grid grid-cols-4 gap-1 p-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps"> | |
| 205 | + | ) : loading ? ( | |
| 206 | + | <div className="grid grid-cols-4 gap-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps"> | |
| 189 | 207 | {Array.from({ length: 4 }, (_, n) => ( | |
| 190 | 208 | <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5"> | |
| 191 | 209 | <Skeleton className="size-10 rounded-xl" /> | |
| 192 | 210 | <Skeleton className="h-2.5 w-12" /> | |
| 211 | + | <Skeleton className="h-2 w-8" /> | |
| 193 | 212 | </div> | |
| 194 | 213 | ))} | |
| 195 | 214 | </div> | |
| 196 | − | ) : installed.length === 0 ? ( | |
| 215 | + | ) : installed!.length === 0 ? ( | |
| 197 | 216 | <NoApps slug={slug} onOpen={onClose} compact /> | |
| 217 | + | ) : apps.length === 0 ? ( | |
| 218 | + | <p className="px-2 py-6 text-center text-sm text-muted">No installed app matches.</p> | |
| 198 | 219 | ) : ( | |
| 199 | − | <> | |
| 200 | − | <Card asChild tone="bg" radius="lg" className="flex h-9 items-center gap-2 px-2.5 text-sm focus-within:border-line-strong"> | |
| 201 | − | <label> | |
| 202 | − | <Search size={15} className="shrink-0 text-faint" /> | |
| 203 | − | <input | |
| 204 | − | value={query} | |
| 205 | − | onChange={(event) => setQuery(event.target.value)} | |
| 206 | − | placeholder="Find an app" | |
| 207 | − | aria-label="Find an app" | |
| 208 | − | autoComplete="off" | |
| 209 | − | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 210 | − | /> | |
| 211 | − | </label> | |
| 212 | − | </Card> | |
| 213 | − | {apps.length === 0 ? ( | |
| 214 | − | <p className="px-2 py-6 text-center text-sm text-muted">No installed app matches.</p> | |
| 215 | − | ) : ( | |
| 216 | − | <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3"> | |
| 217 | − | {apps.map((app) => ( | |
| 218 | − | <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={onToggle} onOpen={onClose} /> | |
| 219 | − | ))} | |
| 220 | − | </div> | |
| 221 | − | )} | |
| 222 | − | </> | |
| 220 | + | <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3"> | |
| 221 | + | {apps.map((app) => ( | |
| 222 | + | <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={onToggle} onOpen={onClose} /> | |
| 223 | + | ))} | |
| 224 | + | </div> | |
| 223 | 225 | )} | |
| 224 | 226 | <div className="flex items-center justify-between border-t border-line pt-2"> | |
| 225 | 227 | <Link |
| 124 | 124 | export function SliceTabs({ current, scope, href }: { current: SliceKey; scope: SpendScope; href: (key: SliceKey) => string }) { | |
| 125 | 125 | const shown = SLICES.filter((s) => scope === "workspace" || !s.workspaceOnly); | |
| 126 | 126 | return ( | |
| 127 | − | <nav aria-label="Slice spend by" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1 [scrollbar-width:none]"> | |
| 127 | + | <nav aria-label="Slice spend by" className="-mx-1 -my-1 flex gap-1 overflow-x-auto p-1 [scrollbar-width:none]"> | |
| 128 | 128 | {shown.map((s) => ( | |
| 129 | 129 | <Link | |
| 130 | 130 | key={s.key} |
| 3 | 3 | ||
| 4 | 4 | import { cn } from "../../lib/cn"; | |
| 5 | 5 | ||
| 6 | − | // shadcn/ui's tooltip, styled with g1t's tokens. Each tooltip carries its | |
| 7 | − | // own provider, so one can be dropped in anywhere without setup. | |
| 6 | + | // shadcn/ui's tooltip, styled with g1t's tokens: the inverted bubble, the | |
| 7 | + | // page's foreground behind the page's background colour, so it reads at a | |
| 8 | + | // glance in either theme. Each tooltip carries its own provider, so one | |
| 9 | + | // can be dropped in anywhere without setup. | |
| 8 | 10 | ||
| 9 | 11 | export function TooltipProvider({ delayDuration = 200, ...props }: ComponentProps<typeof Primitive.Provider>) { | |
| 10 | 12 | return <Primitive.Provider delayDuration={delayDuration} {...props} />; | |
| ⋯ | |||
| 31 | 33 | <Primitive.Content | |
| 32 | 34 | sideOffset={sideOffset} | |
| 33 | 35 | className={cn( | |
| 34 | − | "z-50 max-w-xs rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs text-fg shadow-lg shadow-black/40", | |
| 36 | + | "z-50 max-w-xs rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg shadow-md text-balance", | |
| 35 | 37 | "origin-(--radix-tooltip-content-transform-origin) animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none", | |
| 36 | 38 | className, | |
| 37 | 39 | )} | |
| 38 | 40 | {...props} | |
| 39 | 41 | > | |
| 40 | 42 | {children} | |
| 41 | − | <Primitive.Arrow className="fill-raised" width={10} height={5} /> | |
| 43 | + | <Primitive.Arrow className="fill-fg" width={10} height={5} /> | |
| 42 | 44 | </Primitive.Content> | |
| 43 | 45 | </Primitive.Portal> | |
| 44 | 46 | ); | |
| 192 | 192 | const userIds: string[] = []; | |
| 193 | 193 | const byAgentId = new Map((agentsList ?? []).map((agent) => [agent.id, agent.handle])); | |
| 194 | 194 | for (const key of keys.slice(0, MAX_NAMES)) { | |
| 195 | − | if (key === G1T_ACTOR) names[key] = "g1t"; | |
| 196 | − | else if (key.startsWith("agent:")) { | |
| 197 | − | const handle = byAgentId.get(key.slice("agent:".length)); | |
| 195 | + | if (key === G1T_ACTOR || key === "user:g1t" || key.startsWith("user:wsp_")) names[key] = "g1t"; | |
| 196 | + | else if (key.startsWith("agent:") || key.startsWith("user:agt_")) { | |
| 197 | + | // An agent, by its own id: under `agent:` as events name it, or under `user:` from older ones. | |
| 198 | + | const handle = byAgentId.get(key.replace(/^(agent|user):/, "")); | |
| 198 | 199 | if (handle) names[key] = handle; | |
| 199 | 200 | } else if (key.startsWith("user:")) userIds.push(key.slice("user:".length)); | |
| 200 | 201 | } |
| 514 | 514 | test("an actor is an agent when it is one of the workspace's agents or g1t; everyone else is a person", () => { | |
| 515 | 515 | assert.equal(actorKind(MARGO), "agent"); | |
| 516 | 516 | assert.equal(actorKind(G1T), "agent"); | |
| 517 | + | assert.equal(actorKind("user:agt_01abc"), "agent", "an agent named by its own id under user: is still a robot"); | |
| 518 | + | assert.equal(actorKind("user:wsp_01abc"), "agent", "the workspace's own upkeep is g1t's"); | |
| 519 | + | assert.equal(actorKind("user:g1t"), "agent"); | |
| 517 | 520 | assert.equal(actorKind(ANA), "person"); | |
| 518 | 521 | assert.equal(actorKind(""), "person"); | |
| 519 | 522 | assert.equal(actorKind("user:usr_bot", new Set(["user:usr_bot"])), "agent"); |
| 381 | 381 | export const G1T_ACTOR = "user:usr_g1t_agent"; | |
| 382 | 382 | ||
| 383 | 383 | export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind { | |
| 384 | − | return key.startsWith("agent:") || key === G1T_ACTOR || agents?.has(key) ? "agent" : "person"; | |
| 384 | + | return key.startsWith("agent:") || key === G1T_ACTOR || isRobotKey(key) || agents?.has(key) ? "agent" : "person"; | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /** | |
| 388 | + | * A member key that names a robot under `user:`: an agent by its own id, | |
| 389 | + | * or g1t's upkeep as the workspace or as `g1t`. Events normalise these | |
| 390 | + | * (services/events digest), so this is the safety net for older ones. | |
| 391 | + | */ | |
| 392 | + | export function isRobotKey(key: string): boolean { | |
| 393 | + | return key.startsWith("user:agt_") || key.startsWith("user:wsp_") || key === "user:g1t"; | |
| 385 | 394 | } | |
| 386 | 395 | ||
| 387 | 396 | /** Whether a username is an agent's: g1t, or an agent account. */ |
| 415 | 415 | markdown: string; | |
| 416 | 416 | }; | |
| 417 | 417 | ||
| 418 | − | /** What an agent may do on a page, for the person it acts for. */ | |
| 419 | − | export type DocAgentAbilities = { read: boolean; suggest: boolean; edit: boolean }; | |
| 418 | + | /** | |
| 419 | + | * What an agent may do on a page, for the person it acts for: read it, | |
| 420 | + | * suggest changes, edit its body (only in a space whose agents edit), and | |
| 421 | + | * attach a file it made (whenever the person could edit: a file beside the | |
| 422 | + | * page changes nothing in it). | |
| 423 | + | */ | |
| 424 | + | export type DocAgentAbilities = { read: boolean; suggest: boolean; edit: boolean; attach: boolean }; | |
| 420 | 425 | ||
| 421 | 426 | export type DocAgentPage = { | |
| 422 | 427 | page: DocPageRef & { updated_at: string }; |
| 303 | 303 | space: { id: "spc_general", slug: "general", name: "General", agent_mode: "suggest" }, | |
| 304 | 304 | content, | |
| 305 | 305 | blocks: [{ id: "b1", type: "heading", level: 1, markdown: `# ${title}` }], | |
| 306 | − | can: { read: true, suggest: true, edit: false }, | |
| 306 | + | can: { read: true, suggest: true, edit: false, attach: true }, | |
| 307 | 307 | audience_can_read, | |
| 308 | 308 | }); | |
| 309 | 309 |
| 190 | 190 | search: async () => "", | |
| 191 | 191 | read: async (_v, _a, id) => ({ | |
| 192 | 192 | ok: true, | |
| 193 | − | value: { folio: { ...ref(id, "Existing"), edited_at: "2026-10-10" }, space: null, content: "", can: { read: true, suggest: true, edit: opts.canEdit ?? true }, audience_can_read: opts.audienceCanRead ?? true } as never, | |
| 193 | + | value: { folio: { ...ref(id, "Existing"), edited_at: "2026-10-10" }, space: null, content: "", can: { read: true, suggest: true, edit: opts.canEdit ?? true, attach: opts.canEdit ?? true }, audience_can_read: opts.audienceCanRead ?? true } as never, | |
| 194 | 194 | }), | |
| 195 | 195 | stale: async () => "", | |
| 196 | 196 | create: async (_v, input) => { |
| 1089 | 1089 | if (!id) return { text: "Give the doc's id (fol_…) or its link, or leave artifact out for a new doc.", outcome: "refused" }; | |
| 1090 | 1090 | const found = await folios.read(asker, audience, id); | |
| 1091 | 1091 | if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" }; | |
| 1092 | − | if (!found.value.can.edit) return { text: "You can't edit that doc for them, so nothing can be attached to it. Leave artifact out to make a new doc.", outcome: "refused" }; | |
| 1092 | + | if (!found.value.can.attach) return { text: "They can't edit that doc, so nothing can be attached to it for them. Leave artifact out to make a new doc.", outcome: "refused" }; | |
| 1093 | 1093 | ref = found.value.folio; | |
| 1094 | 1094 | if (!this.audience.shared || !found.value.audience_can_read) this.privateRead = true; | |
| 1095 | 1095 | hidden = !found.value.audience_can_read; |
| 51 | 51 | }); | |
| 52 | 52 | ||
| 53 | 53 | test("an agent is capped by the person it acts for and by the space's agent mode", () => { | |
| 54 | − | assert.deepEqual(agentAbilities("edit", "suggest"), { read: true, suggest: true, edit: false }); | |
| 55 | − | assert.deepEqual(agentAbilities("edit", "edit"), { read: true, suggest: true, edit: true }); | |
| 56 | − | assert.deepEqual(agentAbilities("view", "edit"), { read: true, suggest: false, edit: false }); | |
| 57 | − | assert.deepEqual(agentAbilities("comment", "edit"), { read: true, suggest: true, edit: false }); | |
| 58 | − | assert.deepEqual(agentAbilities(null, "edit"), { read: false, suggest: false, edit: false }); | |
| 54 | + | assert.deepEqual(agentAbilities("edit", "suggest"), { read: true, suggest: true, edit: false, attach: true }, "a file can be attached where the person could edit, whatever the agent mode"); | |
| 55 | + | assert.deepEqual(agentAbilities("edit", "edit"), { read: true, suggest: true, edit: true, attach: true }); | |
| 56 | + | assert.deepEqual(agentAbilities("view", "edit"), { read: true, suggest: false, edit: false, attach: false }); | |
| 57 | + | assert.deepEqual(agentAbilities("comment", "edit"), { read: true, suggest: true, edit: false, attach: false }); | |
| 58 | + | assert.deepEqual(agentAbilities(null, "edit"), { read: false, suggest: false, edit: false, attach: false }); | |
| 59 | 59 | }); | |
| 60 | 60 | ||
| 61 | 61 | test("roles order view < comment < edit < manage", () => { |
| 80 | 80 | return space.kind === "workspace" && atLeast(space.default_role, "view"); | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | − | /** What an agent may do for a person whose role is `asker`, in a space whose agents `mode`. */ | |
| 83 | + | /** | |
| 84 | + | * What an agent may do for a person whose role is `asker`, in a space whose | |
| 85 | + | * agents `mode`. Editing the body follows the mode; attaching a file it | |
| 86 | + | * made only needs the person to be able to edit, since the body stays as | |
| 87 | + | * it is. | |
| 88 | + | */ | |
| 84 | 89 | export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities { | |
| 85 | 90 | return { | |
| 86 | 91 | read: atLeast(asker, "view"), | |
| 87 | 92 | suggest: atLeast(asker, "comment"), | |
| 88 | 93 | edit: atLeast(asker, "edit") && mode === "edit", | |
| 94 | + | attach: atLeast(asker, "edit"), | |
| 89 | 95 | }; | |
| 90 | 96 | } | |
| 91 | 97 |
| 237 | 237 | const asker = roleIn(nodes, grants, "f", bo); | |
| 238 | 238 | assert.equal(asker, "view"); | |
| 239 | 239 | assert.equal(agentFolioRole(asker, true), "view"); | |
| 240 | − | assert.deepEqual(agentAbilities(agentFolioRole(asker, true), "edit"), { read: true, suggest: false, edit: false }); | |
| 240 | + | assert.deepEqual(agentAbilities(agentFolioRole(asker, true), "edit"), { read: true, suggest: false, edit: false, attach: false }); | |
| 241 | 241 | // Someone in the conversation can't read it: the agent can't either. | |
| 242 | 242 | assert.equal(agentFolioRole("manage", false), null); | |
| 243 | 243 | // Someone who can't read it gets nothing from the agent's grant. |
| 88 | 88 | const grants = { f: [{ principal: "agent:ag1", role: "manage" as const }, { principal: "user:bo", role: "comment" as const }] }; | |
| 89 | 89 | const r = reach(n, grants, null, { kind: "asker" }, [], [], bo); | |
| 90 | 90 | assert.equal(r.asker_role, "comment"); | |
| 91 | − | assert.deepEqual(r.can, { read: true, suggest: true, edit: false }); | |
| 91 | + | assert.deepEqual(r.can, { read: true, suggest: true, edit: false, attach: false }, "a commenter can't have a file attached for them either"); | |
| 92 | 92 | const none = reach(n, grants, null, { kind: "asker" }, [], [], ana); | |
| 93 | 93 | assert.equal(none.asker_role, null); | |
| 94 | − | assert.deepEqual(none.can, { read: false, suggest: false, edit: false }); | |
| 94 | + | assert.deepEqual(none.can, { read: false, suggest: false, edit: false, attach: false }); | |
| 95 | 95 | assert.equal(agentMayFind(none), false); | |
| 96 | 96 | }); |
| 1117 | 1117 | space: this.spaceOf(ctx, row), | |
| 1118 | 1118 | content: read.content, | |
| 1119 | 1119 | ...(read.blocks ? { blocks: read.blocks } : {}), | |
| 1120 | − | can: { read: true, suggest: atLeast(role, "comment"), edit: atLeast(role, "edit") }, | |
| 1120 | + | can: { read: true, suggest: atLeast(role, "comment"), edit: atLeast(role, "edit"), attach: atLeast(role, "edit") }, | |
| 1121 | 1121 | audience_can_read: true, | |
| 1122 | 1122 | }); | |
| 1123 | 1123 | } | |
| ⋯ | |||
| 2074 | 2074 | page.items.map((f) => f.id), | |
| 2075 | 2075 | ); | |
| 2076 | 2076 | const reach = await this.reach(actx, [...rows.values()]); | |
| 2077 | − | return ok({ items: page.items.filter((f) => agentMayFind(reach.get(f.id) ?? { asker_role: null, audience_can_read: false, can: { read: false, suggest: false, edit: false } })), next_cursor: page.next_cursor }); | |
| 2077 | + | return ok({ items: page.items.filter((f) => agentMayFind(reach.get(f.id) ?? { asker_role: null, audience_can_read: false, can: { read: false, suggest: false, edit: false, attach: false } })), next_cursor: page.next_cursor }); | |
| 2078 | 2078 | } | |
| 2079 | 2079 | ||
| 2080 | 2080 | async readForAgent(a: AgentArgs & { folio_id: string }): Promise<Result<FolioAgentRead>> { | |
| ⋯ | |||
| 2226 | 2226 | const opened = await this.agentOpen(actx, a.folio_id); | |
| 2227 | 2227 | if (!opened.ok) return opened; | |
| 2228 | 2228 | const { row, reach } = opened.value; | |
| 2229 | − | if (!reach.can.edit) return fail("forbidden", `${actx.viewer.username} can't edit this artifact, so nothing can be attached to it for them.`); | |
| 2229 | + | if (!reach.can.attach) return fail("forbidden", `${actx.viewer.username} can't edit this artifact, so nothing can be attached to it for them.`); | |
| 2230 | 2230 | let bytes: Uint8Array; | |
| 2231 | 2231 | try { | |
| 2232 | 2232 | bytes = Uint8Array.from(atob(String(a.file?.data ?? "")), (c) => c.charCodeAt(0)); | |
| 155 | 155 | } | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | − | /// An event's actor as a member key: `user:<id>`, or empty for nobody. | |
| 158 | + | /// An event's actor as a member key, or empty for nobody. Actors are | |
| 159 | + | /// people (`user:<id>`), but some events name an agent by its own id, and | |
| 160 | + | /// g1t's own upkeep names the workspace or `g1t`: those read as the agent | |
| 161 | + | /// (`agent:<id>`) and as g1t (`user:usr_g1t_agent`), so the digest never | |
| 162 | + | /// counts a robot among the people. | |
| 159 | 163 | fn member_key(actor: Option<&str>) -> String { | |
| 160 | 164 | match actor { | |
| 165 | + | Some(id) if id.starts_with("agt_") => format!("agent:{id}"), | |
| 166 | + | Some(id) if id == "g1t" || id.starts_with("wsp_") => "user:usr_g1t_agent".to_owned(), | |
| 161 | 167 | Some(id) if !id.is_empty() => format!("user:{id}"), | |
| 162 | 168 | _ => String::new(), | |
| 163 | 169 | } | |
| ⋯ | |||
| 422 | 428 | #[cfg(test)] | |
| 423 | 429 | mod tests { | |
| 424 | 430 | use super::*; | |
| 431 | + | ||
| 432 | + | #[test] | |
| 433 | + | fn robots_never_count_among_the_people() { | |
| 434 | + | assert_eq!(member_key(Some("usr_ana")), "user:usr_ana"); | |
| 435 | + | assert_eq!(member_key(Some("agt_margo")), "agent:agt_margo"); | |
| 436 | + | assert_eq!(member_key(Some("wsp_acme")), "user:usr_g1t_agent"); | |
| 437 | + | assert_eq!(member_key(Some("g1t")), "user:usr_g1t_agent"); | |
| 438 | + | assert_eq!(member_key(Some("")), ""); | |
| 439 | + | assert_eq!(member_key(None), ""); | |
| 440 | + | } | |
| 425 | 441 | use serde_json::json; | |
| 426 | 442 | ||
| 427 | 443 | fn event( | |