Skip to content

Commit

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.

syntaqxcommitted Parent103d8ceBrowse files
17 files+102−560/17 viewed
+3−1
127127 conversation in a direct message or private channel, the General space in
128128 a public channel.
129129 - **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.
131133
132134 Either way, the doc gets a link to the file, and the agent answers with the
133135 file's link. Files are served from the usercontent address
+30−28
180180 const installed = loader.data?.apps;
181181 const wanted = query.trim().toLowerCase();
182182 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;
183185 return (
184186 <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 ? (
186204 <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">
189207 {Array.from({ length: 4 }, (_, n) => (
190208 <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5">
191209 <Skeleton className="size-10 rounded-xl" />
192210 <Skeleton className="h-2.5 w-12" />
211+ <Skeleton className="h-2 w-8" />
193212 </div>
194213 ))}
195214 </div>
196− ) : installed.length === 0 ? (
215+ ) : installed!.length === 0 ? (
197216 <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>
198219 ) : (
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>
223225 )}
224226 <div className="flex items-center justify-between border-t border-line pt-2">
225227 <Link
+1−1
124124 export function SliceTabs({ current, scope, href }: { current: SliceKey; scope: SpendScope; href: (key: SliceKey) => string }) {
125125 const shown = SLICES.filter((s) => scope === "workspace" || !s.workspaceOnly);
126126 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]">
128128 {shown.map((s) => (
129129 <Link
130130 key={s.key}
+6−4
33
44 import { cn } from "../../lib/cn";
55
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.
810
911 export function TooltipProvider({ delayDuration = 200, ...props }: ComponentProps<typeof Primitive.Provider>) {
1012 return <Primitive.Provider delayDuration={delayDuration} {...props} />;
3133 <Primitive.Content
3234 sideOffset={sideOffset}
3335 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",
3537 "origin-(--radix-tooltip-content-transform-origin) animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
3638 className,
3739 )}
3840 {...props}
3941 >
4042 {children}
41− <Primitive.Arrow className="fill-raised" width={10} height={5} />
43+ <Primitive.Arrow className="fill-fg" width={10} height={5} />
4244 </Primitive.Content>
4345 </Primitive.Portal>
4446 );
+4−3
192192 const userIds: string[] = [];
193193 const byAgentId = new Map((agentsList ?? []).map((agent) => [agent.id, agent.handle]));
194194 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):/, ""));
198199 if (handle) names[key] = handle;
199200 } else if (key.startsWith("user:")) userIds.push(key.slice("user:".length));
200201 }
+3−0
514514 test("an actor is an agent when it is one of the workspace's agents or g1t; everyone else is a person", () => {
515515 assert.equal(actorKind(MARGO), "agent");
516516 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");
517520 assert.equal(actorKind(ANA), "person");
518521 assert.equal(actorKind(""), "person");
519522 assert.equal(actorKind("user:usr_bot", new Set(["user:usr_bot"])), "agent");
+10−1
381381 export const G1T_ACTOR = "user:usr_g1t_agent";
382382
383383 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";
385394 }
386395
387396 /** Whether a username is an agent's: g1t, or an agent account. */
+7−2
415415 markdown: string;
416416 };
417417
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 };
420425
421426 export type DocAgentPage = {
422427 page: DocPageRef & { updated_at: string };
+1−1
303303 space: { id: "spc_general", slug: "general", name: "General", agent_mode: "suggest" },
304304 content,
305305 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 },
307307 audience_can_read,
308308 });
309309
+1−1
190190 search: async () => "",
191191 read: async (_v, _a, id) => ({
192192 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,
194194 }),
195195 stale: async () => "",
196196 create: async (_v, input) => {
+1−1
10891089 if (!id) return { text: "Give the doc's id (fol_…) or its link, or leave artifact out for a new doc.", outcome: "refused" };
10901090 const found = await folios.read(asker, audience, id);
10911091 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" };
10931093 ref = found.value.folio;
10941094 if (!this.audience.shared || !found.value.audience_can_read) this.privateRead = true;
10951095 hidden = !found.value.audience_can_read;
+5−5
5151 });
5252
5353 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 });
5959 });
6060
6161 test("roles order view < comment < edit < manage", () => {
+7−1
8080 return space.kind === "workspace" && atLeast(space.default_role, "view");
8181 }
8282
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+ */
8489 export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities {
8590 return {
8691 read: atLeast(asker, "view"),
8792 suggest: atLeast(asker, "comment"),
8893 edit: atLeast(asker, "edit") && mode === "edit",
94+ attach: atLeast(asker, "edit"),
8995 };
9096 }
9197
+1−1
237237 const asker = roleIn(nodes, grants, "f", bo);
238238 assert.equal(asker, "view");
239239 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 });
241241 // Someone in the conversation can't read it: the agent can't either.
242242 assert.equal(agentFolioRole("manage", false), null);
243243 // Someone who can't read it gets nothing from the agent's grant.
+2−2
8888 const grants = { f: [{ principal: "agent:ag1", role: "manage" as const }, { principal: "user:bo", role: "comment" as const }] };
8989 const r = reach(n, grants, null, { kind: "asker" }, [], [], bo);
9090 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");
9292 const none = reach(n, grants, null, { kind: "asker" }, [], [], ana);
9393 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 });
9595 assert.equal(agentMayFind(none), false);
9696 });
+3−3
11171117 space: this.spaceOf(ctx, row),
11181118 content: read.content,
11191119 ...(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") },
11211121 audience_can_read: true,
11221122 });
11231123 }
20742074 page.items.map((f) => f.id),
20752075 );
20762076 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 });
20782078 }
20792079
20802080 async readForAgent(a: AgentArgs & { folio_id: string }): Promise<Result<FolioAgentRead>> {
22262226 const opened = await this.agentOpen(actx, a.folio_id);
22272227 if (!opened.ok) return opened;
22282228 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.`);
22302230 let bytes: Uint8Array;
22312231 try {
22322232 bytes = Uint8Array.from(atob(String(a.file?.data ?? "")), (c) => c.charCodeAt(0));
+17−1
155155 }
156156 }
157157
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.
159163 fn member_key(actor: Option<&str>) -> String {
160164 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(),
161167 Some(id) if !id.is_empty() => format!("user:{id}"),
162168 _ => String::new(),
163169 }
422428 #[cfg(test)]
423429 mod tests {
424430 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+ }
425441 use serde_json::json;
426442
427443 fn event(