Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
164 linesCodeBlameRaw

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)1import type { Membership, User } from "@g1t/contracts";
2import { useQueryClient } from "@tanstack/react-query";
3import * as SecureStore from "expo-secure-store";
4import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
6import { ApiError, Client } from "./client";
7import { type Credentials, clearCredentials, loadCredentials, loadServer, saveCredentials, saveServer } from "./credentials";
8import { signInWithOAuth } from "./oauth";
9import { unregisterPush } from "./push";
10import 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. */
18export type Me = User & { token?: { token_id: string; scopes?: string[] } };
19
20type 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
25type SignedInState = Extract<State, { status: "signed-in" }>;
26
27type 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
45const WORKSPACE_KEY = "g1t.workspace";
46
47const SessionContext = createContext<SessionApi | null>(null);
48
49export 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
152export 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. */
159export 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.