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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 8 additions & 23 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Button, EmptyState } from "@corbits/react-ui";
import { WorkbenchLoadingState } from "@/chat";
import { QueryClientProvider } from "@tanstack/react-query";
import { BoldIconProvider, WarningCircle } from "@/lib/icons";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";

import { AuthScreen } from "./auth-screen";
import { BenchProvider } from "./bench-context";
Expand All @@ -18,32 +18,13 @@ import { NotFoundPage } from "./pages/not-found-page";
import { OnboardingPage } from "./pages/onboarding-page";
import { ProvisioningErrorPage } from "./pages/provisioning-error-page";
import { createAppQueryClient } from "./query-client";
import { Redirect } from "./redirect";
import { APP_ROUTES, LOGIN_PATH, matchesRoute, ONBOARDING_PATH } from "./routes";
import type { SessionState, SessionUser } from "./session";
import { AppShell } from "./shell/app-shell";
import { ComposerInsertionProvider } from "./shell/composer-insertion";
import { ShellChromeProvider } from "./shell/shell-chrome-provider";

/** Any signed-out request for a path other than `/login` itself bounces
* there with `?next=` so a successful sign-in returns to where the visitor
* meant to go — the URL is the source of truth for "where was I headed",
* not an implicit conditional swap in `App`. */
function LoginRedirect({ path, navigate }: { readonly path: string; readonly navigate: Navigate }) {
useEffect(() => {
navigate(buildLoginRedirect(path));
}, [path, navigate]);
return null;
}

/** An already-authed visit to `/login` (a stale tab, a bookmark) bounces
* home rather than showing the sign-in form to someone already signed in. */
function LoginBounceHome({ navigate }: { readonly navigate: Navigate }) {
useEffect(() => {
navigate("/");
}, [navigate]);
return null;
}

/**
* Onboarding renders above the shell entirely — no rail, no col2, no bench
* dock, nothing that implies a workbench already exists. An account the
Expand Down Expand Up @@ -150,8 +131,10 @@ export function App({
</div>
);
case "signed-out":
// The URL is the source of truth for "where was I headed": a
// signed-out request for another path carries `?next=` to login.
if (path !== LOGIN_PATH) {
return <LoginRedirect path={path} navigate={navigate} />;
return <Redirect to={buildLoginRedirect(path)} from={path} navigate={navigate} />;
}
return <AuthScreen onSignedIn={onSignedIn} />;
case "error":
Expand All @@ -170,8 +153,10 @@ export function App({
</div>
);
case "signed-in":
// An already-authed visit to `/login` (a stale tab, a bookmark)
// bounces home rather than showing the sign-in form again.
if (path === LOGIN_PATH) {
return <LoginBounceHome navigate={navigate} />;
return <Redirect to="/" from={path} navigate={navigate} />;
}
if (path === ONBOARDING_PATH) {
return <OnboardingGate navigate={navigate} user={session.user} />;
Expand Down
13 changes: 5 additions & 8 deletions apps/web/src/auth/dither-background.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { useEffect, useRef } from "react";

// 8x8 ordered Bayer threshold matrix (same as the corbits dither shader).
// prettier-ignore
const BAYER = [
Expand Down Expand Up @@ -39,10 +37,9 @@ const ASSET = "/images/hero-dither.png"; // same-origin source image
* static frame, re-evaluated when the OS setting toggles).
*/
export function DitherBackground({ className }: { className?: string }) {
const ref = useRef<HTMLCanvasElement>(null);

useEffect(() => {
const canvas = ref.current;
// The animation belongs to the canvas element, so it starts and stops with
// it: a ref callback with a cleanup, never an effect reaching for a ref.
const attach = (canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
Expand Down Expand Up @@ -254,11 +251,11 @@ export function DitherBackground({ className }: { className?: string }) {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onMove);
};
}, []);
};

return (
<canvas
ref={ref}
ref={attach}
aria-hidden
className={className}
style={{
Expand Down
14 changes: 8 additions & 6 deletions apps/web/src/auth/quote-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type } from "arktype";
import { useEffect, useState } from "react";
import { useState } from "react";

export const QuoteSchema = type({
quote: "string",
Expand Down Expand Up @@ -54,16 +54,18 @@ function nextIndex(): number {
* localStorage) — it does not cycle while the page is open.
*/
export function QuoteCard() {
const [index] = useState(nextIndex);

useEffect(() => {
// Picked and persisted once, as the card mounts — the rotation advances
// per page load, never while the page is open.
const [index] = useState(() => {
const next = nextIndex();
try {
localStorage.setItem(STORAGE_KEY, String(index));
localStorage.setItem(STORAGE_KEY, String(next));
} catch {
// localStorage unavailable (private mode / blocked) — rotation just
// restarts from the first quote next load.
}
}, [index]);
return next;
});

const current = QUOTES[index % QUOTES.length];
if (current === undefined) return null;
Expand Down
14 changes: 7 additions & 7 deletions apps/web/src/bench-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { isRawIdentifier } from "@/bench";
import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";

import type { APIQuery } from "@/lib/api-query";
Expand Down Expand Up @@ -84,12 +84,12 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) {
const resolved =
memberships.kind === "ready" ? resolveSelection(memberships.data.data, stored) : undefined;

useEffect(() => {
if (resolved !== undefined && resolved.tenantId !== stored) {
writeStoredTenantId(resolved.tenantId);
setStored(resolved.tenantId);
}
}, [resolved, stored]);
// The resolved bench is the stored one: written during render so no
// consumer reads a selection the store disagrees with.
if (resolved !== undefined && resolved.tenantId !== stored) {
writeStoredTenantId(resolved.tenantId);
setStored(resolved.tenantId);
}

const value = useMemo<BenchState>(
() => ({
Expand Down
137 changes: 75 additions & 62 deletions apps/web/src/chat/blocks/approve-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

import { Button, toast } from "@corbits/react-ui";
import type { ApproveBlockData } from "../wire/blocks";
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";

import { CHAT_STRINGS } from "../strings";
import { BlockCard } from "./block-card";
Expand Down Expand Up @@ -119,78 +120,90 @@ export function ApproveBlockView({
readonly data: ApproveBlockData;
readonly actions?: ApprovalActions;
}) {
const [live, setLive] = useState<ApprovalStatusQuery>({ kind: "loading" });
const [deciding, setDeciding] = useState<DecisionInFlight>(null);
const [decisionError, setDecisionError] = useState<string | null>(null);
const [resolvedElsewhere, setResolvedElsewhere] = useState(false);
const [allowingStanding, setAllowingStanding] = useState(false);

useEffect(() => {
if (actions === undefined) return;
let cancelled = false;
setLive({ kind: "loading" });
setResolvedElsewhere(false);
actions.getStatus(data.approvalId).then((result) => {
if (!cancelled) setLive(result);
});
return () => {
cancelled = true;
};
}, [actions, data.approvalId]);
const status = useQuery<ApprovalStatusQuery>({
queryKey: ["approval-status", data.approvalId],
queryFn: () =>
actions === undefined
? Promise.resolve<ApprovalStatusQuery>({ kind: "loading" })
: actions.getStatus(data.approvalId),
enabled: actions !== undefined,
});
const live: ApprovalStatusQuery = status.data ?? { kind: "loading" };

// Never trust a decision response (or a local guess) over the platform's
// own state — every outcome, success or failure alike, re-reads the
// status above and renders only what comes back.
const decideMutation = useMutation({
mutationFn: (kind: "approve" | "reject") => {
if (actions === undefined) throw new Error("approval actions unavailable");
const call = kind === "approve" ? actions.approve : actions.reject;
return call(data.approvalId);
},
onSuccess: (result, kind) => {
if (result.kind === "resolved") {
setResolvedElsewhere(false);
toast(
kind === "approve"
? CHAT_STRINGS.blockApproveStatusApproved
: CHAT_STRINGS.blockApproveStatusRejected,
);
} else if (result.kind === "conflict") {
// Someone/something else resolved this first. There is nothing to
// retry — the refreshed terminal status speaks, with a calmer note
// than a bare error.
setResolvedElsewhere(true);
} else {
setResolvedElsewhere(false);
setDecisionError(
result.kind === "forbidden"
? CHAT_STRINGS.blockApproveActionForbidden
: CHAT_STRINGS.blockApproveActionError,
);
}
},
onSettled: () => {
void status.refetch();
},
});

const allowStandingMutation = useMutation({
mutationFn: () => {
if (actions?.allowStanding === undefined) {
throw new Error("standing approval unavailable");
}
return actions.allowStanding(data.approvalId);
},
onSuccess: (result) => {
if (result.kind === "resolved") {
toast(CHAT_STRINGS.blockApproveStatusApproved);
} else if (result.kind !== "conflict") {
setDecisionError(
result.kind === "forbidden"
? CHAT_STRINGS.blockApproveActionForbidden
: CHAT_STRINGS.blockApproveActionError,
);
}
},
onSettled: () => {
void status.refetch();
},
});

const deciding: DecisionInFlight = decideMutation.isPending ? decideMutation.variables : null;
const allowingStanding = allowStandingMutation.isPending;

function decide(kind: "approve" | "reject") {
if (actions === undefined) return;
setDeciding(kind);
setDecisionError(null);
const call = kind === "approve" ? actions.approve : actions.reject;
call(data.approvalId)
.then((result) => {
if (result.kind === "resolved") {
setResolvedElsewhere(false);
toast(
kind === "approve"
? CHAT_STRINGS.blockApproveStatusApproved
: CHAT_STRINGS.blockApproveStatusRejected,
);
} else if (result.kind === "conflict") {
// Someone/something else resolved this first. There is nothing
// to retry -- re-sync below and let the refreshed terminal
// status speak, with a calmer note than a bare error.
setResolvedElsewhere(true);
} else {
setResolvedElsewhere(false);
setDecisionError(
result.kind === "forbidden"
? CHAT_STRINGS.blockApproveActionForbidden
: CHAT_STRINGS.blockApproveActionError,
);
}
// Never trust the decision response (or a local guess) over the
// platform's own state -- re-read it after every outcome, success
// or failure alike, and render only what comes back.
return actions.getStatus(data.approvalId).then(setLive);
})
.finally(() => setDeciding(null));
decideMutation.mutate(kind);
}

function allowStanding() {
if (actions?.allowStanding === undefined) return;
setAllowingStanding(true);
actions
.allowStanding(data.approvalId)
.then((result) => {
if (result.kind === "resolved") {
toast(CHAT_STRINGS.blockApproveStatusApproved);
} else if (result.kind !== "conflict") {
setDecisionError(
result.kind === "forbidden"
? CHAT_STRINGS.blockApproveActionForbidden
: CHAT_STRINGS.blockApproveActionError,
);
}
return actions.getStatus(data.approvalId).then(setLive);
})
.finally(() => setAllowingStanding(false));
allowStandingMutation.mutate();
}

const view = deriveApproveCardView({
Expand Down
38 changes: 22 additions & 16 deletions apps/web/src/chat/blocks/connect-service-block-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
// disconnected key-paste-free framing with a disabled-by-inaction
// connect that goes nowhere, matching the "no port, no feature"
// fallback every other block uses.
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import type { ConnectServiceBlockData } from "../wire/blocks";

import type { ConnectServiceActions, ConnectServiceQuery } from "./connect-service-actions";
Expand All @@ -20,24 +21,29 @@ export function ConnectServiceBlockContainer({
readonly data: ConnectServiceBlockData;
readonly actions?: ConnectServiceActions;
}) {
const [query, setQuery] = useState<ConnectServiceQuery>({ kind: "loading" });
const queryClient = useQueryClient();
// Keyed by connector, not by message: the connection is the tenant's, so
// every card for the same service shares one read.
const queryKey = ["connect-state", data.connectorId] as const;
const live = useQuery<ConnectServiceQuery>({
queryKey,
queryFn: () =>
actions === undefined
? Promise.resolve<ConnectServiceQuery>({ kind: "loading" })
: actions.getConnectState(data.connectorId),
enabled: actions !== undefined,
});
const query: ConnectServiceQuery = live.data ?? { kind: "loading" };

// The live fold is a subscription, so it writes into the cache the read
// above already owns rather than keeping a second copy beside it.
useEffect(() => {
if (actions === undefined) return;
let cancelled = false;

function applyQuery(result: ConnectServiceQuery) {
if (cancelled) return;
setQuery(result);
}

void actions.getConnectState(data.connectorId).then(applyQuery);
const unsubscribe = actions.subscribeConnectState(data.connectorId, applyQuery);
return () => {
cancelled = true;
unsubscribe();
};
}, [actions, data.connectorId]);
return actions.subscribeConnectState(data.connectorId, (result) => {
queryClient.setQueryData(queryKey, result);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- queryKey is derived from connectorId
}, [actions, data.connectorId, queryClient]);

if (query.kind === "connected") {
return <ConnectServiceBlockView kind="connected" displayName={data.displayName} />;
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/chat/turn-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,13 @@ export function useTurnActivity(
} {
const [activity, setActivity] = useState<TurnActivityState>(null);

useEffect(() => {
// Another workbench's activity must never show for a frame, so the reset
// happens during render rather than after the paint that would leak it.
const [activityFor, setActivityFor] = useState(workbenchId);
if (activityFor !== workbenchId) {
setActivityFor(workbenchId);
setActivity(null);
}, [workbenchId]);
}

// Reset (clear + re-arm) on every event that actually changes the
// activity object — an ignored event never resets the clock, since
Expand Down
Loading
Loading