diff --git a/apps/web/components/github/github-access-help.tsx b/apps/web/components/github/github-access-help.tsx new file mode 100644 index 0000000000..6bd2fc8f9d --- /dev/null +++ b/apps/web/components/github/github-access-help.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; + +export function GitHubAccessHelp({ + label, + title, + description, + content, +}: { + label: string; + title: string; + description: string; + content?: ReactNode; +}) { + const usesTouchDrawer = useTouchDrawer(); + const [open, setOpen] = useState(false); + const button = ( + + ); + const trigger = usesTouchDrawer ? ( + {button} + ) : ( + + {button} + + {content ?? description} + + + ); + + return ( + + {trigger} + + + {title} + + {description} + + + {content &&
{content}
} +
+
+ ); +} diff --git a/apps/web/components/github/github-auth-method-list.tsx b/apps/web/components/github/github-auth-method-list.tsx index fd702a1635..1cd5e910d9 100644 --- a/apps/web/components/github/github-auth-method-list.tsx +++ b/apps/web/components/github/github-auth-method-list.tsx @@ -6,13 +6,6 @@ import { cn } from "@/lib/utils"; export type GitHubAutomationMethod = "pat" | "cli" | "app"; const methods = [ - { - value: "pat" as const, - label: "Personal access token", - description: - "Stored encrypted by Kandev. Workspace automation and managed task GitHub HTTPS/gh act as your account.", - icon: IconKey, - }, { value: "cli" as const, label: "GitHub CLI account", @@ -20,6 +13,13 @@ const methods = [ "Resolved from one named host account when needed. Managed tasks receive a scoped brokered identity; host Git is not inherited unless selected below.", icon: IconTerminal2, }, + { + value: "pat" as const, + label: "Personal access token", + description: + "Stored encrypted by Kandev. Workspace automation and managed task GitHub HTTPS/gh act as your account.", + icon: IconKey, + }, { value: "app" as const, label: "GitHub App", diff --git a/apps/web/components/github/github-cli-form.test.tsx b/apps/web/components/github/github-cli-form.test.tsx index 3a129930a8..8f2d226002 100644 --- a/apps/web/components/github/github-cli-form.test.tsx +++ b/apps/web/components/github/github-cli-form.test.tsx @@ -5,17 +5,14 @@ import { GitHubCLIForm } from "./github-cli-form"; const mocks = vi.hoisted(() => ({ fetchAccounts: vi.fn(), - setConnection: vi.fn(), })); vi.mock("@/lib/api/domains/github-api", () => ({ fetchGitHubCLIAccounts: mocks.fetchAccounts, - setGitHubWorkspaceConnection: mocks.setConnection, })); beforeEach(() => { mocks.fetchAccounts.mockReset(); - mocks.setConnection.mockReset(); }); afterEach(() => cleanup()); @@ -26,7 +23,7 @@ describe("GitHubCLIForm", () => { render( - + , ); @@ -41,11 +38,33 @@ describe("GitHubCLIForm", () => { render( - + , ); expect(await screen.findByText(/Sign in with/)).toBeTruthy(); expect(screen.queryByRole("alert")).toBeNull(); }); + + it("reports the preferred account without requiring a second action", async () => { + const account = { + host: "github.com", + login: "octocat", + active: true, + selected: true, + state: "active", + }; + const onAccountChange = vi.fn(); + mocks.fetchAccounts.mockResolvedValue([account]); + + render( + + + , + ); + + expect(await screen.findByText(/octocat/)).toBeTruthy(); + expect(onAccountChange).toHaveBeenLastCalledWith(account); + expect(screen.queryByRole("button", { name: "Use account" })).toBeNull(); + }); }); diff --git a/apps/web/components/github/github-cli-form.tsx b/apps/web/components/github/github-cli-form.tsx index a9e7fc0eb4..d5a8b553ba 100644 --- a/apps/web/components/github/github-cli-form.tsx +++ b/apps/web/components/github/github-cli-form.tsx @@ -1,12 +1,9 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button } from "@kandev/ui/button"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { Spinner } from "@kandev/ui/spinner"; -import { useToast } from "@/components/toast-provider"; -import { fetchGitHubCLIAccounts, setGitHubWorkspaceConnection } from "@/lib/api/domains/github-api"; +import { fetchGitHubCLIAccounts } from "@/lib/api/domains/github-api"; import type { GitHubCLIAccount } from "@/lib/types/github"; function GitHubCLIAccountNotice({ @@ -41,17 +38,19 @@ function accountPlaceholder(loading: boolean, loadError: string | null) { export function GitHubCLIForm({ workspaceId, - onSaved, + onAccountChange, + disabled, }: { workspaceId: string; - onSaved: () => void; + onAccountChange: (account: GitHubCLIAccount | null) => void; + disabled?: boolean; }) { const [accounts, setAccounts] = useState([]); const [selected, setSelected] = useState(""); const [loadError, setLoadError] = useState(null); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const { toast } = useToast(); + const onAccountChangeRef = useRef(onAccountChange); + onAccountChangeRef.current = onAccountChange; useEffect(() => { let current = true; @@ -59,7 +58,7 @@ export function GitHubCLIForm({ setSelected(""); setLoadError(null); setLoading(true); - setSaving(false); + onAccountChangeRef.current(null); fetchGitHubCLIAccounts(workspaceId, { cache: "no-store" }) .then((items) => { if (!current) return; @@ -86,26 +85,9 @@ export function GitHubCLIForm({ return accounts.find((item) => item.host === host && item.login === login); }, [accounts, selected]); - const connect = useCallback(async () => { - if (!account) return; - setSaving(true); - try { - await setGitHubWorkspaceConnection(workspaceId, { - source: "gh_cli", - host: account.host, - login: account.login, - }); - toast({ description: `Connected ${account.login} for this workspace`, variant: "success" }); - onSaved(); - } catch (error) { - toast({ - description: error instanceof Error ? error.message : "Connection failed", - variant: "error", - }); - } finally { - setSaving(false); - } - }, [account, onSaved, toast, workspaceId]); + useEffect(() => { + onAccountChangeRef.current(account ?? null); + }, [account]); return (
@@ -116,7 +98,11 @@ export function GitHubCLIForm({

- @@ -128,10 +114,6 @@ export function GitHubCLIForm({ ))} -
({ mobile: false, registrations: [] as GitHubAppRegistrationCatalogItem[], + setConnection: vi.fn(), })); const changeConnectionLabel = "Change connection"; const registrationDisplayName = "Work automation"; const githubAppLabel = "GitHub App"; +const saveChangesLabel = "Save changes"; +const WORKSPACE_ID = "workspace-1"; +const DESKTOP_CONNECTION_TEST_ID = "github-connection-desktop"; +const taskAccess: TaskGitCredentialsState = { + mode: "managed", + loading: false, + error: false, + save: vi.fn(), +}; vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ isMobile: mocks.mobile }), @@ -44,10 +56,15 @@ vi.mock("./github-app-import-form", () => ({ ), })); +vi.mock("@/lib/api/domains/github-api", async (importOriginal) => ({ + ...(await importOriginal()), + setGitHubWorkspaceConnection: mocks.setConnection, +})); + const status: GitHubStatus = { - workspace_id: "workspace-1", + workspace_id: WORKSPACE_ID, automation: { - workspace_id: "workspace-1", + workspace_id: WORKSPACE_ID, source: "pat", github_host: "github.com", login: "octocat", @@ -89,21 +106,107 @@ const registration: GitHubAppRegistrationCatalogItem = { webhook_url: "https://kandev.example/webhook", }; -function view(workspaceId = "workspace-1") { +function view(workspaceId = WORKSPACE_ID, currentTaskAccess = taskAccess) { + const onSaved = vi.fn(); return render( , ); } +function PartialSaveHarness() { + const [mode, setMode] = useState("managed"); + const save = vi.fn(async (nextMode: TaskGitCredentialsState["mode"]) => { + setMode(nextMode); + return true; + }); + return ( + + + + ); +} + beforeEach(() => { mocks.mobile = false; mocks.registrations = [registration]; + mocks.setConnection.mockReset(); + mocks.setConnection.mockResolvedValue(status.automation); +}); + +describe("GitHubConnectionDialog task access", () => { + it("saves connection and task access drafts with one action", async () => { + const save = vi.fn().mockResolvedValue(true); + const rendered = view(WORKSPACE_ID, { ...taskAccess, save }); + fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); + const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID); + const executor = screen + .getByTestId("github-task-access-option-executor") + .querySelector('[role="radio"]')!; + fireEvent.click(executor); + fireEvent.change(screen.getByLabelText("Personal access token"), { + target: { value: "ghp_replacement" }, + }); + + expect(screen.queryByRole("button", { name: "Connect token" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Save task access" })).toBeNull(); + expect(screen.getAllByRole("button", { name: saveChangesLabel })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: saveChangesLabel })); + + await waitFor(() => + expect(mocks.setConnection).toHaveBeenCalledWith(WORKSPACE_ID, { + source: "pat", + token: "ghp_replacement", + }), + ); + expect(save).toHaveBeenCalledWith("executor"); + await waitFor(() => expect(dialog.getAttribute("data-state")).toBe("closed")); + expect(rendered.baseElement.textContent).toContain("GitHub access settings saved"); + }); + + it("does not report a discarded task access save as successful", async () => { + const save = vi.fn().mockResolvedValue(false); + view(WORKSPACE_ID, { ...taskAccess, save }); + fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); + const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID); + const executor = screen + .getByTestId("github-task-access-option-executor") + .querySelector('[role="radio"]')!; + fireEvent.click(executor); + + fireEvent.click(screen.getByRole("button", { name: saveChangesLabel })); + + await waitFor(() => expect(save).toHaveBeenCalledWith("executor")); + expect(screen.queryByText("GitHub access settings saved")).toBeNull(); + expect(dialog.getAttribute("data-state")).toBe("open"); + }); + + it("preserves a failed PAT draft when task access saves successfully", async () => { + mocks.setConnection.mockRejectedValue(new Error("Token rejected")); + render(); + fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); + const token = screen.getByLabelText("Personal access token") as HTMLInputElement; + fireEvent.change(token, { target: { value: "ghp_retry_me" } }); + fireEvent.click( + screen.getByTestId("github-task-access-option-executor").querySelector('[role="radio"]')!, + ); + + fireEvent.click(screen.getByRole("button", { name: saveChangesLabel })); + + await screen.findByText(/Some settings were saved/); + expect(token.value).toBe("ghp_retry_me"); + }); }); afterEach(() => cleanup()); @@ -111,7 +214,7 @@ describe("GitHubConnectionDialog", () => { it("uses a method list and shows workspace App choices and sharing impact", async () => { view(); fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel })); - expect(await screen.findByTestId("github-connection-desktop")).toBeTruthy(); + expect(await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeTruthy(); expect(screen.getByLabelText("Connection method")).toBeTruthy(); expect(screen.getAllByText("Personal access token")).not.toHaveLength(0); expect(screen.getByText("GitHub CLI account")).toBeTruthy(); @@ -132,7 +235,7 @@ describe("GitHubConnectionDialog", () => { const drawer = await screen.findByTestId("github-connection-mobile"); expect(drawer.className).toContain("100dvh"); expect(drawer.querySelectorAll(".overflow-y-auto")).toHaveLength(1); - expect(screen.queryByTestId("github-connection-desktop")).toBeNull(); + expect(screen.queryByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeNull(); }); it("returns to the registration catalog after importing an App", async () => { @@ -202,9 +305,10 @@ describe("GitHubConnectionDialog", () => { status={{ ...status, workspace_id: "workspace-2" }} workspaceId="workspace-2" onSaved={vi.fn()} + taskAccess={taskAccess} /> , ); - await waitFor(() => expect(screen.queryByTestId("github-connection-desktop")).toBeNull()); + await waitFor(() => expect(screen.queryByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeNull()); }); }); diff --git a/apps/web/components/github/github-connection-dialog.tsx b/apps/web/components/github/github-connection-dialog.tsx index 36295d56db..085658734f 100644 --- a/apps/web/components/github/github-connection-dialog.tsx +++ b/apps/web/components/github/github-connection-dialog.tsx @@ -20,11 +20,10 @@ import { DrawerTrigger, } from "@kandev/ui/drawer"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import type { TaskGitCredentialsState } from "@/hooks/domains/github/use-task-git-credentials"; import type { GitHubStatus } from "@/lib/types/github"; -import { GitHubAuthMethodList, type GitHubAutomationMethod } from "./github-auth-method-list"; -import { GitHubAppConnectionPanel } from "./github-app-connection-panel"; -import { GitHubCLIForm } from "./github-cli-form"; -import { GitHubPATForm } from "./github-pat-form"; +import type { GitHubAutomationMethod } from "./github-auth-method-list"; +import { GitHubConnectionSettingsForm } from "./github-connection-settings-form"; function methodForStatus(status: GitHubStatus): GitHubAutomationMethod { if (status.automation?.source === "github_app_installation") return "app"; @@ -35,37 +34,16 @@ function methodForStatus(status: GitHubStatus): GitHubAutomationMethod { const description = "This workspace uses one credential for repository sync, watches, background jobs, and managed agent GitHub access. Executor profile credentials can still take precedence."; -function ConnectionBody({ - method, - workspaceId, - onMethodChange, - onSaved, -}: { - method: GitHubAutomationMethod; - workspaceId: string; - onMethodChange: (method: GitHubAutomationMethod) => void; - onSaved: () => void; -}) { - return ( -
- -
- {method === "pat" && } - {method === "cli" && } - {method === "app" && } -
-
- ); -} - export function GitHubConnectionDialog({ status, workspaceId, onSaved, + taskAccess, }: { status: GitHubStatus; workspaceId: string; onSaved: () => void; + taskAccess: TaskGitCredentialsState; }) { const [open, setOpen] = useState(false); const [method, setMethod] = useState(() => methodForStatus(status)); @@ -73,14 +51,13 @@ export function GitHubConnectionDialog({ const connected = Boolean(status.automation); useEffect(() => { - setOpen(false); setMethod(methodForStatus(status)); - }, [status, workspaceId]); + }, [status]); - const saved = useCallback(() => { - onSaved(); + useEffect(() => { setOpen(false); - }, [onSaved]); + }, [workspaceId]); + const openChange = useCallback( (next: boolean) => { if (next) setMethod(methodForStatus(status)); @@ -95,11 +72,16 @@ export function GitHubConnectionDialog({ ); const body = ( - setOpen(false)} + taskAccess={taskAccess} + isMobile={isMobile} /> ); @@ -115,9 +97,7 @@ export function GitHubConnectionDialog({ {connected ? "Change GitHub connection" : "Connect GitHub"} {description} -
- {body} -
+ {body} ); @@ -128,13 +108,13 @@ export function GitHubConnectionDialog({ {trigger} {connected ? "Change GitHub connection" : "Connect GitHub"} {description} -
{body}
+ {body}
); diff --git a/apps/web/components/github/github-connection-settings-form.tsx b/apps/web/components/github/github-connection-settings-form.tsx new file mode 100644 index 0000000000..b918380f08 --- /dev/null +++ b/apps/web/components/github/github-connection-settings-form.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@kandev/ui/button"; +import { Spinner } from "@kandev/ui/spinner"; +import { useToast } from "@/components/toast-provider"; +import type { TaskGitCredentialsState } from "@/hooks/domains/github/use-task-git-credentials"; +import { + setGitHubWorkspaceConnection, + type SetGitHubConnectionRequest, +} from "@/lib/api/domains/github-api"; +import type { + GitHubAutomationConnection, + GitHubCLIAccount, + GitHubStatus, + TaskGitCredentialsMode, +} from "@/lib/types/github"; +import { cn } from "@/lib/utils"; +import { GitHubAppConnectionPanel } from "./github-app-connection-panel"; +import { GitHubAuthMethodList, type GitHubAutomationMethod } from "./github-auth-method-list"; +import { GitHubCLIForm } from "./github-cli-form"; +import { GitHubPATForm } from "./github-pat-form"; +import { GitHubTaskAccessForm } from "./github-task-credentials-section"; + +function methodForStatus(status: GitHubStatus): GitHubAutomationMethod { + if (status.automation?.source === "github_app_installation") return "app"; + if (status.automation?.source === "gh_cli") return "cli"; + return "pat"; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "The settings could not be saved"; +} + +function runSaveOperations({ + workspaceId, + connectionRequest, + taskMode, + saveTask, +}: { + workspaceId: string; + connectionRequest: SetGitHubConnectionRequest | null; + taskMode: TaskGitCredentialsMode | null; + saveTask: TaskGitCredentialsState["save"]; +}) { + const operations: Promise[] = []; + if (connectionRequest) { + operations.push(setGitHubWorkspaceConnection(workspaceId, connectionRequest)); + } + if (taskMode) { + operations.push( + saveTask(taskMode).then((saved) => { + if (!saved) throw new Error("The workspace changed before task access was saved"); + }), + ); + } + return Promise.allSettled(operations); +} + +function buildConnectionRequest({ + method, + currentMethod, + token, + cliAccount, + automation, +}: { + method: GitHubAutomationMethod; + currentMethod: GitHubAutomationMethod; + token: string; + cliAccount: GitHubCLIAccount | null; + automation?: GitHubAutomationConnection | null; +}): SetGitHubConnectionRequest | null { + if (method === "pat" && token.trim()) return { source: "pat", token: token.trim() }; + if ( + method === "cli" && + cliAccount && + (currentMethod !== "cli" || + automation?.github_host !== cliAccount.host || + automation.login !== cliAccount.login) + ) { + return { source: "gh_cli", host: cliAccount.host, login: cliAccount.login }; + } + return null; +} + +type SettingsFormProps = { + status: GitHubStatus; + method: GitHubAutomationMethod; + workspaceId: string; + open: boolean; + onMethodChange: (method: GitHubAutomationMethod) => void; + onSaved: () => void; + onComplete: () => void; + taskAccess: TaskGitCredentialsState; + isMobile: boolean; +}; + +function useConnectionSettingsDraft({ + status, + method, + workspaceId, + open, + onSaved, + onComplete, + taskAccess, +}: SettingsFormProps) { + const [token, setToken] = useState(""); + const [cliAccount, setCLIAccount] = useState(null); + const [taskMode, setTaskMode] = useState(taskAccess.mode); + const [saving, setSaving] = useState(false); + const activeWorkspaceId = useRef(workspaceId); + const { toast } = useToast(); + activeWorkspaceId.current = workspaceId; + useEffect(() => { + if (!open) return; + setToken(""); + setSaving(false); + }, [open, workspaceId]); + useEffect(() => { + if (open) setTaskMode(taskAccess.mode); + }, [open, taskAccess.mode, workspaceId]); + const currentMethod = methodForStatus(status); + const taskDirty = taskMode !== taskAccess.mode; + const connectionRequest = useMemo( + () => + buildConnectionRequest({ + method, + currentMethod, + token, + cliAccount, + automation: status.automation, + }), + [cliAccount, currentMethod, method, status.automation, token], + ); + const methodNeedsWorkflow = method === "app" && currentMethod !== "app"; + const connectionInvalid = + (method === "pat" && currentMethod !== "pat" && !token.trim()) || + (method === "cli" && !cliAccount) || + methodNeedsWorkflow; + const canSave = + !saving && + !taskAccess.loading && + !taskAccess.error && + !connectionInvalid && + (Boolean(connectionRequest) || taskDirty); + const save = useCallback(async () => { + if (!canSave) return; + const savingWorkspaceId = workspaceId; + setSaving(true); + const results = await runSaveOperations({ + workspaceId, + connectionRequest, + taskMode: taskDirty ? taskMode : null, + saveTask: taskAccess.save, + }); + if (activeWorkspaceId.current !== savingWorkspaceId) return; + setSaving(false); + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + const successes = results.length - failures.length; + if (successes > 0) onSaved(); + if (failures.length === 0) { + setToken(""); + toast({ description: "GitHub access settings saved", variant: "success" }); + onComplete(); + return; + } + const detail = errorMessage(failures[0].reason); + toast({ + description: + successes > 0 ? `Some settings were saved, but another change failed: ${detail}` : detail, + variant: "error", + }); + }, [ + canSave, + connectionRequest, + onComplete, + onSaved, + taskAccess, + taskDirty, + taskMode, + toast, + workspaceId, + ]); + return { + token, + setToken, + setCLIAccount, + taskMode, + setTaskMode, + saving, + methodNeedsWorkflow, + canSave, + save, + }; +} + +export function GitHubConnectionSettingsForm(props: SettingsFormProps) { + const { method, workspaceId, onMethodChange, taskAccess, isMobile } = props; + const { + token, + setToken, + setCLIAccount, + taskMode, + setTaskMode, + saving, + methodNeedsWorkflow, + canSave, + save, + } = useConnectionSettingsDraft(props); + + return ( +
+
+
+
+ +
+ {method === "pat" && ( + + )} + {method === "cli" && ( + + )} + {method === "app" && } +
+ + {methodNeedsWorkflow && ( +

+ Install a GitHub App above to change the workspace connection. Task access can be + saved after the installation finishes. +

+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/components/github/github-pat-form.tsx b/apps/web/components/github/github-pat-form.tsx index b904671e3f..4827745d12 100644 --- a/apps/web/components/github/github-pat-form.tsx +++ b/apps/web/components/github/github-pat-form.tsx @@ -1,56 +1,30 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { IconEye, IconEyeOff } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; -import { Spinner } from "@kandev/ui/spinner"; -import { useToast } from "@/components/toast-provider"; -import { setGitHubWorkspaceConnection } from "@/lib/api/domains/github-api"; export function GitHubPATForm({ workspaceId, - onSaved, + value, + onChange, + disabled, }: { workspaceId: string; - onSaved: () => void; + value: string; + onChange: (value: string) => void; + disabled?: boolean; }) { - const [token, setToken] = useState(""); const [visible, setVisible] = useState(false); - const [saving, setSaving] = useState(false); - const { toast } = useToast(); useEffect(() => { - setToken(""); setVisible(false); - setSaving(false); }, [workspaceId]); - const submit = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - if (!token.trim()) return; - setSaving(true); - try { - await setGitHubWorkspaceConnection(workspaceId, { source: "pat", token: token.trim() }); - setToken(""); - toast({ description: "Workspace GitHub token connected", variant: "success" }); - onSaved(); - } catch (error) { - toast({ - description: error instanceof Error ? error.message : "Connection failed", - variant: "error", - }); - } finally { - setSaving(false); - } - }, - [onSaved, toast, token, workspaceId], - ); - return ( -
+

@@ -62,10 +36,11 @@ export function GitHubPATForm({ setToken(event.target.value)} + value={value} + onChange={(event) => onChange(event.target.value)} placeholder="ghp_xxxxxxxxxxxx" autoComplete="off" + disabled={disabled} className="h-11 pr-11 font-mono" />

-
-
+
); } diff --git a/apps/web/components/github/github-rate-limit.tsx b/apps/web/components/github/github-rate-limit.tsx index bed95b7677..2f11d6928d 100644 --- a/apps/web/components/github/github-rate-limit.tsx +++ b/apps/web/components/github/github-rate-limit.tsx @@ -5,11 +5,12 @@ import { formatDistanceToNow } from "date-fns"; import { IconAlertTriangle } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import type { GitHubRateLimitInfo, GitHubRateLimitSnapshot } from "@/lib/types/github"; +import { GitHubAccessHelp } from "./github-access-help"; -const RESOURCE_LABELS: Record = { - core: "REST", - graphql: "GraphQL", - search: "Search", +const RESOURCE_LABELS: Record = { + core: { label: "API rate limit", unit: "requests" }, + graphql: { label: "GraphQL query limit", unit: "points" }, + search: { label: "Search limit", unit: "requests" }, }; // useTickNow re-renders every intervalMs and exposes a stable now-value so the @@ -60,28 +61,52 @@ export function GitHubRateLimitDisplay({ info }: { info?: GitHubRateLimitInfo }) if (snapshots.length === 0) return null; const exhausted = snapshots.filter((s) => isExhausted(s, now)); + return ( + } + /> + ); +} + +function RateLimitDetails({ + snapshots, + exhausted, +}: { + snapshots: GitHubRateLimitSnapshot[]; + exhausted: GitHubRateLimitSnapshot[]; +}) { return (
{exhausted.length > 0 && ( - - GitHub API rate limit exhausted on{" "} - {exhausted.map((s) => RESOURCE_LABELS[s.resource] ?? s.resource).join(", ")}. Background - PR/issue checks are paused until the limit resets {formatReset(latestReset(exhausted))}. + + GitHub API access is exhausted for{" "} + {exhausted + .map((snapshot) => RESOURCE_LABELS[snapshot.resource]?.label ?? snapshot.resource) + .join(", ")} + . Background PR and issue checks are paused until the last limit resets{" "} + {formatReset(latestReset(exhausted))}. )} -
- {snapshots.map((snap) => { - const label = RESOURCE_LABELS[snap.resource] ?? snap.resource; - const limit = snap.limit > 0 ? snap.limit : "?"; - const reset = formatReset(snap); +
+ {snapshots.map((snapshot) => { + const resource = RESOURCE_LABELS[snapshot.resource] ?? { + label: snapshot.resource, + unit: "requests", + }; + const limit = snapshot.limit > 0 ? snapshot.limit.toLocaleString("en-US") : "unknown"; + const reset = formatReset(snapshot); return ( - - {label}: {snap.remaining}/{limit} +

+ {resource.label}:{" "} + {snapshot.remaining.toLocaleString("en-US")} of {limit} {resource.unit} remaining {reset ? ` · resets ${reset}` : ""} - +

); })}
diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index 562e9e8bbe..2132ba68da 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -17,7 +17,6 @@ import { IssueWatchDialog } from "./issue-watch-dialog"; import { ActionPresetsSection } from "./action-presets-section"; import { DefaultQueriesSection } from "./default-queries-section"; import { GitHubRepoScopeSection } from "./github-repo-scope-section"; -import { GitHubTaskCredentialsSection } from "./github-task-credentials-section"; import { PRStatsPanel } from "./pr-stats"; import { useReviewWatches } from "@/hooks/domains/github/use-review-watches"; import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches"; @@ -275,7 +274,6 @@ function PerWorkspaceSection({ workspaceId }: { workspaceId: string }) { - diff --git a/apps/web/components/github/github-status.test.tsx b/apps/web/components/github/github-status.test.tsx index fa2abad953..ce3bfcac1c 100644 --- a/apps/web/components/github/github-status.test.tsx +++ b/apps/web/components/github/github-status.test.tsx @@ -47,11 +47,11 @@ function status(source: "pat" | "gh_cli" | "github_app_installation"): GitHubSta } describe("GitHubPersonalSettings", () => { - it.each(["pat", "gh_cli"] as const)("explains the shared human identity for %s", (source) => { + it.each(["pat", "gh_cli"] as const)("does not separate the shared identity for %s", (source) => { mocks.status = status(source); renderPersonal(); - expect(screen.getByText("My GitHub identity")).toBeTruthy(); - expect(screen.getByText(/same human account selected for workspace access/)).toBeTruthy(); + expect(screen.queryByText("My GitHub identity")).toBeNull(); + expect(screen.queryByTestId("github-personal-identity")).toBeNull(); expect(screen.queryByRole("button", { name: /Connect identity/ })).toBeNull(); }); diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 5eb9883457..edfc9603db 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -16,6 +16,10 @@ import { SettingsSection } from "@/components/settings/settings-section"; import { useToast } from "@/components/toast-provider"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useGitHubAppRegistrations } from "@/hooks/domains/github/use-github-app-registrations"; +import { + useTaskGitCredentials, + type TaskGitCredentialsState, +} from "@/hooks/domains/github/use-task-git-credentials"; import { disconnectGitHubPersonal, disconnectGitHubWorkspace, @@ -29,7 +33,10 @@ import type { GitHubAppRegistrationCatalogItem, } from "@/lib/types/github"; import { GitHubConnectionDialog } from "./github-connection-dialog"; +import { GitHubAccessHelp } from "./github-access-help"; import { GitHubPermissionsDialog } from "./github-permissions-dialog"; +import { GitHubRateLimitDisplay } from "./github-rate-limit"; +import { GitHubTaskAccessSummary } from "./github-task-credentials-section"; const sourceLabels: Record = { pat: "Personal access token", @@ -81,6 +88,7 @@ function StatusLine({ status }: { status: GitHubStatus }) { {sourceLabels[connection.source]} {connection.status !== "active" && {connection.status}} +
); } @@ -99,9 +107,11 @@ function errorMessage(error: unknown, fallback: string) { function AutomationStatusSummary({ status, app, + taskAccess, }: { status: GitHubStatus; app?: GitHubAppRegistrationCatalogItem; + taskAccess: Omit; }) { const appAutomation = status.automation?.source === "github_app_installation"; return ( @@ -109,8 +119,8 @@ function AutomationStatusSummary({ {appAutomation && } - {!appAutomation && } +
); } @@ -124,12 +134,25 @@ function AutomationActorExplanation({ }) { const actor = status.automation?.actor?.login; if (!status.authenticated || !actor) return null; + const humanIdentity = status.effective_personal_actor?.kind === "human"; return ( -

- {appAutomation - ? `Kandev-managed operations use the GitHub App installed for ${actor}.` - : `Kandev-managed operations act as ${actor}.`} -

+
+ +
+

+ {appAutomation + ? `Kandev-managed operations use the GitHub App installed for ${actor}.` + : `Kandev-managed operations act as ${actor}.`} +

+ {!appAutomation && humanIdentity && ( +

This account also powers My GitHub and user-triggered actions.

+ )} +
+
); } @@ -157,15 +180,6 @@ function AppRegistrationDetails({ app }: { app?: GitHubAppRegistrationCatalogIte ); } -function HumanIdentityExplanation({ status }: { status: GitHubStatus }) { - if (status.effective_personal_actor?.kind !== "human") return null; - return ( -

- This account also powers My GitHub and user-triggered actions. -

- ); -} - function AutomationError({ status }: { status: GitHubStatus }) { if (!status.automation?.last_error) return null; return

{status.automation.last_error}

; @@ -177,17 +191,24 @@ function AutomationActions({ busy, onDisconnect, onRefresh, + taskAccess, }: { status: GitHubStatus; workspaceId: string; busy: boolean; onDisconnect: () => void; onRefresh: () => void; + taskAccess: TaskGitCredentialsState; }) { return (
- +
); @@ -326,34 +349,11 @@ export function GitHubPersonalSettings({ workspaceId }: { workspaceId: string }) const { status, loaded, loading, refresh } = useGitHubStatus(workspaceId); const [busy, setBusy] = useState(false); const { toast } = useToast(); - if (!loaded || loading || !status) { - return ( - - - - ); - } + if (!loaded || loading || !status) return null; const view = personalIdentityView(status); const appAutomation = status.automation?.source === "github_app_installation"; if (!status.automation) return null; - if (!appAutomation) { - return ( - -
- -

- A separate personal identity is only needed when workspace automation uses a GitHub App. -

-
-
- ); - } + if (!appAutomation) return null; const disconnect = async () => { setBusy(true); try { diff --git a/apps/web/components/github/github-task-credentials-section.tsx b/apps/web/components/github/github-task-credentials-section.tsx index 0c0d5d8980..ae150d6ad2 100644 --- a/apps/web/components/github/github-task-credentials-section.tsx +++ b/apps/web/components/github/github-task-credentials-section.tsx @@ -1,162 +1,108 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { IconInfoCircle } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { CardContent } from "@kandev/ui/card"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@kandev/ui/drawer"; import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsSection } from "@/components/settings/settings-section"; -import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; -import { useToast } from "@/components/toast-provider"; -import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; -import { - fetchGitHubWorkspaceSettings, - updateGitHubWorkspaceSettings, -} from "@/lib/api/domains/github-api"; +import type { TaskGitCredentialsState } from "@/hooks/domains/github/use-task-git-credentials"; import type { TaskGitCredentialsMode } from "@/lib/types/github"; +import { GitHubAccessHelp } from "./github-access-help"; -const deliveryHelp = - "PAT, named GitHub CLI, and GitHub App connections choose Kandev's workspace automation identity. In managed mode Kandev brokers that identity to the task for GitHub HTTPS and gh. Inherit executor credentials leaves task Git and gh to the selected executor. An explicit executor-profile GH_TOKEN or GITHUB_TOKEN takes precedence in managed mode."; +const taskAccessLabels: Record = { + managed: "Managed workspace credentials", + executor: "Inherit executor Git credentials", +}; -function TaskCredentialsHelp() { - const usesDrawer = useTouchDrawer(); - const [open, setOpen] = useState(false); - const button = ( - - ); - const trigger = {button}; +export function GitHubTaskAccessSummary({ + mode, + loading, + error, +}: Omit) { + let value = taskAccessLabels[mode]; + if (loading) value = "Loading task access…"; + if (error) value = "Task access unavailable"; return ( - - {usesDrawer ? ( - trigger - ) : ( - - {trigger} - - {deliveryHelp} - - +
+ + + Task access: + {value} + +
+ ); +} + +export function GitHubTaskAccessForm({ + taskAccess, + value, + onChange, + disabled, +}: { + taskAccess: TaskGitCredentialsState; + value: TaskGitCredentialsMode; + onChange: (value: TaskGitCredentialsMode) => void; + disabled?: boolean; +}) { + return ( +
+
+
+

Task Git access

+ +
+

+ Choose how newly launched tasks authenticate to GitHub. This does not change the workspace + automation connection above. +

+
+ onChange(nextValue as TaskGitCredentialsMode)} + disabled={taskAccess.loading || taskAccess.error || disabled} + className="gap-2" + > + + + +

+ An executor-profile GH_TOKEN or GITHUB_TOKEN overrides managed workspace credentials for + that task. +

+ {taskAccess.error && ( +

Unable to load the current task access setting.

)} - - - How task Git credentials work - {deliveryHelp} - - - - ); -} - -export function GitHubTaskCredentialsSection({ workspaceId }: { workspaceId: string }) { - const { toast } = useToast(); - const [mode, setMode] = useState("managed"); - const [baseline, setBaseline] = useState("managed"); - const [loading, setLoading] = useState(true); - useEffect(() => { - let cancelled = false; - setLoading(true); - void fetchGitHubWorkspaceSettings(workspaceId) - .then((settings) => { - if (!cancelled) { - const next = settings.task_git_credentials_mode ?? "managed"; - setMode(next); - setBaseline(next); - } - }) - .catch(() => { - if (!cancelled) - toast({ description: "Failed to load task Git credential settings", variant: "error" }); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [toast, workspaceId]); - const save = useCallback(async () => { - try { - const updated = await updateGitHubWorkspaceSettings({ - workspace_id: workspaceId, - task_git_credentials_mode: mode, - }); - const next = updated.task_git_credentials_mode ?? "managed"; - setBaseline(next); - setMode((current) => (current === mode ? next : current)); - toast({ description: "Task Git credential settings saved", variant: "success" }); - } catch { - toast({ description: "Failed to save task Git credential settings", variant: "error" }); - throw new Error("Failed to save task Git credential settings"); - } - }, [mode, toast, workspaceId]); - const dirty = mode !== baseline; - useSettingsSaveContributor({ - id: `github-task-credentials:${workspaceId}`, - revision: mode, - isDirty: dirty, - canSave: !loading, - save, - discard: () => setMode(baseline), - }); - return ( - } - > - - - setMode(value as TaskGitCredentialsMode)} - disabled={loading} - data-testid="github-task-credentials-mode" - > - - - -

- An executor-profile GH_TOKEN or GITHUB_TOKEN overrides managed workspace credentials for - that task. -

-
-
-
+
); } diff --git a/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts b/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts index e3da0d227d..9e8d083eae 100644 --- a/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts +++ b/apps/web/e2e/tests/cli-mode/comment-run-passthrough.spec.ts @@ -18,8 +18,6 @@ import type { ApiClient } from "../../helpers/api-client"; * - Backend integration: TestPromptTask_PassthroughRoutesToPTYStdin */ test.describe("CLI mode: message.add submits to PTY", () => { - test.describe.configure({ retries: 1 }); - async function createPassthroughProfile(apiClient: ApiClient, name: string): Promise { const { agents } = await apiClient.listAgents(); if (agents.length === 0) throw new Error("no agents registered in this e2e profile"); @@ -55,6 +53,24 @@ test.describe("CLI mode: message.add submits to PTY", () => { }, ); if (!created.session_id) throw new Error("createTaskWithAgent did not return session_id"); + await expect + .poll(async () => (await apiClient.getTaskEnvironment(created.id))?.status, { + timeout: 30_000, + message: `${taskTitle} environment did not become ready`, + }) + .toBe("ready"); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(created.id); + return sessions.find((session) => session.id === created.session_id)?.state ?? null; + }, + { + timeout: 30_000, + message: `${taskTitle} passthrough session did not become idle`, + }, + ) + .toBe("WAITING_FOR_INPUT"); const kanban = new KanbanPage(testPage); await kanban.goto(); diff --git a/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts b/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts index 99abf44ef5..72f3905b43 100644 --- a/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts +++ b/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts @@ -14,6 +14,12 @@ import { KanbanPage } from "../../pages/kanban-page"; useRegularMode(); test.describe("CLI mode: create-task dialog prompt", () => { + test.afterEach(async ({ apiClient, seedData }) => { + await apiClient.updateAgentProfile(seedData.agentProfileId, { + cli_passthrough: false, + }); + }); + test("prompt textarea is enabled, no 'prompt ignored' warning, and submit works", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts index 43e6a6605e..17b1796dda 100644 --- a/apps/web/e2e/tests/command-panel.spec.ts +++ b/apps/web/e2e/tests/command-panel.spec.ts @@ -136,10 +136,15 @@ test.describe("Command Panel", () => { task.session_id, "command alias task session did not become ready", ); + await expect + .poll(async () => (await apiClient.getTaskEnvironment(task.id))?.status, { + timeout: 30_000, + message: "command alias task environment did not become ready", + }) + .toBe("ready"); await testPage.goto(`/t/${task.id}`); - const session = new SessionPage(testPage); - await session.waitForLoad(); + await expect(testPage.getByTestId("task-topbar")).toBeVisible(); await openCommandPanel(testPage); const dialog = commandDialog(testPage); diff --git a/apps/web/e2e/tests/integrations/github-authentication.spec.ts b/apps/web/e2e/tests/integrations/github-authentication.spec.ts index db4c1c6b1d..f899fc992e 100644 --- a/apps/web/e2e/tests/integrations/github-authentication.spec.ts +++ b/apps/web/e2e/tests/integrations/github-authentication.spec.ts @@ -68,6 +68,12 @@ test.describe("GitHub workspace authentication", () => { timeout: 15_000, }); await expect(automation.getByText("GitHub CLI", { exact: true })).toBeVisible(); + await expect(testPage.getByText("My GitHub identity", { exact: true })).toHaveCount(0); + await expect( + automation.getByText("This account also powers My GitHub and user-triggered actions.", { + exact: true, + }), + ).toBeVisible(); const accountsResponse = await apiClient.rawRequest( "GET", `/api/v1/github/auth/gh-cli/accounts?workspace_id=${encodeURIComponent(workspaceB.id)}`, @@ -96,10 +102,6 @@ test.describe("GitHub workspace authentication", () => { await expect( workspaceBDialog.getByRole("combobox", { name: "GitHub CLI account" }), ).toContainText("bob-cli"); - await expect(testPage.getByText("My GitHub identity", { exact: true })).toBeVisible(); - await expect( - testPage.getByText(/same human account selected for workspace access/), - ).toBeVisible(); await testPage.screenshot({ path: testInfo.outputPath("github-workspace-isolation-desktop.png"), diff --git a/apps/web/e2e/tests/integrations/github-rate-limit-fixture.ts b/apps/web/e2e/tests/integrations/github-rate-limit-fixture.ts new file mode 100644 index 0000000000..f3ecc913b6 --- /dev/null +++ b/apps/web/e2e/tests/integrations/github-rate-limit-fixture.ts @@ -0,0 +1,44 @@ +import type { Page } from "@playwright/test"; + +const RATE_LIMIT_RESET_AT = "2030-01-01T00:00:00Z"; + +export async function stubGitHubRateLimits(page: Page, workspaceId: string) { + await page.route("**/api/v1/github/status?*", async (route) => { + const url = new URL(route.request().url()); + if (url.searchParams.get("workspace_id") !== workspaceId) { + await route.continue(); + return; + } + const response = await route.fetch(); + const body = (await response.json()) as Record; + await route.fulfill({ + response, + json: { + ...body, + rate_limit: { + core: { + resource: "core", + remaining: 4321, + limit: 5000, + reset_at: RATE_LIMIT_RESET_AT, + updated_at: "2029-12-31T23:45:00Z", + }, + graphql: { + resource: "graphql", + remaining: 4900, + limit: 5000, + reset_at: RATE_LIMIT_RESET_AT, + updated_at: "2029-12-31T23:45:00Z", + }, + search: { + resource: "search", + remaining: 25, + limit: 30, + reset_at: RATE_LIMIT_RESET_AT, + updated_at: "2029-12-31T23:45:00Z", + }, + }, + }, + }); + }); +} diff --git a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts index 613d8265d7..a9ca8bc37d 100644 --- a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { stubGitHubRateLimits } from "./github-rate-limit-fixture"; type ReviewWatchesResponse = { watches: Array<{ id: string; enabled: boolean }>; @@ -68,23 +69,140 @@ test.describe("GitHub workspace settings", () => { } }); - test("saves the explicit task Git credential policy", async ({ + test("configures task Git access from the workspace connection dialog", async ({ testPage, apiClient, seedData, prCapture, }) => { - await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`); - await expect(testPage.getByRole("heading", { name: "Task Git credentials" })).toBeVisible(); - - await testPage.getByRole("radio", { name: "Inherit executor Git credentials" }).click(); - await prCapture.screenshot("desktop-task-git-credentials", { - caption: "Workspace task Git credential policy", + await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, { + source: "legacy_shared", + status: "active", }); - await testPage.getByTestId("settings-floating-save").getByRole("button").click(); - await expect(testPage.getByText("Task Git credential settings saved")).toBeVisible({ + await apiClient.mockGitHubSetCLIAccounts([ + { host: "github.com", login: "workspace-cli", active: true, state: "active" }, + ]); + await stubGitHubRateLimits(testPage, seedData.workspaceId); + await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`); + const automation = testPage.getByTestId("github-workspace-automation"); + await expect(automation.getByTestId("github-task-access-summary")).toContainText( + "Managed workspace credentials", + ); + await expect(testPage.getByRole("heading", { name: "My GitHub identity" })).toHaveCount(0); + await expect(testPage.getByRole("heading", { name: "Task Git credentials" })).toHaveCount(0); + const identityHelp = automation.getByRole("button", { + name: "Explain workspace GitHub identity", + }); + await expect(identityHelp).not.toHaveAttribute("aria-haspopup"); + await expect(identityHelp).not.toHaveAttribute("aria-expanded"); + await identityHelp.hover(); + await expect( + testPage.getByRole("tooltip", { + name: /repository sync, watches, background jobs, and managed agent GitHub commands/, + }), + ).toBeVisible(); + const taskAccessHelp = automation.getByRole("button", { name: "Explain task Git access" }); + await expect(taskAccessHelp).not.toHaveAttribute("aria-haspopup"); + await expect(taskAccessHelp).not.toHaveAttribute("aria-expanded"); + await taskAccessHelp.hover(); + await expect( + testPage.getByRole("tooltip", { + name: /newly launched tasks authenticate to GitHub/, + }), + ).toBeVisible(); + const rateLimitHelp = automation.getByRole("button", { name: "Show GitHub API limits" }); + await expect(rateLimitHelp).toBeVisible(); + await rateLimitHelp.hover(); + const rateLimitTooltip = testPage.getByRole("tooltip", { name: /API rate limit/ }); + await expect(rateLimitTooltip).toContainText( + "API rate limit: 4,321 of 5,000 requests remaining", + ); + await expect(rateLimitTooltip).toContainText( + "GraphQL query limit: 4,900 of 5,000 points remaining", + ); + + await automation.getByRole("button", { name: "Change connection" }).click(); + const dialog = testPage.getByRole("dialog", { name: "Change GitHub connection" }); + const dialogBox = await dialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.width).toBeGreaterThanOrEqual(800); + const methodGroup = dialog.getByRole("radiogroup", { name: "Connection method" }); + await expect(methodGroup.getByRole("radio").first()).toHaveAttribute("id", "github-method-cli"); + const scrollBody = dialog.getByTestId("github-connection-scroll"); + const scrollFade = dialog.getByTestId("github-connection-scroll-fade"); + const footer = dialog.getByTestId("github-connection-footer"); + const fixedSaveButton = footer.getByRole("button", { name: "Save changes" }); + await expect(fixedSaveButton).toBeVisible(); + const [scrollBox, fadeBox, footerBox, initialSaveBox] = await Promise.all([ + scrollBody.boundingBox(), + scrollFade.boundingBox(), + footer.boundingBox(), + fixedSaveButton.boundingBox(), + ]); + expect(scrollBox).not.toBeNull(); + expect(fadeBox).not.toBeNull(); + expect(footerBox).not.toBeNull(); + expect(initialSaveBox).not.toBeNull(); + expect(fadeBox!.y + fadeBox!.height).toBeCloseTo(scrollBox!.y + scrollBox!.height, 1); + expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height); + await dialog.getByRole("radio", { name: /^GitHub CLI account/ }).click(); + await expect(dialog.getByRole("combobox", { name: "GitHub CLI account" })).toContainText( + "workspace-cli", + ); + await expect(dialog.getByRole("button", { name: "Use account" })).toHaveCount(0); + await expect(dialog.getByRole("heading", { name: "Task Git access" })).toBeVisible(); + const taskHelp = dialog.getByRole("button", { + name: "Explain how managed task credentials work", + }); + await taskHelp.hover(); + await expect( + testPage.getByRole("tooltip", { + name: /agentctl as Git's credential helper/, + }), + ).toBeVisible(); + const cliDescription = dialog.getByText( + "Choose the exact account. Kandev does not silently follow the CLI's active-account change.", + { exact: true }, + ); + const managedDescription = dialog.getByText( + /Kandev brokers the workspace PAT, named GitHub CLI account, or App identity/, + ); + const [cliFontSize, managedFontSize] = await Promise.all([ + cliDescription.evaluate((element) => getComputedStyle(element).fontSize), + managedDescription.evaluate((element) => getComputedStyle(element).fontSize), + ]); + expect(managedFontSize).toBe(cliFontSize); + const managedOption = dialog.getByTestId("github-task-access-option-managed"); + const executorOption = dialog.getByTestId("github-task-access-option-executor"); + const [managedBox, executorBox] = await Promise.all([ + managedOption.boundingBox(), + executorOption.boundingBox(), + ]); + expect(managedBox).not.toBeNull(); + expect(executorBox).not.toBeNull(); + expect(executorBox!.y - (managedBox!.y + managedBox!.height)).toBeLessThanOrEqual(9); + await dialog.getByRole("radio", { name: "Inherit executor Git credentials" }).click(); + await testPage.waitForTimeout(300); + await prCapture.screenshot("desktop-task-git-access-dialog", { + caption: "Task Git access is configured alongside the workspace connection", + }); + await expect(dialog.getByRole("button", { name: "Save task access" })).toHaveCount(0); + const saveButton = dialog.getByRole("button", { name: "Save changes" }); + await expect(saveButton).toHaveCount(1); + const beforeScrollSaveBox = await fixedSaveButton.boundingBox(); + expect(beforeScrollSaveBox).not.toBeNull(); + await scrollBody.evaluate((element) => element.scrollTo(0, element.scrollHeight)); + const scrolledSaveBox = await fixedSaveButton.boundingBox(); + expect(scrolledSaveBox).not.toBeNull(); + expect(scrolledSaveBox!.y).toBeCloseTo(beforeScrollSaveBox!.y, 1); + await saveButton.click(); + await expect(testPage.getByText("GitHub access settings saved")).toBeVisible({ timeout: 10_000, }); + await expect(dialog).not.toBeVisible(); + await expect(automation.getByTestId("github-task-access-summary")).toContainText( + "Inherit executor Git credentials", + ); const response = await apiClient.rawRequest( "GET", @@ -92,6 +210,26 @@ test.describe("GitHub workspace settings", () => { ); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ task_git_credentials_mode: "executor" }); + const statusResponse = await apiClient.rawRequest( + "GET", + `/api/v1/github/status?workspace_id=${seedData.workspaceId}`, + ); + expect(await statusResponse.json()).toMatchObject({ + automation: { source: "gh_cli", login: "workspace-cli" }, + }); + + await automation.getByRole("button", { name: "Change connection" }).click(); + const reopenedDialog = testPage.getByRole("dialog", { name: "Change GitHub connection" }); + await expect( + reopenedDialog.getByRole("radio", { name: "Inherit executor Git credentials" }), + ).toBeChecked(); + await expect(reopenedDialog.locator("#github-method-cli")).toHaveAttribute( + "data-state", + "checked", + ); + await expect( + reopenedDialog.getByRole("combobox", { name: "GitHub CLI account" }), + ).toContainText("workspace-cli"); }); test("repository scope is saved per workspace and filters the GitHub PR list", async ({ diff --git a/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts index 3aa21ed139..297cdd931e 100644 --- a/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts @@ -19,9 +19,13 @@ test.describe("Mobile GitHub authentication settings", () => { await expect(testPage.getByRole("heading", { name: "Workspace GitHub access" })).toBeVisible({ timeout: 15_000, }); - await expect(testPage.getByText("My GitHub identity", { exact: true })).toBeVisible(); - const automation = testPage.getByTestId("github-workspace-automation"); + await expect(testPage.getByText("My GitHub identity", { exact: true })).toHaveCount(0); + await expect( + automation.getByText("This account also powers My GitHub and user-triggered actions.", { + exact: true, + }), + ).toBeVisible(); await automation.getByRole("button", { name: "Change connection" }).click(); const githubSettings = new GitHubAuthSettingsPage(testPage); const connectionDialog = githubSettings.connectionSurface(); @@ -33,14 +37,14 @@ test.describe("Mobile GitHub authentication settings", () => { ).toBeVisible(); await githubSettings.chooseMethod("GitHub CLI account"); - const [accountBox, useAccountBox] = await Promise.all([ + const [accountBox, saveBox] = await Promise.all([ connectionDialog.getByRole("combobox", { name: "GitHub CLI account" }).boundingBox(), - connectionDialog.getByRole("button", { name: "Use account" }).boundingBox(), + connectionDialog.getByRole("button", { name: "Save changes" }).boundingBox(), ]); expect(accountBox).not.toBeNull(); - expect(useAccountBox).not.toBeNull(); + expect(saveBox).not.toBeNull(); expect(accountBox!.height).toBeGreaterThanOrEqual(44); - expect(accountBox!.height).toBeCloseTo(useAccountBox!.height, 1); + expect(saveBox!.height).toBeGreaterThanOrEqual(44); const hasHorizontalOverflow = await testPage.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, diff --git a/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts index 232c729c58..be0f5ed221 100644 --- a/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts @@ -1,34 +1,158 @@ import { test, expect } from "../../fixtures/test-base"; +import { stubGitHubRateLimits } from "./github-rate-limit-fixture"; test.describe("GitHub workspace settings on mobile", () => { - test("explains and saves task Git credential inheritance", async ({ + test("configures task Git access in the connection drawer", async ({ testPage, apiClient, seedData, prCapture, }) => { - await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`); - - await testPage.getByRole("button", { name: "Explain task Git credentials" }).click(); - await expect( - testPage.getByRole("dialog", { name: "How task Git credentials work" }), - ).toContainText("GH_TOKEN or GITHUB_TOKEN"); - await prCapture.screenshot("mobile-task-git-credentials-help", { - caption: "Mobile task Git credential explanation", + await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, { + source: "legacy_shared", + status: "active", }); + await apiClient.mockGitHubSetCLIAccounts([ + { host: "github.com", login: "mobile-cli", active: true, state: "active" }, + ]); + await stubGitHubRateLimits(testPage, seedData.workspaceId); + await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`); + const automation = testPage.getByTestId("github-workspace-automation"); + await expect(automation.getByTestId("github-task-access-summary")).toContainText( + "Managed workspace credentials", + ); + await expect(testPage.getByRole("heading", { name: "My GitHub identity" })).toHaveCount(0); + await expect(testPage.getByRole("heading", { name: "Task Git credentials" })).toHaveCount(0); + const identityHelp = automation.getByRole("button", { + name: "Explain workspace GitHub identity", + }); + const taskAccessHelp = automation.getByRole("button", { name: "Explain task Git access" }); + await expect(identityHelp).toHaveAttribute("aria-haspopup", "dialog"); + await expect(identityHelp).toHaveAttribute("aria-expanded", "false"); + await expect(taskAccessHelp).toHaveAttribute("aria-haspopup", "dialog"); + await expect(taskAccessHelp).toHaveAttribute("aria-expanded", "false"); + const [identityHelpBox, taskAccessHelpBox] = await Promise.all([ + identityHelp.boundingBox(), + taskAccessHelp.boundingBox(), + ]); + expect(identityHelpBox?.height).toBeGreaterThanOrEqual(44); + expect(taskAccessHelpBox?.height).toBeGreaterThanOrEqual(44); + await identityHelp.tap(); + await expect(testPage.getByRole("dialog", { name: "Workspace GitHub identity" })).toContainText( + "repository sync, watches, background jobs, and managed agent GitHub commands", + ); + await testPage.keyboard.press("Escape"); + await taskAccessHelp.tap(); + await expect(testPage.getByRole("dialog", { name: "Task Git access" })).toContainText( + "newly launched tasks authenticate to GitHub", + ); + await testPage.keyboard.press("Escape"); + const rateLimitHelp = automation.getByRole("button", { name: "Show GitHub API limits" }); + await expect(rateLimitHelp).toBeVisible(); + const rateLimitHelpBox = await rateLimitHelp.boundingBox(); + expect(rateLimitHelpBox?.height).toBeGreaterThanOrEqual(44); + await rateLimitHelp.tap(); + const rateLimitDrawer = testPage.getByRole("dialog", { name: "GitHub API limits" }); + await expect(rateLimitDrawer).toContainText( + "API rate limit: 4,321 of 5,000 requests remaining", + ); + await expect(rateLimitDrawer).toContainText( + "GraphQL query limit: 4,900 of 5,000 points remaining", + ); await testPage.keyboard.press("Escape"); - await testPage.getByRole("radio", { name: "Inherit executor Git credentials" }).click(); - await testPage.getByTestId("settings-floating-save").getByRole("button").click(); - await expect(testPage.getByText("Task Git credential settings saved")).toBeVisible({ + await automation.getByRole("button", { name: "Change connection" }).tap(); + const drawer = testPage.getByTestId("github-connection-mobile"); + const drawerBox = await drawer.boundingBox(); + const scrollBody = drawer.getByTestId("github-connection-scroll"); + const scrollFade = drawer.getByTestId("github-connection-scroll-fade"); + const footer = drawer.getByTestId("github-connection-footer"); + const fixedSaveButton = footer.getByRole("button", { name: "Save changes" }); + await expect(fixedSaveButton).toBeVisible(); + const [scrollBox, fadeBox, footerBox, initialSaveBox] = await Promise.all([ + scrollBody.boundingBox(), + scrollFade.boundingBox(), + footer.boundingBox(), + fixedSaveButton.boundingBox(), + ]); + expect(drawerBox).not.toBeNull(); + expect(scrollBox).not.toBeNull(); + expect(fadeBox).not.toBeNull(); + expect(footerBox).not.toBeNull(); + expect(initialSaveBox).not.toBeNull(); + expect(fadeBox!.y + fadeBox!.height).toBeCloseTo(scrollBox!.y + scrollBox!.height, 1); + expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height); + const methodGroup = drawer.getByRole("radiogroup", { name: "Connection method" }); + await expect(methodGroup.getByRole("radio").first()).toHaveAttribute("id", "github-method-cli"); + await drawer.getByRole("radio", { name: /^GitHub CLI account/ }).tap(); + await expect(drawer.getByRole("combobox", { name: "GitHub CLI account" })).toContainText( + "mobile-cli", + ); + await expect(drawer.getByRole("button", { name: "Use account" })).toHaveCount(0); + await expect(drawer.getByRole("heading", { name: "Task Git access" })).toBeVisible(); + await expect(drawer.locator(".overflow-y-auto")).toHaveCount(1); + const taskHelp = drawer.getByRole("button", { + name: "Explain how managed task credentials work", + }); + const taskHelpBox = await taskHelp.boundingBox(); + expect(taskHelpBox?.height).toBeGreaterThanOrEqual(44); + await taskHelp.tap(); + await expect( + testPage.getByRole("dialog", { name: "How managed task credentials work" }), + ).toContainText("agentctl as Git's credential helper"); + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeVisible(); + const executorOption = drawer.getByTestId("github-task-access-option-executor"); + await expect(drawer.getByRole("button", { name: "Save task access" })).toHaveCount(0); + const saveButton = drawer.getByRole("button", { name: "Save changes" }); + const [optionBox, saveButtonBox] = await Promise.all([ + executorOption.boundingBox(), + saveButton.boundingBox(), + ]); + expect(optionBox).not.toBeNull(); + expect(saveButtonBox).not.toBeNull(); + expect(optionBox!.height).toBeGreaterThanOrEqual(44); + expect(saveButtonBox!.height).toBeGreaterThanOrEqual(44); + + const managedOption = drawer.getByTestId("github-task-access-option-managed"); + const [managedBox, compactExecutorBox] = await Promise.all([ + managedOption.boundingBox(), + executorOption.boundingBox(), + ]); + expect(managedBox).not.toBeNull(); + expect(compactExecutorBox).not.toBeNull(); + expect(compactExecutorBox!.y - (managedBox!.y + managedBox!.height)).toBeLessThanOrEqual(9); + const beforeScrollSaveBox = await fixedSaveButton.boundingBox(); + expect(beforeScrollSaveBox).not.toBeNull(); + await scrollBody.evaluate((element) => element.scrollTo(0, element.scrollHeight)); + const scrolledSaveBox = await fixedSaveButton.boundingBox(); + expect(scrolledSaveBox).not.toBeNull(); + expect(scrolledSaveBox!.y).toBeCloseTo(beforeScrollSaveBox!.y, 1); + + await executorOption.tap(); + await prCapture.screenshot("mobile-task-git-access-drawer", { + caption: "Task Git access is configured in the mobile connection drawer", + }); + await saveButton.tap(); + await expect(testPage.getByText("GitHub access settings saved")).toBeVisible({ timeout: 10_000, }); + await expect(automation.getByTestId("github-task-access-summary")).toContainText( + "Inherit executor Git credentials", + ); const response = await apiClient.rawRequest( "GET", `/api/v1/github/workspace-settings?workspace_id=${seedData.workspaceId}`, ); expect(await response.json()).toMatchObject({ task_git_credentials_mode: "executor" }); + const statusResponse = await apiClient.rawRequest( + "GET", + `/api/v1/github/status?workspace_id=${seedData.workspaceId}`, + ); + expect(await statusResponse.json()).toMatchObject({ + automation: { source: "gh_cli", login: "mobile-cli" }, + }); }); test("explains repository scope below issue watches without requiring hover", async ({ diff --git a/apps/web/hooks/domains/github/use-task-git-credentials.test.ts b/apps/web/hooks/domains/github/use-task-git-credentials.test.ts new file mode 100644 index 0000000000..74918fe38c --- /dev/null +++ b/apps/web/hooks/domains/github/use-task-git-credentials.test.ts @@ -0,0 +1,144 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchGitHubWorkspaceSettings, + updateGitHubWorkspaceSettings, +} from "@/lib/api/domains/github-api"; +import type { GitHubWorkspaceSettings, TaskGitCredentialsMode } from "@/lib/types/github"; +import { useTaskGitCredentials } from "./use-task-git-credentials"; + +const WORKSPACE_A = "workspace-a"; +const WORKSPACE_B = "workspace-b"; + +vi.mock("@/lib/api/domains/github-api", () => ({ + fetchGitHubWorkspaceSettings: vi.fn(), + updateGitHubWorkspaceSettings: vi.fn(), +})); + +function workspaceSettings( + workspaceId: string, + mode?: TaskGitCredentialsMode, +): GitHubWorkspaceSettings { + return { + workspace_id: workspaceId, + task_git_credentials_mode: mode, + repo_scope_mode: "all", + repo_scope_orgs: [], + repo_scope_repos: [], + created_at: "2026-07-30T00:00:00Z", + updated_at: "2026-07-30T00:00:00Z", + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("useTaskGitCredentials", () => { + it("loads the workspace task access mode", async () => { + vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue( + workspaceSettings(WORKSPACE_A, "executor"), + ); + + const { result } = renderHook(() => useTaskGitCredentials(WORKSPACE_A)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current).toMatchObject({ mode: "executor", error: false }); + }); + + it("reports a task access load failure", async () => { + vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("unavailable")); + + const { result } = renderHook(() => useTaskGitCredentials(WORKSPACE_A)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current).toMatchObject({ mode: "managed", error: true }); + }); + + it("loads the replacement workspace after a workspace change", async () => { + vi.mocked(fetchGitHubWorkspaceSettings) + .mockResolvedValueOnce(workspaceSettings(WORKSPACE_A, "managed")) + .mockResolvedValueOnce(workspaceSettings(WORKSPACE_B, "executor")); + + const { result, rerender } = renderHook( + ({ workspaceId }) => useTaskGitCredentials(workspaceId), + { initialProps: { workspaceId: WORKSPACE_A } }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ workspaceId: WORKSPACE_B }); + + await waitFor(() => expect(result.current.mode).toBe("executor")); + expect(fetchGitHubWorkspaceSettings).toHaveBeenLastCalledWith(WORKSPACE_B); + }); + + it("updates the loaded mode after a successful save", async () => { + vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue( + workspaceSettings(WORKSPACE_A, "managed"), + ); + vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue( + workspaceSettings(WORKSPACE_A, "executor"), + ); + const { result } = renderHook(() => useTaskGitCredentials(WORKSPACE_A)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let saved: boolean | undefined; + await act(async () => { + saved = await result.current.save("executor"); + }); + + expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({ + workspace_id: WORKSPACE_A, + task_git_credentials_mode: "executor", + }); + expect(saved).toBe(true); + expect(result.current).toMatchObject({ mode: "executor", error: false }); + }); + + it("preserves the loaded state when a save fails", async () => { + vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue( + workspaceSettings(WORKSPACE_A, "managed"), + ); + vi.mocked(updateGitHubWorkspaceSettings).mockRejectedValue(new Error("save failed")); + const { result } = renderHook(() => useTaskGitCredentials(WORKSPACE_A)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await expect(result.current.save("executor")).rejects.toThrow("save failed"); + + expect(result.current).toMatchObject({ mode: "managed", error: false }); + }); + + it("does not apply a completed save from the previous workspace", async () => { + const pendingSave = deferred(); + vi.mocked(fetchGitHubWorkspaceSettings).mockImplementation(async (workspaceId) => + workspaceSettings(workspaceId, workspaceId === WORKSPACE_A ? "managed" : "executor"), + ); + vi.mocked(updateGitHubWorkspaceSettings).mockReturnValue(pendingSave.promise); + const { result, rerender } = renderHook( + ({ workspaceId }) => useTaskGitCredentials(workspaceId), + { initialProps: { workspaceId: WORKSPACE_A } }, + ); + await waitFor(() => expect(result.current.mode).toBe("managed")); + + const save = result.current.save("managed"); + rerender({ workspaceId: WORKSPACE_B }); + await waitFor(() => expect(result.current.mode).toBe("executor")); + + pendingSave.resolve(workspaceSettings(WORKSPACE_A, "managed")); + let saved: boolean | undefined; + await act(async () => { + saved = await save; + }); + + expect(saved).toBe(false); + expect(result.current.mode).toBe("executor"); + }); +}); diff --git a/apps/web/hooks/domains/github/use-task-git-credentials.ts b/apps/web/hooks/domains/github/use-task-git-credentials.ts new file mode 100644 index 0000000000..3cffe9570e --- /dev/null +++ b/apps/web/hooks/domains/github/use-task-git-credentials.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + fetchGitHubWorkspaceSettings, + updateGitHubWorkspaceSettings, +} from "@/lib/api/domains/github-api"; +import type { TaskGitCredentialsMode } from "@/lib/types/github"; + +export type TaskGitCredentialsState = { + mode: TaskGitCredentialsMode; + loading: boolean; + error: boolean; + save: (mode: TaskGitCredentialsMode) => Promise; +}; + +export function useTaskGitCredentials(workspaceId: string): TaskGitCredentialsState { + const [mode, setMode] = useState("managed"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const activeWorkspaceId = useRef(workspaceId); + activeWorkspaceId.current = workspaceId; + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(false); + void fetchGitHubWorkspaceSettings(workspaceId) + .then((settings) => { + if (!cancelled) setMode(settings.task_git_credentials_mode ?? "managed"); + }) + .catch(() => { + if (!cancelled) setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceId]); + + const save = useCallback( + async (nextMode: TaskGitCredentialsMode) => { + const updated = await updateGitHubWorkspaceSettings({ + workspace_id: workspaceId, + task_git_credentials_mode: nextMode, + }); + if (activeWorkspaceId.current !== workspaceId) return false; + setMode(updated.task_git_credentials_mode ?? "managed"); + setError(false); + return true; + }, + [workspaceId], + ); + + return { mode, loading, error, save }; +} diff --git a/docs/plans/task-git-credential-policy/plan.md b/docs/plans/task-git-credential-policy/plan.md index 3b6e81e0d3..d0895eb0f6 100644 --- a/docs/plans/task-git-credential-policy/plan.md +++ b/docs/plans/task-git-credential-policy/plan.md @@ -111,19 +111,31 @@ Confirmed root causes: ## Frontend -### Task Git credentials settings +### Unified GitHub access configuration - Extend `GitHubWorkspaceSettings` and `UpdateGitHubWorkspaceSettingsRequest` in `apps/web/lib/types/github.ts`. -- Add `apps/web/components/github/github-task-credentials-section.tsx`, registered with - `useSettingsSaveContributor`, with two explicit choices: - **Managed workspace credentials** and **Inherit executor Git credentials**. +- Refactor `apps/web/components/github/github-task-credentials-section.tsx` from a standalone + settings section into reusable task-access summary and dialog controls with two explicit choices: + **Managed workspace credentials** and **Inherit executor Git credentials**. The dialog owns the + draft, failure feedback, and saved baseline. - Visible copy must explain local/Worktree host behavior, remote executor behavior, profile-token - precedence, and when each choice applies. Reuse the repository-scope responsive help pattern: - tooltip/focus on a fine pointer and a 44px information-button Drawer on a coarse pointer. + precedence, and when each choice applies. Supplementary responsive help explains the injected + `agentctl` Git credential helper, scoped broker lease, broker-aware `gh` shim, and launch/resume + timing without exposing credentials. - Update `github-auth-method-list.tsx`, `github-connection-dialog.tsx`, and `github-settings.tsx` so PAT, named CLI, and App cards state where their credential is stored/resolved and how managed - tasks receive it. The information disclosure supplements rather than replaces this text. + tasks receive it; the task policy controls live below the method controls in the same bounded + dialog/drawer. Remove the standalone Task Git credentials section from the settings page. +- Replace the PAT, CLI, and task-policy submission buttons with one dialog-level **Save changes** + action in a fixed bottom row that persists every changed local draft. Keep one scrolling content + region above it, add a bottom fade as an overflow cue, place the GitHub CLI card first, and use + compact spacing between task-access options. App create/import/install remain workflow actions + because they navigate through GitHub instead of saving a local credential draft. +- Update `github-status.tsx` so the Workspace GitHub access summary shows the current task access + mode beside the automation identity and refreshes after a successful dialog submission. +- Desktop retains a bounded but wider dialog. Mobile retains the existing full-height Drawer, one + internal scroll owner, a safe-area-aware fixed footer, 44px controls, and no horizontal overflow. ### Changes branch credential disclosure @@ -180,10 +192,11 @@ Confirmed root causes: **File:** `apps/backend/internal/github/auth_resolver_test.go`. **How:** deterministic fake clock and call counter. - **What:** settings drafts load, save, discard, and explain both policies and every automation - method. - **File:** new component/model tests beside - `apps/web/components/github/github-task-credentials-section.tsx`. - **How:** mocked API promises and settings-save provider; keyboard-focus help assertion. + method. + **File:** `apps/web/components/github/github-connection-dialog.test.tsx` and focused tests beside + `apps/web/components/github/github-task-credentials-section.tsx` if state is extracted. + **How:** mocked workspace-settings API promises; verify compact summary, explicit dialog save, + close/discard behavior, refresh, and failure retention. - **What:** valid/malformed/missing session snapshots produce truthful disclosure labels, never an inferred actor. **File:** new Changes credential view-model test and focused header component test. @@ -195,16 +208,18 @@ Targeted pre-PR commands are recorded in the task files. ## E2E Tests -- **Scenario:** GIVEN workspace settings, WHEN the user switches to executor inheritance and saves, - THEN the API persists `executor`; discard/reload and switching back to managed remain coherent, - and the desktop help names PAT/CLI/App delivery and profile-token precedence. +- **Scenario:** GIVEN workspace settings, WHEN the user sees the compact Task access summary, opens + Change GitHub connection, switches to executor inheritance, and explicitly saves, THEN the API + persists `executor`, the summary updates, reopening retains the saved mode, and the automation + identity remains unchanged. **File:** extend `apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts`. -- **Scenario:** GIVEN a coarse-pointer settings viewport, WHEN the task-credential information icon - is tapped, THEN a Drawer contains the same delivery explanation, controls meet 44px targets, and - the page has no horizontal overflow. +- **Scenario:** GIVEN a coarse-pointer settings viewport, WHEN Change GitHub connection opens, THEN + its full-height Drawer contains the task access controls in the same single scroll body, the + controls meet 44px targets, saving updates the page summary, and neither surface has horizontal + overflow. **File:** extend - `apps/web/e2e/tests/integrations/mobile-github-auth-settings.spec.ts`. + `apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts`. - **Scenario:** GIVEN a session seeded with a managed CLI or App snapshot, WHEN a desktop user hovers/focuses the Changes branch trigger, THEN branch/base information and launch-time actor, method, and managed transport are visible. @@ -223,6 +238,9 @@ Production E2E runs only after rebuilding backend and Vite artifacts. - Update `docs/public/integrations.md`, `docs/public/executors.md`, and `docs/public/use-kandev.md` to separate workspace automation method from task credential policy, describe named CLI brokerage, explain executor inheritance, and document the Changes snapshot. +- Update `docs/public/integrations.md` so users find task access inside **Change GitHub connection** + and understand the compact Workspace GitHub access summary; do not describe a standalone Task Git + credentials section. - Update troubleshooting so `agentctl: command not found` is a managed-runtime defect rather than a prompt to fall back to personal SSH, and distinguish managed-helper failure from missing executor credentials. @@ -250,6 +268,10 @@ Wave 4: - [x] [task-06-e2e-and-documentation](task-06-e2e-and-documentation.md) +Wave 5: + +- [x] [task-07-unified-github-access-settings](task-07-unified-github-access-settings.md) + The default execution is sequential in this primary conversation. Waves identify dependency-safe opportunities only and do not authorize subagents. @@ -270,3 +292,6 @@ opportunities only and do not authorize subagents. reintroduce misleading identity. - Agentctl tool-directory activation must be per instance so executor inheritance and explicit profile-token overrides do not accidentally invoke the broker shim. +- Workspace connection and task policy remain separate persistence operations inside one dialog. + The single submission must report complete success only when every changed operation succeeds, + refresh any partial success, and preserve failed or unsaved drafts for retry. diff --git a/docs/plans/task-git-credential-policy/task-07-unified-github-access-settings.md b/docs/plans/task-git-credential-policy/task-07-unified-github-access-settings.md new file mode 100644 index 0000000000..0ce0d3e991 --- /dev/null +++ b/docs/plans/task-git-credential-policy/task-07-unified-github-access-settings.md @@ -0,0 +1,146 @@ +--- +id: "07-unified-github-access-settings" +title: "Group task credentials with Workspace GitHub access" +status: done +wave: 5 +depends_on: ["04-settings-explanation", "06-e2e-and-documentation"] +plan: "plan.md" +spec: "../../specs/integrations/github-authentication.md" +--- + +# Task 07: Group Task Credentials With Workspace GitHub Access + +## Acceptance + +- Workspace GitHub access shows one compact read-only summary containing the active automation + identity and effective task access mode, with no standalone Task Git credentials section. +- PAT/CLI connections describe their shared human identity inside that summary and do not render a + separate My GitHub identity section. App automation keeps the separate, connectable personal + identity section. +- The workspace identity and task-access summary lines provide accessible help: a tooltip on + hover/keyboard focus and an equivalent 44px-target Drawer interaction on touch devices. +- The connection status restores the workspace-scoped GitHub API, GraphQL query, and Search quota + disclosure through the same tooltip/Drawer pattern when snapshots are available. +- Change GitHub connection contains the managed/executor task-access controls and one **Save + changes** action for PAT/CLI and task-access drafts; success refreshes the summary, failure + preserves unsaved drafts, closing without saving restores persisted values, and neither setting + silently determines the other. The action remains visible in a fixed bottom row while one + content region scrolls behind a bottom fade. App create/import/install remain workflow actions. +- Task Git access includes responsive supplementary help that accurately explains the injected Git + credential helper, scoped broker lease, `gh` shim, executor inheritance, and launch/resume timing. + Option descriptions match the dialog's explanatory typography. +- The desktop dialog is widened so the method cards and descriptions are readable without cramped + columns. GitHub CLI is the first method card, and the task-access option gap is compact. +- Desktop and mobile preserve the same capability. The phone flow uses the existing full-height + Drawer with one scroll owner, safe-area clearance, touch-reachable controls, and no horizontal + overflow. Public GitHub integration docs point users to the new location. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/github/github-connection-dialog.test.tsx +cd apps/web && pnpm run typecheck +cd apps/web && pnpm e2e:run tests/integrations/github-workspace-settings.spec.ts -- --project=chromium --grep "configures task Git access from the workspace connection dialog" +cd apps/web && pnpm e2e:run tests/integrations/mobile-github-workspace-settings.spec.ts -- --project=mobile-chrome --grep "configures task Git access in the connection drawer" +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `apps/web/components/github/github-settings.tsx` +- `apps/web/components/github/github-status.tsx` +- `apps/web/components/github/github-rate-limit.tsx` +- `apps/web/components/github/github-task-credentials-section.tsx` +- `apps/web/components/github/github-connection-dialog.tsx` +- `apps/web/components/github/github-connection-dialog.test.tsx` +- `apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts` +- `apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts` +- `docs/public/integrations.md` +- this task file and `plan.md` + +## Dependencies + +Tasks 04 and 06 provide the shipped policy controls, responsive GitHub connection surface, E2E +fixtures, and public explanation that this task regroups without changing backend behavior. + +## Parallelism + +Sequential. The component, desktop/mobile E2E, and documentation edits describe one user-visible +flow and should land together. + +## Inputs + +- Spec `What`, `UX And Mobile Contract`, and grouped-access scenarios. +- ADR `docs/decisions/2026-07-27-task-git-credential-policy.md`: automation identity and task policy + remain behaviorally separate even when shown together. +- ADR `docs/decisions/0046-settings-route-save-coordinator.md` and + `docs/specs/ui/settings-manual-save.md`: dialog-level explicit submissions remain named immediate + actions and do not use the route floating-save contributor. +- `apps/web/components/github/github-connection-dialog.tsx` as the existing desktop Dialog/mobile + full-height Drawer composition. + +## Mobile Design Contract + +- **Outcome and entry point:** the Workspace GitHub access summary exposes the saved task mode; + the same Change connection button opens configuration on desktop and mobile. +- **Exemplar:** reuse `GitHubConnectionDialog` itself for the shipped full-height mobile Drawer, + including its fixed header, single `min-h-0` scroll body, and safe-area bottom padding. Reuse the + responsive help-control pattern from `RepositoryScopeHelp` for fine-pointer tooltips and + coarse-pointer Drawers. +- **Hierarchy and action:** GitHub CLI is the first workspace method, followed by PAT and App; + task access comes second. One **Save changes** action stays fixed in the bottom row while content + scrolls above a gradient cue. PAT/CLI and task access no longer compete with separate submission + buttons. App create/import/install remain explicit external workflow actions. +- **Surface rationale:** both choices are infrequent workspace-level GitHub configuration, so one + bounded dialog/full-height phone Drawer is more appropriate than a second page section or stacked + overlay. +- **Shared versus responsive behavior:** share connection/task drafts, validation, persistence, + footer, fade, and labels; only the existing Dialog/Drawer shell padding differs by viewport. + The footer clears the mobile safe area. Help has the same content on every viewport, with 44px + touch targets on mobile. +- **Proof:** desktop and `mobile-chrome` E2E prove CLI-first order, compact task-option spacing, + fixed footer geometry before and after content scrolling, and the fade position; they then select + a named CLI account and executor mode, save, and reopen to prove persistence. + +## Risks + +- The backend contracts remain independent, so one dialog submission may encounter a partial + failure. Refresh persisted successes, keep failed drafts available for retry, and never report + complete success unless every changed draft succeeds. +- Workspace settings are also read by repository-scope controls. Use partial updates and avoid + introducing a competing whole-resource draft owner. +- App installation may navigate away from Kandev. The task-policy draft must remain explicitly + unsaved unless its own submission succeeds. + +## Output contract + +Report the summary wording, dialog hierarchy, submission behavior, mobile geometry, RED/GREEN +component and E2E results, docs validation, files changed, risks, and task/plan status updates. + +## Verification results + +- `pnpm exec vitest run components/github` — passed (32 files, 289 tests). The focused dialog + tests prove one action can save PAT and task-access drafts together and preserve a failed PAT + draft after a partial save; that regression first failed RED with an empty token. The CLI-form + test proves the preferred account becomes the draft without a separate **Use account** action. +- `pnpm --filter @kandev/web test -- --run components/github/github-status.test.tsx` — passed + (3 tests) after a deliberate RED failure proved PAT/CLI still rendered the redundant personal + identity section. +- `pnpm run typecheck`, focused ESLint for every changed frontend/E2E file, and the earlier full + web lint — passed. +- A fresh `pnpm run build:vite` production build — passed. +- Chromium E2E for `configures task Git access from the workspace connection dialog` — passed + after deliberate RED failures proved the missing summary/API-limit controls, old 638px width, + separate **Use account** action, and PAT-first card order. It now verifies the identity, + task-access and rate-limit tooltips, wider dialog, CLI-first order, typography parity, compact + task-option spacing, a fixed footer and aligned fade before/after scrolling, one **Save changes** + action, combined CLI/task persistence, and reopen state. +- Both mobile GitHub settings E2E files — passed (4 tests), including the nested credential-helper + and API-limit Drawers, 44px controls, one scroll owner, no overflow, combined persistence, and + safe parent-Drawer behavior. The focused production-build rerun first failed RED because no fixed + footer existed, then passed with the safe-area footer, fade, CLI-first order, compact option gap, + and unchanged footer position after scrolling. +- `node --test scripts/validate-public-docs.test.mjs` and + `node scripts/validate-public-docs.mjs` — passed (58 tests; 41 published docs pages). +- `git diff --check` — passed. diff --git a/docs/public/integrations.md b/docs/public/integrations.md index 6e7708302a..af52c96801 100644 --- a/docs/public/integrations.md +++ b/docs/public/integrations.md @@ -67,13 +67,13 @@ managed repository clones. When both environment variables are set, `GH_TOKEN` t matching GitHub CLI behavior. Its token remains in memory during use. Choosing a new workspace connection leaves compatibility mode permanently. -The status panel identifies the selected source, verified actor, connection state, rate limits, and any missing App capabilities. A failed PAT or CLI validation leaves the previous connection intact. An unknown CLI login, revoked PAT, suspended/deleted installation, or missing App permission affects only the bound workspace and displays a reconnect or capability-specific error. +The status panel identifies the selected source, verified actor, connection state, and any missing App capabilities. When GitHub has reported quota data, use **Show GitHub API limits** to inspect the remaining API requests, GraphQL query points, Search requests, and reset times for that workspace connection. The disclosure appears as a tooltip on desktop and a tap-accessible drawer on touch devices. A failed PAT or CLI validation leaves the previous connection intact. An unknown CLI login, revoked PAT, suspended/deleted installation, or missing App permission affects only the bound workspace and displays a reconnect or capability-specific error. ### Automation and personal identity -PAT and CLI connections are human identities. They provide both workspace automation and the fallback identity for **My GitHub** views and user-triggered actions. +PAT and CLI connections are human identities. They provide both workspace automation and the fallback identity for **My GitHub** views and user-triggered actions. Settings show this shared identity inside **Workspace GitHub access** instead of repeating it as a separate **My GitHub identity** section. -A GitHub App installation is an automation identity, not a person. App-backed repository discovery, watches, task Git operations, pull-request creation, reviews, and merges are attributed to the App when the App is the effective actor. To see pull requests or issues assigned to the current user, connect **My GitHub identity** in that workspace. This is a GitHub App user authorization, stored per Kandev user and workspace. +A GitHub App installation is an automation identity, not a person. App-backed repository discovery, watches, task Git operations, pull-request creation, reviews, and merges are attributed to the App when the App is the effective actor. App-backed workspaces therefore show a separate **My GitHub identity** section where you can connect the human account used to see pull requests or issues assigned to the current user. This is a GitHub App user authorization, stored per Kandev user and workspace. Kandev routes credentials as follows: @@ -94,13 +94,23 @@ must be scoped and rotated independently. ### Choose task Git credentials -The **Task Git credentials** setting is separate from the workspace automation connection. It -controls how GitHub HTTPS and `gh` authenticate *inside newly launched task processes*: +Within **Workspace GitHub access**, select **Connect GitHub** or **Change connection** to manage +both the workspace automation connection and **Task Git access**. The page keeps a compact summary +of the saved task access mode. Task Git access controls how GitHub HTTPS and `gh` authenticate +*inside newly launched task processes*: + +For PAT or GitHub CLI connections, choose the connection and task-access settings first, then +select **Save changes** once. GitHub App creation, import, and installation remain separate +GitHub workflows. The help control beside **Task Git access** explains the effective credential +path on desktop hover or focus and in a touch-accessible drawer on mobile. - **Managed workspace credentials** (the default) uses the selected workspace PAT, named GitHub - CLI account, or GitHub App through Kandev's short-lived, task/repository-scoped broker. The task - receives neither the stored PAT nor an App private key. An executor-profile `GH_TOKEN` or - `GITHUB_TOKEN` deliberately takes precedence for that task. + CLI account, or GitHub App through Kandev's short-lived, task/repository-scoped broker. Kandev + configures `agentctl` as Git's credential helper so an attached repository can redeem its + matching lease on demand; the returned credential is not written to the repository or Git + configuration. A separate broker-aware shim handles `gh`. The task receives neither the stored + PAT nor an App private key. An executor-profile `GH_TOKEN` or `GITHUB_TOKEN` deliberately takes + precedence for that task. - **Inherit executor Git credentials** does not install Kandev's broker helper or `gh` shim. Local and Worktree tasks use credentials already visible to the host Git process (including SSH). Docker, SSH, and cloud tasks use only credentials intentionally configured in that executor. diff --git a/docs/specs/integrations/github-authentication.md b/docs/specs/integrations/github-authentication.md index 485ad98845..bbdbb272e6 100644 --- a/docs/specs/integrations/github-authentication.md +++ b/docs/specs/integrations/github-authentication.md @@ -1,7 +1,7 @@ --- status: draft created: 2026-07-19 -amended: 2026-07-28 +amended: 2026-07-31 owner: Kandev --- @@ -50,6 +50,15 @@ automation under different GitHub Apps without operating separate Kandev deploym workspace automation connection. **Inherit executor Git credentials** injects no GitHub broker helper or `gh` shim: Local and Worktree tasks use host-visible Git/SSH credentials, while remote tasks use credentials configured in that executor. +- Workspace settings present the automation identity and task Git credential routing as one + **Workspace GitHub access** group. The page shows a compact read-only summary of both effective + choices; the existing **Change GitHub connection** dialog contains the full controls for the + automation method and task routing policy. The task policy remains independent in behavior and + persistence even though the related choices share one configuration surface. +- The connection dialog has one **Save changes** action for local PAT or named CLI connection + drafts and task Git access. Method-specific **Connect token**, **Use account**, and **Save task + access** actions are not shown. GitHub App create, import, and install remain explicit workflow + actions because they leave Kandev for GitHub rather than saving a local connection draft. - For Kandev-managed GitHub checkouts used by Local and Worktree tasks, the selected task policy also controls the persisted `origin` transport. Managed routing uses canonical GitHub HTTPS. Executor inheritance uses the host's detected `gh` clone protocol, including SSH, and reconciles @@ -415,13 +424,26 @@ registration and never creates a global default. ## UX And Mobile Contract - Workspace GitHub settings lead with the active automation identity and a **Change connection** - command. The method chooser uses a menu/list with PAT, GitHub CLI, and GitHub App descriptions, - not a segmented tab control. + command. The method chooser presents GitHub CLI first, followed by PAT and GitHub App, with + descriptions rather than a segmented tab control. - Method descriptions state where the credential is stored/resolved and how managed tasks receive - it. A separate **Task Git credentials** setting visibly explains **Managed workspace - credentials** and **Inherit executor Git credentials**, including local/Worktree versus remote - behavior and explicit profile-token precedence. An information icon provides the complete - per-method delivery explanation as supplementary help. + it. The same access group shows a compact **Task access** summary and the **Change GitHub + connection** dialog visibly explains and edits **Managed workspace credentials** and **Inherit + executor Git credentials**, including local/Worktree versus remote behavior and explicit + profile-token precedence. The page does not repeat those controls in a standalone settings + section. +- The Task Git access heading exposes supplementary help that explains the managed Git credential + helper, repository-scoped broker lease redemption, broker-aware `gh` shim, executor inheritance, + and when a changed policy takes effect. The visible option descriptions remain plain-language + decision guidance and use the same explanatory typography as the rest of the dialog. +- One **Save changes** submission persists every changed local draft in the dialog: a PAT or named + CLI workspace connection and the task Git access policy. It reports success only after all + changed drafts save, keeps failed drafts available for retry, and never implies that one setting + silently determines the other. The action stays visible in a fixed bottom row while the content + above owns scrolling; a bottom fade cues additional content. App create/import/install remain + separate workflow actions. +- Task Git access options use compact spacing while retaining their full descriptions and minimum + touch targets. - GitHub App selection first explains when to use it and the sharing/isolation trade-off, then lists known registrations and actions to **Add existing App** or **Create new App**. - The import guide provides copyable callback, setup, and webhook URLs; required permissions/events; @@ -430,12 +452,22 @@ registration and never creates a global default. distinguishes installability from Marketplace publication and repository access. - Permission details use a button and dialog. Current actor, installation account, App label, source, visibility, webhook health, and sharing warning are scannable without exposing secrets. -- `My GitHub identity` appears as a connectable section only for App automation. For PAT/CLI it shows - that personal actions use the same verified human identity and offers no fake selector. +- `My GitHub identity` appears as a connectable section only for App automation. For PAT/CLI, + Workspace GitHub access states that My GitHub and user-triggered actions use the same verified + human identity; the page does not render a redundant identity section or a fake selector. +- The workspace identity and task-access summary lines expose concise help through a tooltip on + hover or keyboard focus and the same explanation in a 44px-target drawer on touch devices. +- When rate-limit snapshots are available, the connection status row exposes a **Show GitHub API + limits** icon. Its desktop tooltip and touch drawer show remaining and total API requests, + GraphQL query points, Search requests, and reset timing. Exhausted buckets explain that + background PR and issue checks are paused. - Desktop and mobile support the same create/import/select/install/switch/disconnect flows. Mobile uses a single-column sheet/page, one scroll owner, safe-area padding, 44px targets, no fixed footer, and no horizontal overflow. External GitHub navigation is deliberate and returns to the same workspace settings route. +- The desktop connection dialog is wide enough for the three method cards and explanatory content + without cramped columns. Mobile keeps the existing full-height single-column drawer; its one + **Save changes** action remains inside the scroll owner and is at least 44px tall. - The Changes panel branch disclosure includes the active session's launch-time Git credential snapshot. Desktop supports hover and keyboard focus; coarse-pointer/mobile users open the same information in a 44px-target drawer. Unknown inherited/profile actors are explicitly labeled @@ -477,6 +509,26 @@ registration and never creates a global default. - **GIVEN** a workspace selects executor inheritance, **WHEN** a Local/Worktree or remote task launches, **THEN** Kandev injects no broker helper/shim and the task uses host-visible or executor-configured credentials respectively. +- **GIVEN** a configured workspace, **WHEN** the user views Workspace GitHub access, **THEN** one + compact summary identifies both the workspace automation identity and the effective task access + mode without rendering a separate Task Git credentials settings section. +- **GIVEN** a PAT or named CLI workspace, **WHEN** the user views Workspace GitHub access, **THEN** + the page states that the same account powers My GitHub and user-triggered actions without + rendering a separate My GitHub identity section, and accessible help explains both the workspace + identity and task-access summary. +- **GIVEN** a workspace status with GitHub rate-limit snapshots, **WHEN** the user hovers, focuses, + or taps **Show GitHub API limits**, **THEN** the disclosure shows the remaining and total API, + GraphQL query, and Search quotas with reset timing for that workspace connection. +- **GIVEN** a PAT or named CLI connection draft and a changed task access mode, **WHEN** the user + presses the dialog's single **Save changes** action, **THEN** both drafts are persisted, the dialog + closes only after both succeed, and reopening shows the selected account and task mode. +- **GIVEN** a changed task access mode but no PAT or CLI connection change, **WHEN** the user presses + **Save changes**, **THEN** only the task policy is persisted and the selected automation identity + remains unchanged. +- **GIVEN** managed task access, **WHEN** the user hovers, focuses, or taps the Task Git access help, + **THEN** it explains that Git calls Kandev's `agentctl` credential helper for attached GitHub + HTTPS repositories, the helper redeems a scoped broker lease on demand, `gh` uses a broker-aware + shim, and credentials are not written into the repository. - **GIVEN** the host `gh` clone protocol is SSH and a Kandev-managed GitHub checkout currently has an HTTPS `origin`, **WHEN** the workspace selects executor inheritance and launches a Local or Worktree task, **THEN** Kandev changes that managed checkout's `origin` to the canonical SSH URL @@ -509,6 +561,9 @@ registration and never creates a global default. exposing the token. - **GIVEN** desktop and mobile viewports, **WHEN** users complete every App flow, **THEN** actions and disclosures remain usable without clipping, overlap, or desktop-only capability. +- **GIVEN** a mobile coarse-pointer viewport, **WHEN** the user opens Change GitHub connection, + **THEN** the task access controls share the existing full-height drawer's single scroll owner, + remain touch reachable, clear the safe area, and introduce no horizontal overflow. - **GIVEN** desktop fine-pointer and mobile coarse-pointer task views, **WHEN** the branch disclosure is opened, **THEN** both show the same credential policy, method, actor truth, and transport without horizontal overflow.