Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/chat-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,7 +1236,7 @@ export function patchWorkbenchSettings(
const ConnectGithubRepoResponse = type({
id: "string",
name: "string",
openPullRequestCount: "number",
"lastPushedAt?": "string",
});

const ConnectGithubStateResponse = type({ kind: "'disconnected'" })
Expand Down
23 changes: 21 additions & 2 deletions packages/chat-ui/src/blocks/connect-github-block-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ function lastConnectedOf(messageId: string): ConnectedGithubQuery | undefined {
return lastConnectedByMessageId.get(messageId);
}

/** A state read that fails is an error the card says out loud, never a
* silent fall-through to "Connect GitHub" over a connection that
* exists (CL-7189): an unhandled rejection used to leave the query on
* `loading`, which renders the disconnected body. */
async function readConnectState(
actions: ConnectGithubActions,
messageId: string,
): Promise<ConnectGithubQuery> {
try {
return await actions.getConnectState(messageId);
} catch (cause) {
reportError(cause, { operation: "connect-github.getConnectState" });
return {
kind: "error",
message: CHAT_STRINGS.blockConnectGithubStateUnreadable,
};
}
}

function displayQueryOf(
messageId: string,
query: ConnectGithubQuery,
Expand Down Expand Up @@ -120,7 +139,7 @@ export function ConnectGithubBlockContainer({

useEffect(() => {
if (actions === undefined) return;
actions.getConnectState(messageId).then(applyQuery);
void readConnectState(actions, messageId).then(applyQuery);
const unsubscribe = actions.subscribeConnectState(messageId, applyQuery);
return unsubscribe;
}, [actions, messageId, applyQuery]);
Expand All @@ -137,7 +156,7 @@ export function ConnectGithubBlockContainer({
}
const result = await actions.submitAccessToken(token);
if (result.ok) {
applyQuery(await actions.getConnectState(messageId));
applyQuery(await readConnectState(actions, messageId));
}
return result;
},
Expand Down
21 changes: 14 additions & 7 deletions packages/chat-ui/src/blocks/connect-github-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,23 @@
// mode) rather than a hand-rolled input -- AGENTS.md puts generic controls
// upstream in react-ui, not here. Its corner radius is react-ui's rounded
// default, a visual delta from the mock's flat radius-0 system; the row
// layout around it (name left, open-PR count right) is workbench-specific
// layout around it (name left, last-push time right) is workbench-specific
// composition and stays local.

import { useEffect, useRef, useState } from "react";
import { Button, Checkbox, Input } from "@corbits/react-ui";
import { Check } from "@corbits/icons";

import { formatRelativeActivity } from "../relative-time";
import { CHAT_STRINGS } from "../strings";
import { BlockCard } from "./block-card";

export type ConnectGithubRepo = {
readonly id: string;
readonly name: string;
readonly openPullRequestCount: number;
/** When GitHub last saw a push, ISO-8601 — absent on a repo with no
* commits yet. It is what the picker's ordering is explaining. */
readonly lastPushedAt?: string;
};

/** One labelled step of the room's walkthrough, as the workbench's own
Expand Down Expand Up @@ -113,10 +116,14 @@ export type ConnectGithubCardProps = ConnectGithubCardBody & {
readonly scene: OnboardingScene;
};

function repoMetaLabel(openPullRequestCount: number): string {
return openPullRequestCount === 0
? CHAT_STRINGS.blockConnectGithubNoOpenPulls
: CHAT_STRINGS.blockConnectGithubOpenPulls(openPullRequestCount);
function repoMetaLabel(lastPushedAt: string | undefined): string {
if (lastPushedAt === undefined) {
return CHAT_STRINGS.blockConnectGithubRepoNeverPushed;
}
const relative = formatRelativeActivity(lastPushedAt);
return relative === ""
? CHAT_STRINGS.blockConnectGithubRepoNeverPushed
: CHAT_STRINGS.blockConnectGithubRepoUpdated(relative);
}

type StepState = "done" | "current" | "upcoming";
Expand Down Expand Up @@ -425,7 +432,7 @@ function ConnectedBody({
{repo.name}
</span>
<span className="chat-block-connect-repo-meta">
{repoMetaLabel(repo.openPullRequestCount)}
{repoMetaLabel(repo.lastPushedAt)}
</span>
</label>
</div>
Expand Down
17 changes: 17 additions & 0 deletions packages/chat-ui/src/relative-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** How long ago a timestamp was, in the compact form the chat surfaces
* use for activity: "just now", "12m ago", "3h ago", "5d ago". An
* absent or unparseable timestamp renders as nothing rather than a
* placeholder date. */
export function formatRelativeActivity(iso: string | null): string {
if (iso === null) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const deltaMs = Date.now() - date.getTime();
const minutes = Math.round(deltaMs / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
27 changes: 15 additions & 12 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,44 +215,47 @@ export const CHAT_STRINGS = {
blockQuestionSubmitting: "Sending…",
blockQuestionAnswerError: "Couldn't send your answer — try again.",
blockQuestionAnsweredLabel: "Your answer",
blockConnectGithubPickHeadline: "Pick your repos",
blockConnectGithubPickHeadline: "Choose what gets reviewed",
blockConnectGithubStepDone: "Done",
blockConnectGithubStepCurrent: "You're here",
blockConnectGithubReviewingHeadline: "Reviewing",
blockConnectGithubReviewingLine:
"Every new pull request in these gets a review posted right here.",
blockConnectGithubChangeRepos: "change repos",
blockConnectGithubIntro:
"Connect GitHub with a personal access token — three quick steps, about a minute.",
"Connect GitHub with a personal access token. You choose which repositories the token can reach while you're creating it — that's exactly what the reviewers will be able to read.",
blockConnectGithubAction: "Connect GitHub",
blockConnectGithubReconnect: "Reconnect",
blockConnectGithubTokenSteps: [
"Open github.com/settings/tokens and generate a new token.",
"Give it the repo scope — that lets agents read code, issues, and pull requests.",
"Open GitHub's fine-grained token page and generate a new token.",
"Under Repository access, select the repositories you want reviewed — nothing outside that list is ever reachable with this token.",
"Under Repository permissions, set Contents, Issues, and Pull requests to Read and write, so the reviewers can read your diffs and post back on them.",
"Paste it here. It's stored encrypted, only your agents use it, and you can remove it any time.",
] as readonly string[],
blockConnectGithubTokenSettingsUrl: "https://github.com/settings/tokens",
blockConnectGithubTokenSettingsLink: "Open github.com/settings/tokens",
blockConnectGithubTokenSettingsUrl:
"https://github.com/settings/personal-access-tokens/new",
blockConnectGithubTokenSettingsLink: "Open GitHub's token page",
blockConnectGithubTokenHelper:
"Your token is stored encrypted, only your agents use it, and you can remove it any time.",
blockConnectGithubConnectedAs: (org: string) =>
`Connected to GitHub as ${org}`,
blockConnectGithubChange: "change",
blockConnectGithubRepoCount: (found: number, picked: number) =>
`${found} repos found · ${picked} picked`,
`${found} repo${found === 1 ? "" : "s"} your token can reach · ${picked} picked`,
blockConnectGithubSelectAll: "Select all",
blockConnectGithubNoOpenPulls: "no open pull requests",
blockConnectGithubOpenPulls: (count: number) =>
count === 1 ? "1 open pull request" : `${count} open pull requests`,
blockConnectGithubRepoUpdated: (relative: string) => `updated ${relative}`,
blockConnectGithubRepoNeverPushed: "no commits yet",
blockConnectGithubPermissionHelper:
"Picking a repo lets the reviewers post reviews to it — each repo is its own permission, and you can turn any off later.",
"These are the repositories your token can reach. Pick the ones you want reviewed — you can change the list any time, and narrowing what the token itself can reach is done back on GitHub.",
blockConnectGithubStartReviewing: (count: number) =>
`Start reviewing ${count} repo${count === 1 ? "" : "s"}`,
blockConnectGithubSkip: "skip for now",
blockConnectGithubStartReviewingError:
"Couldn't start reviewing — try again.",
blockConnectGithubStateUnreadable:
"Couldn't reach GitHub with your token just now — try connecting again.",
blockConnectGithubTokenFieldLabel: "Personal access token",
blockConnectGithubTokenFieldPlaceholder: "ghp_...",
blockConnectGithubTokenFieldPlaceholder: "github_pat_...",
blockConnectGithubTokenSubmit: "Connect",
blockConnectGithubTokenSubmitting: "Connecting…",
blockConnectGithubTokenCancel: "cancel",
Expand Down
15 changes: 1 addition & 14 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { WorkbenchLoadingState } from "./loading-state";
import { Markdown } from "./markdown";
import type { ProfileSubject } from "./profile-subject";
import { profileSubjectFromParticipant } from "./profile-subject";
import { formatRelativeActivity } from "./relative-time";
import { CHAT_STRINGS } from "./strings";

/**
Expand Down Expand Up @@ -1801,20 +1802,6 @@ export type ThreadAffordanceMeta = {
readonly participantAddresses: readonly string[];
};

function formatRelativeActivity(iso: string | null): string {
if (iso === null) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const deltaMs = Date.now() - date.getTime();
const minutes = Math.round(deltaMs / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}

function ThreadAffordance({
messageId,
meta,
Expand Down
35 changes: 31 additions & 4 deletions packages/chat-ui/test/connect-github-block-container.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ const DATA: ConnectGithubBlockData = {
state: "disconnected",
};

const REPOS: readonly ConnectGithubRepo[] = [
{ id: "1", name: "acme/widgets", openPullRequestCount: 2 },
];
const REPOS: readonly ConnectGithubRepo[] = [{ id: "1", name: "acme/widgets" }];

let container: HTMLDivElement | null = null;
let root: Root | null = null;
Expand Down Expand Up @@ -169,7 +167,7 @@ describe("ConnectGithubBlockContainer post-submit refresh (CL-6463)", () => {
});

const heading = el.querySelector(".chat-block-scene-pick-heading");
expect(heading?.textContent).toBe("Pick your repos");
expect(heading?.textContent).toBe("Choose what gets reviewed");
expect(heading?.getAttribute("tabindex")).toBe("-1");
expect(document.activeElement).toBe(heading);
expect(el.querySelector("#connect-github-token")).toBeNull();
Expand Down Expand Up @@ -322,4 +320,33 @@ describe("ConnectGithubBlockContainer names a kind:error state (PR 422)", () =>
);
expect(connectPrimary).toBeUndefined();
});

// CL-7189: a rejected read used to leave the query on `loading`, which
// renders the disconnected body — the card told a person with a working
// connection that they had never connected.
test("a state read that rejects becomes a spoken error, never a silent Connect GitHub", async () => {
const actions: ConnectGithubActions = {
getConnectState: () => Promise.reject(new Error("boom")),
subscribeConnectState: () => () => {},
requestConnect: () => {},
submitAccessToken: async () => ({ ok: true as const }),
startReviewing: async () => ({ startedTriggerCount: 0 }),
skip: async () => {},
};

const el = await mount(actions, "m_state_rejected");
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});

const alert = el.querySelector('[role="alert"]');
expect(alert?.textContent).toBe(
"Couldn't reach GitHub with your token just now — try connecting again.",
);
const connectPrimary = [...el.querySelectorAll("button")].find(
(button) => button.textContent === "Connect GitHub",
);
expect(connectPrimary).toBeUndefined();
});
});
Loading
Loading