Repository navigation
feat(web): open My Work at startup when it has items #441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
johnnyelwailer
merged 3 commits into
main
from
feature/open-on-my-work-when-data-exists-6a385915
Oct 7, 2026
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
111
apps/web/src/t3team/t3team-startupLanding.logic.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a cold load of
/, this component is never reached: the parent/_chatroute callstranslateUpstreamPathand throws redirects in_chat.tsx:212-224, while that bridge maps/directly to/t3teamint3team-upstreamRouteBridge.ts:134-136before 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/t3teamroute.AGENTS.md reference: AGENTS.md:L23-L24
Useful? React with 👍 / 👎.