Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 1 | import type { Membership, User } from "@g1t/contracts"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | import * as SecureStore from "expo-secure-store"; | |
| 4 | import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | ||
| 6 | import { ApiError, Client } from "./client"; | |
| 7 | import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials"; | |
| 8 | import { signInWithOAuth } from "./oauth"; | |
| 9 | import { unregisterPush } from "./push"; | |
| 10 | import type { Server } from "./server"; | |
| 11 | ||
| 12 | /** | |
| 13 | * Who is signed in, to which g1t, and in which workspace. Everything that | |
| 14 | * talks to g1t reads its {@link Client} from here. | |
| 15 | */ | |
| 16 | ||
| 17 | /** `GET /user`: the person, their workspaces, and what the token may do. */ | |
| 18 | export type Me = User & { token?: { token_id: string; scopes?: string[] } }; | |
| 19 | ||
| 20 | type State = | |
| 21 | | { status: "loading"; server: Server | null } | |
| 22 | | { status: "signed-out"; server: Server } | |
| 23 | | { status: "signed-in"; server: Server; client: Client; me: Me; workspace: Membership | null }; | |
| 24 | ||
| 25 | type SignedInState = Extract<State, { status: "signed-in" }>; | |
| 26 | ||
| 27 | type SessionApi = { | |
| 28 | state: State; | |
| 29 | /** | |
| 30 | * The last signed-in state, kept through signing out: the signed-in | |
| 31 | * screens render once more as they leave, and read it then. | |
| 32 | */ | |
| 33 | lastSignedIn: SignedInState | null; | |
| 34 | /** Opens the site to approve the app, then signs in. */ | |
| 35 | signIn: () => Promise<void>; | |
| 36 | /** Signs in with a personal access token (`g1t_…`). */ | |
| 37 | signInWithToken: (token: string) => Promise<void>; | |
| 38 | signOut: () => Promise<void>; | |
| 39 | /** Points the app at another g1t; signs out of the one before. */ | |
| 40 | changeServer: (server: Server) => Promise<void>; | |
| 41 | /** Switches workspace by slug. */ | |
| 42 | selectWorkspace: (slug: string) => Promise<void>; | |
| 43 | }; | |
| 44 | ||
| 45 | const WORKSPACE_KEY = "g1t.workspace"; | |
| 46 | ||
| 47 | const SessionContext = createContext<SessionApi | null>(null); | |
| 48 | ||
| 49 | export function SessionProvider({ children }: { children: ReactNode }) { | |
| 50 | const [state, setState] = useState<State>({ status: "loading", server: null }); | |
| 51 | const queries = useQueryClient(); | |
| 52 | const serverRef = useRef<Server | null>(null); | |
| 53 | const lastSignedIn = useRef<SignedInState | null>(null); | |
| 54 | if (state.status === "signed-in") lastSignedIn.current = state; | |
| 55 | ||
| 56 | const signedOut = useCallback( | |
| 57 | async (server: Server) => { | |
| 58 | await clearCredentials(); | |
| 59 | queries.clear(); | |
| 60 | setState({ status: "signed-out", server }); | |
| 61 | }, | |
| 62 | [queries], | |
| 63 | ); | |
| 64 | ||
| 65 | /** Makes a client for credentials, reads who they are, and signs in. */ | |
| 66 | const start = useCallback( | |
| 67 | async (server: Server, credentials: Credentials) => { | |
| 68 | const client = new Client({ | |
| 69 | server, | |
| 70 | credentials, | |
| 71 | onCredentials: (next) => { | |
| 72 | if (next === null) void signedOut(server); | |
| 73 | }, | |
| 74 | }); | |
| 75 | const me = await client.api<Me>("/user"); | |
| 76 | if ((me.kind ?? "user") !== "user") { | |
| The phone app no longer has g1t's own agents, to match the site. The Agents tab is gone from the bar, and Customize (skills, connectors and plugins) is gone with its row in More. New message finds and messages people only, and its search and heading say so. A card an agent posted earlier still shows with its title, state, details and links, but its other buttons are no longer drawn, since chat no longer answers them; an old agent's messages and direct messages still show, under the name chat gives them, marked Deleted agent. The app stops asking for agents:run when it signs in. An old g1t:// link to Agents, the skill library, Customize or the Marketplace opens Home, while the integrations directory opens on the site. Notifications, the sign-in screen and the access-token error no longer mention agents waiting on you or agent tokens, and the sign-in screen speaks of reviewing what you and your coding agents make. Pull requests still show the coding agent they were made with. | 77 | throw new ApiError("forbidden", "Sign in as yourself: a workspace's token cannot use the app."); |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 78 | } |
| 79 | const saved = await SecureStore.getItemAsync(WORKSPACE_KEY).catch(() => null); | |
| 80 | const workspaces = me.workspaces ?? []; | |
| 81 | const workspace = workspaces.find((one) => one.slug === saved) ?? workspaces[0] ?? null; | |
| 82 | setState({ status: "signed-in", server, client, me, workspace }); | |
| 83 | }, | |
| 84 | [signedOut], | |
| 85 | ); | |
| 86 | ||
| 87 | useEffect(() => { | |
| 88 | void (async () => { | |
| 89 | const server = await loadServer(); | |
| 90 | serverRef.current = server; | |
| 91 | const credentials = await loadCredentials(); | |
| 92 | if (!credentials) return setState({ status: "signed-out", server }); | |
| 93 | try { | |
| 94 | await start(server, credentials); | |
| 95 | } catch (error) { | |
| 96 | // Offline at launch keeps the credentials for next time; anything | |
| 97 | // else (revoked, expired) signs out. | |
| 98 | if (error instanceof ApiError && error.code === "offline") { | |
| 99 | setState({ status: "signed-out", server }); | |
| 100 | return; | |
| 101 | } | |
| 102 | await signedOut(server); | |
| 103 | } | |
| 104 | })(); | |
| 105 | }, [start, signedOut]); | |
| 106 | ||
| 107 | const api = useMemo<SessionApi>(() => { | |
| 108 | const current = () => serverRef.current ?? state.server; | |
| 109 | return { | |
| 110 | state, | |
| 111 | lastSignedIn: lastSignedIn.current, | |
| 112 | signIn: async () => { | |
| 113 | const server = current(); | |
| 114 | if (!server) return; | |
| 115 | const credentials = await signInWithOAuth(server); | |
| 116 | await saveCredentials(credentials); | |
| 117 | await start(server, credentials); | |
| 118 | }, | |
| 119 | signInWithToken: async (token) => { | |
| 120 | const server = current(); | |
| 121 | if (!server) return; | |
| 122 | const trimmed = token.trim(); | |
| 123 | if (!trimmed.startsWith("g1t_")) throw new ApiError("failed", "An access token starts with g1t_."); | |
| 124 | const credentials: Credentials = { kind: "token", accessToken: trimmed }; | |
| 125 | await start(server, credentials); | |
| 126 | await saveCredentials(credentials); | |
| 127 | }, | |
| 128 | signOut: async () => { | |
| 129 | const server = current(); | |
| 130 | // The phone stops getting notifications first, while the token still works. | |
| 131 | if (state.status === "signed-in") await unregisterPush(state.client).catch(() => undefined); | |
| 132 | if (server) await signedOut(server); | |
| 133 | }, | |
| 134 | changeServer: async (server) => { | |
| 135 | await saveServer(server); | |
| 136 | serverRef.current = server; | |
| 137 | await signedOut(server); | |
| 138 | }, | |
| 139 | selectWorkspace: async (slug) => { | |
| 140 | if (state.status !== "signed-in") return; | |
| 141 | const workspace = state.me.workspaces?.find((one) => one.slug.toLowerCase() === slug.toLowerCase()); | |
| 142 | if (!workspace || workspace.slug === state.workspace?.slug) return; | |
| 143 | await SecureStore.setItemAsync(WORKSPACE_KEY, workspace.slug); | |
| 144 | setState({ ...state, workspace }); | |
| 145 | }, | |
| 146 | }; | |
| 147 | }, [state, start, signedOut]); | |
| 148 | ||
| 149 | return <SessionContext.Provider value={api}>{children}</SessionContext.Provider>; | |
| 150 | } | |
| 151 | ||
| 152 | export function useSession(): SessionApi { | |
| 153 | const session = useContext(SessionContext); | |
| 154 | if (!session) throw new Error("useSession is used outside SessionProvider."); | |
| 155 | return session; | |
| 156 | } | |
| 157 | ||
| 158 | /** The signed-in session, for screens that only show when signed in. */ | |
| 159 | export function useSignedIn() { | |
| 160 | const { state, lastSignedIn, ...rest } = useSession(); | |
| 161 | const signedIn = state.status === "signed-in" ? state : lastSignedIn; | |
| 162 | if (!signedIn) throw new Error("This screen needs a signed-in session."); | |
| 163 | return { ...signedIn, ...rest }; | |
| 164 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.