Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .t3team-additive-guard.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
"apps/web/src/routes/__root.tsx",
"apps/web/src/routes/_chat.pull-requests.tsx",
"apps/web/src/routes/_chat.tsx",
"apps/web/src/routes/_chat.index.tsx",
"apps/web/src/rpc/atomRegistry.ts",
"apps/web/src/session-logic.test.ts",
"apps/web/src/session-logic.ts",
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { useEnvironments } from "../state/environments";
import { APP_DISPLAY_NAME } from "~/branding";
import { hasCloudPublicConfig } from "~/cloud/publicConfig";
import { T3TeamStartupMyWorkGate } from "~/t3team/t3team-StartupMyWorkGate";

function ChatIndexRouteView() {
const { authGateState } = Route.useRouteContext();
Expand All @@ -31,7 +32,11 @@ function ChatIndexRouteView() {
if (environments.length === 0) return <HostedStaticOnboardingState />;
}

return <IndexDraftLanding />;
return (
<T3TeamStartupMyWorkGate>
<IndexDraftLanding />
</T3TeamStartupMyWorkGate>
);
Comment on lines +35 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mount the startup gate on the reachable startup route

On a cold load of /, this component is never reached: the parent /_chat route calls translateUpstreamPath and throws redirects in _chat.tsx:212-224, while that bridge maps / directly to /t3team in t3team-upstreamRouteBridge.ts:134-136 before child route components mount. Consequently the gate never probes or navigates, so the advertised startup behavior remains unchanged; mount this decision before that redirect or on the /t3team route.

AGENTS.md reference: AGENTS.md:L23-L24

Useful? React with 👍 / 👎.

}

/**
Expand Down
12 changes: 1 addition & 11 deletions apps/web/src/t3team/t3team-AllProjectsMyWorkView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,7 @@ import {
} from "~/t3team/t3team-ProjectMyWorkViewSwitch";
import { ProjectMyWorkLoadingState } from "~/t3team/t3team-projectMyWorkContentState";
import { useT3TeamBetaFlags } from "~/t3team/t3team-betaFlags";
import type { ProjectShellProject } from "@t3tools/project-context";

/**
* Projects whose work items can be fetched at all: a local-only project has no external work
* source, so a "my work" section for it would always be empty.
*/
export function selectBoundProjects(
projects: ReadonlyArray<ProjectShellProject>,
): ReadonlyArray<ProjectShellProject> {
return projects.filter((project) => project.source && project.source.provider !== "local");
}
import { selectBoundProjects } from "~/t3team/t3team-allProjectsMyWorkProjects";

export function AllProjectsMyWorkView({
onOpenTicket,
Expand Down
141 changes: 141 additions & 0 deletions apps/web/src/t3team/t3team-StartupMyWorkGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Cold-start gate on the index route: opens My Work when it has something to show, otherwise
* renders `children` (the draft landing) unchanged. Nothing paints while it decides, so the draft
* landing never flashes before a redirect; the wait is bounded so a slow Jira never blocks startup.
*
* The probe reads the same inputs `AllProjectsMyWorkView` does — bound projects, the all-projects
* digest graph, the resolved lens — and the digest hook caches its graph per scope, so My Work
* paints from that cache right after the redirect instead of fetching again.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";
import type { ProjectShellProject } from "@t3tools/project-context";

import { isElectron } from "~/env";
import { useNowMinute } from "~/hooks/useNowMinute";
import { useAllEnvironmentShellsBootstrapped, useProjects } from "~/state/entities";
import { BackendProvider, createT3Backend } from "~/t3team/backend/t3team-index";
import { hydrateStoredProjects } from "~/t3team/hooks/t3team-projectStorePersistence";
import {
deriveLooseWorkspaceProjects,
loadStoredProjects,
reconcileStoredProjectsWithLive,
} from "~/t3team/hooks/t3team-projectStoreUtils";
import { useMyWorkDigestGraph } from "~/t3team/mywork-digest/t3team-useMyWorkDigestGraph";
import { selectBoundProjects } from "~/t3team/t3team-allProjectsMyWorkProjects";
import {
getProjectDashboardMyWorkStorageKey,
readPersistedProjectDashboardMyWorkState,
resolveProjectDashboardMyWorkState,
} from "~/t3team/t3team-projectDashboardMyWorkState";
import { resolveWsBaseUrl } from "~/t3team/t3team-route-surface-wsUrl";
import {
decideStartupLanding,
deriveStartupMyWorkProbe,
isIndexBootUrl,
isStartupLandingPending,
markStartupLandingDecided,
readBootUrl,
} from "~/t3team/t3team-startupLanding.logic";

/** Longest the cold start waits on My Work before showing the regular landing. */
const STARTUP_MY_WORK_TIMEOUT_MS = 2_000;

/** The All-projects My Work view keys its state under this pseudo project id. */
const ALL_PROJECTS_MY_WORK_ID = "all";

function readStartupLandingEligible(): boolean {
return isStartupLandingPending() && isIndexBootUrl(readBootUrl() ?? "", isElectron);
}

export function T3TeamStartupMyWorkGate({ children }: { readonly children: ReactNode }) {
const [eligible] = useState(readStartupLandingEligible);
const [fellBack, setFellBack] = useState(false);
const [backend] = useState(() => (eligible ? createT3Backend(resolveWsBaseUrl()) : null));
const fallBack = useCallback(() => setFellBack(true), []);
// Claim the session's one decision as soon as it starts: a user who clicks away mid-wait and
// later comes back to `/` chose the index, and must not be bounced to My Work then.
useEffect(() => {
if (eligible) markStartupLandingDecided();
}, [eligible]);

if (!eligible || fellBack || backend === null) return children;
return (
<BackendProvider backend={backend}>
<StartupMyWorkProbe onFallback={fallBack} />
</BackendProvider>
);
}

function StartupMyWorkProbe({ onFallback }: { readonly onFallback: () => void }) {
const navigate = useNavigate();
const boundProjects = useStartupBoundProjects();
const [lens] = useState(
() =>
resolveProjectDashboardMyWorkState({
persisted: readPersistedProjectDashboardMyWorkState(
getProjectDashboardMyWorkStorageKey(ALL_PROJECTS_MY_WORK_ID),
),
}).lens,
);
const digest = useMyWorkDigestGraph({
projects: boundProjects ?? EMPTY_PROJECTS,
scope: "all",
enabled: (boundProjects?.length ?? 0) > 0,
});
const nowMs = Date.parse(`${useNowMinute()}Z`);
const [timedOut, setTimedOut] = useState(false);
const actedRef = useRef(false);

useEffect(() => {
const timer = setTimeout(() => setTimedOut(true), STARTUP_MY_WORK_TIMEOUT_MS);
return () => clearTimeout(timer);
}, []);

const decision = decideStartupLanding({
eligible: true,
probe: deriveStartupMyWorkProbe({ boundProjects, digest, lens, nowMs }),
timedOut,
});

useEffect(() => {
if (decision === "wait" || actedRef.current) return;
actedRef.current = true;
if (decision === "my-work") {
void navigate({ to: "/t3team/my-work", replace: true });
} else {
onFallback();
}
}, [decision, navigate, onFallback]);

return null;
}

const EMPTY_PROJECTS: ReadonlyArray<ProjectShellProject> = [];

/** `AllProjectsMyWorkView`'s bound projects, or `null` until stored and live projects are loaded. */
function useStartupBoundProjects(): ReadonlyArray<ProjectShellProject> | null {
const liveProjects = useProjects();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const [storedProjects, setStoredProjects] = useState<ProjectShellProject[] | null>(null);

useEffect(() => {
let cancelled = false;
void hydrateStoredProjects()
.catch(() => loadStoredProjects())
.then((projects) => {
if (!cancelled) setStoredProjects(projects);
});
return () => {
cancelled = true;
};
}, []);

return useMemo(() => {
if (storedProjects === null || !bootstrapped) return null;
return selectBoundProjects([
...reconcileStoredProjectsWithLive(storedProjects, liveProjects),
...deriveLooseWorkspaceProjects(storedProjects, liveProjects),
]);
}, [bootstrapped, liveProjects, storedProjects]);
}
2 changes: 1 addition & 1 deletion apps/web/src/t3team/t3team-allProjectsMyWork.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";

import { selectBoundProjects } from "~/t3team/t3team-AllProjectsMyWorkView";
import { selectBoundProjects } from "~/t3team/t3team-allProjectsMyWorkProjects";
import {
parseT3TeamViewFromPath,
resolveT3TeamRouteSearchTarget,
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/t3team/t3team-allProjectsMyWorkProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ProjectShellProject } from "@t3tools/project-context";

/**
* Projects whose work items can be fetched at all: a local-only project has no external work
* source, so a "my work" section for it would always be empty.
*/
export function selectBoundProjects(
projects: ReadonlyArray<ProjectShellProject>,
): ReadonlyArray<ProjectShellProject> {
return projects.filter((project) => project.source && project.source.provider !== "local");
}
111 changes: 111 additions & 0 deletions apps/web/src/t3team/t3team-startupLanding.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from "vite-plus/test";

import {
DIGEST_FIXTURE_NOW_MS,
digestFixtureGraph,
viewer,
} from "~/t3team/t3team-projectMyWorkDigestFixtures";
import { buildDigestPlan, type DigestGraph } from "~/t3team/t3team-projectMyWorkDigestPlan";
import {
decideStartupLanding,
deriveStartupMyWorkProbe,
isIndexBootUrl,
isStartupLandingPending,
markStartupLandingDecided,
resetStartupLandingSessionForTests,
type StartupMyWorkProbe,
} from "~/t3team/t3team-startupLanding.logic";

const emptyGraph: DigestGraph = {
scope: "all",
projects: [],
viewer,
tickets: [],
claims: [],
decisions: [],
changeRequests: [],
transitions: [],
blockers: [],
};

const ready = (graph: DigestGraph | null) =>
({ graph, status: graph ? "ready" : "loading", sessionExpired: false }) as const;

const probeFor = (
boundProjects: ReadonlyArray<unknown> | null,
digest: Parameters<typeof deriveStartupMyWorkProbe>[0]["digest"],
lens: "digest" | "board" = "digest",
) => deriveStartupMyWorkProbe({ boundProjects, digest, lens, nowMs: DIGEST_FIXTURE_NOW_MS });

const decide = (probe: StartupMyWorkProbe, timedOut = false) =>
decideStartupLanding({ eligible: true, probe, timedOut });

describe("startup landing", () => {
it("keeps the draft landing when no project is bound to Jira", () => {
// No digest fetch runs without a bound project, so the hook never leaves "loading".
expect(decide(probeFor([], ready(null)))).toBe("default");
});

it("keeps the draft landing when Jira is bound but My Work has nothing to show", () => {
expect(buildDigestPlan(emptyGraph, DIGEST_FIXTURE_NOW_MS).sections).toHaveLength(0);
expect(decide(probeFor(["p"], ready(emptyGraph)))).toBe("default");
expect(decide(probeFor(["p"], ready(emptyGraph), "board"))).toBe("default");
});

it("opens My Work when it has items, by the user's own lens", () => {
expect(decide(probeFor(["p"], ready(digestFixtureGraph)))).toBe("my-work");
expect(decide(probeFor(["p"], ready(digestFixtureGraph), "board"))).toBe("my-work");
});

it("waits, without painting, while projects or the digest are loading", () => {
expect(decide(probeFor(null, ready(null)))).toBe("wait");
expect(decide(probeFor(["p"], ready(null)))).toBe("wait");
});

it("falls back once a slow load outlasts the deadline", () => {
expect(decide(probeFor(null, ready(null)), true)).toBe("default");
expect(decide(probeFor(["p"], ready(null)), true)).toBe("default");
// Data that lands in time still wins over the deadline.
expect(decide(probeFor(["p"], ready(digestFixtureGraph)), true)).toBe("my-work");
});

it("falls back at once when the digest fails or the Jira session expired", () => {
for (const status of ["retrying", "error"] as const) {
expect(decide(probeFor(["p"], { graph: null, status, sessionExpired: false }))).toBe(
"default",
);
}
expect(decide(probeFor(["p"], { graph: null, status: "loading", sessionExpired: true }))).toBe(
"default",
);
});

it("never redirects a landing that is not eligible", () => {
const probe = probeFor(["p"], ready(digestFixtureGraph));
expect(decideStartupLanding({ eligible: false, probe, timedOut: false })).toBe("default");
});
});

describe("startup landing session guard", () => {
afterEach(() => resetStartupLandingSessionForTests());

it("decides once per app session, so returning to / never bounces back to My Work", () => {
expect(isStartupLandingPending()).toBe(true);
markStartupLandingDecided();
expect(isStartupLandingPending()).toBe(false);
markStartupLandingDecided();
expect(isStartupLandingPending()).toBe(false);
});

it("only treats a document loaded on the index route as a cold start", () => {
expect(isIndexBootUrl("https://app.t3.codes/", false)).toBe(true);
expect(isIndexBootUrl("https://app.t3.codes/?x=1", false)).toBe(true);
expect(isIndexBootUrl("https://app.t3.codes/settings", false)).toBe(false);
expect(isIndexBootUrl("https://app.t3.codes/env/thread", false)).toBe(false);
// Desktop routes in the hash.
expect(isIndexBootUrl("t3code://app/index.html", true)).toBe(true);
expect(isIndexBootUrl("t3code://app/index.html#/", true)).toBe(true);
expect(isIndexBootUrl("t3code://app/index.html#/settings", true)).toBe(false);
expect(isIndexBootUrl("not a url", false)).toBe(false);
});
});
Loading
Loading