1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { createFileRoute, Link, useRouter } from "@tanstack/react-router";
4import { LinkIcon, PlusIcon } from "lucide-react";
5import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
6
7import { isLocalEnvironmentDisabled } from "../localEnvironment";
8import { isElectron } from "../env";
9import { NoProjectsHero } from "../components/NoProjectsHero";
10import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
11import { Button } from "../components/ui/button";
12import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
13import { SidebarInset } from "../components/ui/sidebar";
14import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
15import { useNewThreadHandler } from "../hooks/useHandleNewThread";
16import {
17 readProject,
18 useAllEnvironmentShellsBootstrapped,
19 useProjects,
20 useThreadShells,
21} from "../state/entities";
22import { useEnvironments } from "../state/environments";
23import { APP_DISPLAY_NAME } from "~/branding";
24
25function ChatIndexRouteView() {
26 const { authGateState } = Route.useRouteContext();
27 const { environments, isReady } = useEnvironments();
28
29 if (authGateState.status === "hosted-static") {
30 if (!isReady) return null;
31 if (environments.length === 0) return <HostedStaticOnboardingState />;
32 }
33
34 return <IndexDraftLanding />;
35}
36
37/**
38 * Landing on the index route drops straight into a draft thread for the most
39 * recently active project, so the first screen is a prompt instead of a dead
40 * end. Falls back to an add-project hero when no project exists yet.
41 */
42function IndexDraftLanding() {
43 const projects = useProjects();
44 const threads = useThreadShells();
45 const bootstrapped = useAllEnvironmentShellsBootstrapped();
46 const handleNewThread = useNewThreadHandler();
47 const router = useRouter();
48 // Read at effect time: this page stays mounted while a navigation away from
49 // "/" is still loading, but the router already points at the new location.
50 const isStillOnIndex = useEffectEvent(() => router.state.location.pathname === "/");
51 const startingRef = useRef(false);
52 const [startState, setStartState] = useState({ failed: false, retryRequest: 0 });
53
54 const mostRecentProject = useMemo(
55 () =>
56 bootstrapped
57 ? (sortScopedProjectsForSidebar(projects, threads, "updated_at")[0] ?? null)
58 : null,
59 [bootstrapped, projects, threads],
60 );
61
62 useEffect(() => {
63 // Starting a draft after the user has left would replace the page they picked.
64 if (mostRecentProject === null || startingRef.current || !isStillOnIndex()) {
65 return;
66 }
67 const projectRef = scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id);
68 // A project removed since this render is gone from the store; the next
69 // render picks another one.
70 if (readProject(projectRef) === null) {
71 return;
72 }
73 startingRef.current = true;
74 void handleNewThread(projectRef, { replace: true })
75 .then((opened) => {
76 // The project was removed while its draft started; land on the next one.
77 if (opened === null) {
78 startingRef.current = false;
79 setStartState((state) => ({ ...state, retryRequest: state.retryRequest + 1 }));
80 }
81 })
82 .catch(() => {
83 startingRef.current = false;
84 setStartState((state) => ({ ...state, failed: true }));
85 });
86 }, [handleNewThread, mostRecentProject, startState.retryRequest]);
87
88 if (!bootstrapped) {
89 return null;
90 }
91 if (mostRecentProject !== null) {
92 return startState.failed ? (
93 <DraftStartError
94 onRetry={() => {
95 setStartState((state) => ({
96 failed: false,
97 retryRequest: state.retryRequest + 1,
98 }));
99 }}
100 />
101 ) : null;
102 }
103 // First-run routing to the welcome wizard happens in FirstRunGate at the
104 // root, before this route ever renders.
105 return <NoProjectsHero />;
106}
107
108function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
109 return (
110 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
111 {isElectron ? <WorkspacePageHeader electron /> : null}
112 <Empty className="flex-1">
113 <EmptyHeader className="max-w-md">
114 <EmptyTitle>Couldn’t start a new thread</EmptyTitle>
115 <EmptyDescription>
116 The project is still available. Try opening the draft again.
117 </EmptyDescription>
118 <div className="mt-5 flex justify-center">
119 <Button size="sm" onClick={onRetry}>
120 <RefreshIcon size="md" />
121 Try again
122 </Button>
123 </div>
124 </EmptyHeader>
125 </Empty>
126 </SidebarInset>
127 );
128}
129
130export const Route = createFileRoute("/_chat/")({
131 component: ChatIndexRouteView,
132});
133
134function HostedStaticOnboardingState() {
135 const localEnvironmentOff = isLocalEnvironmentDisabled();
136 const description = localEnvironmentOff
137 ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections."
138 : "Open Connections to choose T3 Connect, your own server, or a pairing link from one computer.";
139
140 return (
141 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
142 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
143 <WorkspacePageHeader electron={isElectron} className="border-b border-border">
144 <div className="flex items-center gap-2">
145 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
146 {APP_DISPLAY_NAME}
147 </span>
148 </div>
149 </WorkspacePageHeader>
150
151 <Empty className="flex-1">
152 <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5">
153 <EmptyHeader className="max-w-none">
154 <div className="mx-auto mb-5 flex size-11 items-center justify-center rounded-xl border border-border/70 bg-background/70 text-muted-foreground">
155 <LinkIcon className="size-5" />
156 </div>
157 <EmptyTitle>Connect to a computer running The Knife</EmptyTitle>
158 <EmptyDescription>
159 This app connects to The Knife running on your computer or a server. Start The Knife
160 desktop app or command-line server on that machine and keep it running.
161 </EmptyDescription>
162 <EmptyDescription>{description}</EmptyDescription>
163 <div className="mt-6 flex justify-center">
164 <Button render={<Link to="/settings/connections" />} size="sm">
165 <PlusIcon className="size-4" />
166 Open Connections
167 </Button>
168 </div>
169 </EmptyHeader>
170 </div>
171 </Empty>
172 </div>
173 </SidebarInset>
174 );
175}