Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.
7 files+44−210/7 viewed
| 9 | 9 | request, a deployment failed, someone mentioned you. You are never told | |
| 10 | 10 | about what you did yourself. | |
| 11 | 11 | ||
| 12 | − | Open them from **Notifications** in the rail, or from the bottom bar on a | |
| 12 | + | Open them from the bell in the top bar, or from the bottom bar on a | |
| 13 | 13 | phone. The number on it is what is unread. The bell at the right of the | |
| 14 | 14 | page's header opens the same notifications in a panel beside the page; its | |
| 15 | 15 | number is amber while something is waiting on you, red while a failure is | |
| ⋯ | |||
| 229 | 229 | ||
| 230 | 230 | ## The Notifications page | |
| 231 | 231 | ||
| 232 | − | **Notifications** in the rail opens | |
| 232 | + | The bell in the top bar opens | |
| 233 | 233 | [g1t.sh/notifications](https://g1t.sh/notifications): every thread, a page | |
| 234 | 234 | at a time, under the same tabs. Its sidebar chooses what you look at: | |
| 235 | 235 | ||
| 206 | 206 | <div className="grid grid-cols-4 gap-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps"> | |
| 207 | 207 | {Array.from({ length: 4 }, (_, n) => ( | |
| 208 | 208 | <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5"> | |
| 209 | − | <Skeleton className="size-10 rounded-xl" /> | |
| 210 | − | <Skeleton className="h-2.5 w-12" /> | |
| 211 | − | <Skeleton className="h-2 w-8" /> | |
| 209 | + | {/* On the launcher's raised surface the usual raised block would vanish: a line-coloured one shows. */} | |
| 210 | + | <Skeleton className="size-10 rounded-xl bg-line" /> | |
| 211 | + | <Skeleton className="h-2.5 w-12 bg-line" /> | |
| 212 | + | <Skeleton className="h-2 w-8 bg-line" /> | |
| 212 | 213 | </div> | |
| 213 | 214 | ))} | |
| 214 | 215 | </div> |
| 200 | 200 | /** | |
| 201 | 201 | * The rail down the window's left edge: a narrow column of icons, each | |
| 202 | 202 | * named by the hint beside it. g1t's mark, which leads to Home; the | |
| 203 | − | * built-in apps (Home, Chat, Notifications, Agents, Code and Artifacts) | |
| 204 | − | * with what is unread on them; the apps you pinned; the Apps launcher; | |
| 203 | + | * built-in apps (Home, Chat, Agents, Code and Artifacts) with what is | |
| 204 | + | * unread on them (Notifications is the bell in the top bar, not a rail | |
| 205 | + | * app); the apps you pinned; the Apps launcher; | |
| 205 | 206 | * and at its foot People, Workspace and your account. The one you are in | |
| 206 | 207 | * sits on a filled square, and which that is follows the address. A | |
| 207 | 208 | * member without Code access has no Code. From 768px; a phone has the | |
| ⋯ | |||
| 234 | 235 | label: "Chat", | |
| 235 | 236 | badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 }, | |
| 236 | 237 | }, | |
| 237 | − | { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } }, | |
| 238 | 238 | { key: "agents", label: "Agents" }, | |
| 239 | 239 | ...(code ? [{ key: "code" as const, label: "Code" }] : []), | |
| 240 | 240 | { key: "artifacts", label: "Artifacts" }, | |
| 23 | 23 | notifyClient, | |
| 24 | 24 | } from "@g1t/contracts"; | |
| 25 | 25 | ||
| 26 | − | import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home"; | |
| 26 | + | import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type SessionWork, type Span, type Spend, deployActorKey, pullKey, spanMonths, spendIn } from "./home"; | |
| 27 | 27 | import { monthSpan, spentMicros } from "./spend"; | |
| 28 | 28 | import { actions, agents, billing, chat, deployments, events, identity, repos, work, workspaceAgents } from "./services.server"; | |
| 29 | 29 | ||
| ⋯ | |||
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | 166 | /** Every member key the digest and chat name. */ | |
| 167 | − | export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null): string[] { | |
| 167 | + | export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null, deploys?: { projects: { deployments: { createdBy: string }[] }[] } | null): string[] { | |
| 168 | 168 | const keys = new Set<string>(); | |
| 169 | + | for (const project of deploys?.projects ?? []) for (const deploy of project.deployments) keys.add(deployActorKey(deploy.createdBy) ?? ""); | |
| 169 | 170 | for (const repo of activity?.repos ?? []) { | |
| 170 | 171 | for (const by of [...repo.pushes.by, ...repo.pushes.default_branch.by]) keys.add(by.actor); | |
| 171 | 172 | for (const list of [repo.pulls.opened, repo.pulls.merged, repo.pulls.closed, repo.issues.opened, repo.issues.closed, repo.reviews, repo.comments, repo.deployments.succeeded, repo.deployments.failed]) { | |
| 674 | 674 | ], | |
| 675 | 675 | ); | |
| 676 | 676 | const builds = landedDeploys([ | |
| 677 | − | { key: "deploy:site:d1", project: "site", kind: "production", outcome: "live", commit: "abcdef1", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d1", by: "g1t" }, | |
| 678 | − | { key: "deploy:site:d2", project: "site", kind: "preview", outcome: "live", commit: "abcdef2", branch: "fix", at: NOW - HOUR, to: "/acme/site/deployments/d2", by: "bo" }, | |
| 679 | − | { key: "deploy:site:d3", project: "site", kind: "production", outcome: "failed", commit: "abcdef3", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d3", by: "bo" }, | |
| 677 | + | { key: "deploy:site:d1", project: "site", kind: "production", outcome: "live", commit: "abcdef1", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d1", by: "@g1t", agent: true }, | |
| 678 | + | { key: "deploy:site:d2", project: "site", kind: "preview", outcome: "live", commit: "abcdef2", branch: "fix", at: NOW - HOUR, to: "/acme/site/deployments/d2", by: "bo", agent: false }, | |
| 679 | + | { key: "deploy:site:d3", project: "site", kind: "production", outcome: "failed", commit: "abcdef3", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d3", by: "bo", agent: false }, | |
| 680 | 680 | ]); | |
| 681 | 681 | assert.deepEqual(builds.map((change) => [change.kind, change.title, change.agent]), [["deploy", "Production of site went live", true]]); | |
| 682 | 682 | const all = landed([fromActivity, builds]); | |
| ⋯ | |||
| 720 | 720 | ], | |
| 721 | 721 | "acme", | |
| 722 | 722 | SPAN, | |
| 723 | + | { "user:bo": "bo" }, | |
| 723 | 724 | ); | |
| 724 | 725 | assert.deepEqual(rows.map((row) => [row.key, row.outcome]), [ | |
| 725 | 726 | ["deploy:site:d1", "live"], | |
| ⋯ | |||
| 728 | 729 | ]); | |
| 729 | 730 | assert.equal(rows[0]!.to, "/acme/site/deployments/d1"); | |
| 730 | 731 | assert.equal(rows[0]!.commit, "abcdef1"); | |
| 732 | + | // The maker is named, never shown as an id; an agent's by its handle; an id identity doesn't know as someone. | |
| 733 | + | assert.equal(rows[0]!.by, "bo"); | |
| 734 | + | assert.equal(rows[0]!.agent, false); | |
| 735 | + | const unknown = deploysIn([{ slug: "site", deployments: [deploy({ createdBy: "usr_zzz" })] }], "acme", SPAN); | |
| 736 | + | assert.equal(unknown[0]!.by, "someone"); | |
| 737 | + | const robot = deploysIn([{ slug: "site", deployments: [deploy({ createdBy: "agt_01x" })] }], "acme", SPAN, { "user:agt_01x": "otto" }); | |
| 738 | + | assert.deepEqual([robot[0]!.by, robot[0]!.agent], ["@otto", true]); | |
| 731 | 739 | }); | |
| 732 | 740 | ||
| 733 | 741 | const request = (over: Partial<InstallRequest>): InstallRequest => ({ | |
| 810 | 810 | key: `landed:${row.key}`, | |
| 811 | 811 | kind: "deploy" as const, | |
| 812 | 812 | title: `Production of ${row.project} went live`, | |
| 813 | − | detail: `${row.commit} · by ${row.by}`, | |
| 813 | + | detail: `${row.commit} · by ${row.by ?? "someone"}`, | |
| 814 | 814 | to: row.to, | |
| 815 | 815 | at: row.at, | |
| 816 | − | agent: isAgentName(row.by), | |
| 816 | + | agent: row.agent, | |
| 817 | 817 | })); | |
| 818 | 818 | } | |
| 819 | 819 | ||
| ⋯ | |||
| 822 | 822 | return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 823 | 823 | } | |
| 824 | 824 | ||
| 825 | + | /** A deployment's maker as a member key: an id becomes `user:<id>`, a key stays as it is. */ | |
| 826 | + | export function deployActorKey(createdBy: string | null | undefined): string | null { | |
| 827 | + | const by = (createdBy ?? "").trim(); | |
| 828 | + | if (!by) return null; | |
| 829 | + | return /^(user|agent):/.test(by) ? by : `user:${by}`; | |
| 830 | + | } | |
| 831 | + | ||
| 825 | 832 | /** A build that finished in the span. */ | |
| 826 | 833 | export type DeployRow = { | |
| 827 | 834 | key: string; | |
| ⋯ | |||
| 833 | 840 | branch: string | null; | |
| 834 | 841 | at: number; | |
| 835 | 842 | to: string; | |
| 836 | − | by: string; | |
| 843 | + | /** Who made it, as a name (`@handle` for an agent); null when the deployment does not say. */ | |
| 844 | + | by: string | null; | |
| 845 | + | /** Whether an agent made it. */ | |
| 846 | + | agent: boolean; | |
| 837 | 847 | }; | |
| 838 | 848 | ||
| 839 | 849 | /** | |
| ⋯ | |||
| 841 | 851 | * and previews too. A build that went live and was replaced since still | |
| 842 | 852 | * went live. Skipped builds are left out. | |
| 843 | 853 | */ | |
| 844 | − | export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] { | |
| 854 | + | export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">, names: Record<string, string> = {}, agentKeys?: ReadonlySet<string>): DeployRow[] { | |
| 845 | 855 | const rows: DeployRow[] = []; | |
| 846 | 856 | for (const project of projects) { | |
| 847 | 857 | for (const deploy of project.deployments) { | |
| 858 | + | // Who made it: the deployment names them by id, so it reads as a name, or as someone when identity doesn't know it. | |
| 859 | + | const key = deployActorKey(deploy.createdBy); | |
| 848 | 860 | const ended = deploy.finishedAt ?? null; | |
| 849 | 861 | if (!within(ended, span)) continue; | |
| 850 | 862 | const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null; | |
| ⋯ | |||
| 858 | 870 | branch: deploy.branch, | |
| 859 | 871 | at: Date.parse(ended!), | |
| 860 | 872 | to: `/${workspace}/${project.slug}/deployments/${deploy.id}`, | |
| 861 | − | by: deploy.createdBy, | |
| 873 | + | by: key ? namesOf([key], names, agentKeys) : null, | |
| 874 | + | agent: !!key && actorKind(key, agentKeys) === "agent", | |
| 862 | 875 | }); | |
| 863 | 876 | } | |
| 864 | 877 | } | |
| 149 | 149 | const memoriesP = code ? loadMemories(viewer, slug).catch(() => null) : Promise.resolve(null); | |
| 150 | 150 | const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null); | |
| 151 | 151 | const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null); | |
| 152 | − | const namesP = Promise.all([activityP, chatActivityP, agentsP]) | |
| 153 | − | .then(([activity, chatActivity, agents]) => loadNames(actorKeys(activity, chatActivity), agents)) | |
| 152 | + | const namesP = Promise.all([activityP, chatActivityP, agentsP, deploysP]) | |
| 153 | + | .then(([activity, chatActivity, agents, deploys]) => loadNames(actorKeys(activity, chatActivity, deploys), agents)) | |
| 154 | 154 | .catch(() => ({}) as Record<string, string>); | |
| 155 | 155 | ||
| 156 | 156 | const work: Promise<SpanWork | null> = Promise.all([codeWorkP, sessionsP]) | |
| ⋯ | |||
| 162 | 162 | .catch(() => null); | |
| 163 | 163 | const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP, activityP, namesP, reposP]) | |
| 164 | 164 | .then(([codeWork, deploys, memories, requests, activity, names, owned]) => { | |
| 165 | − | const deployRows = deploys ? deploysIn(deploys.projects, slug, span) : null; | |
| 165 | + | const deployRows = deploys ? deploysIn(deploys.projects, slug, span, names) : null; | |
| 166 | 166 | const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null; | |
| 167 | 167 | return { | |
| 168 | 168 | // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read. | |