Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,4 @@ combined_training_gemini-2.0.jsonl
/.claude/settings.local.json
/.claude/scheduled_tasks.lock
/backend/.eval_history/
/.playwright-mcp
17 changes: 11 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Navbar from "./shared/components/Navbar/Navbar";
import RegionNotice from "./shared/components/RegionNotice";
import Chat from "./Chat";
import LoadingPage from "./pages/LoadingPage";
import PageLayout from "./layouts/PageLayout";
Expand All @@ -16,6 +17,7 @@ export default function App() {
return (
<Router>
<Navbar />
<RegionNotice />
<PageLayout>
<Routes>
<Route path="/" element={<HomePage />} />
Expand All @@ -24,9 +26,8 @@ export default function App() {
element={
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path="/chat" element={<Chat />} />
<Route path="/letter" element={<Letter />} />
<Route path="/letter/:org/:loc?" element={<Letter />} />
<Route path="/chat/:state?/:city?" element={<Chat />} />
<Route path="/letter/:state?/:city?" element={<Letter />} />
<Route path="/about" element={<About />} />
<Route path="/disclaimer" element={<Disclaimer />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
Expand All @@ -35,10 +36,14 @@ export default function App() {
}
/>
</Routes>
<footer className="hidden sm:block fixed bottom-4 right-4 text-xs">
UI Version {__APP_VERSION__}
</footer>
</PageLayout>
<footer className="fixed bottom-0 left-0 w-full h-(--footer-height) bg-paper-background border-t border-gray-light flex items-center justify-end px-4 text-xs text-gray-dark z-40">
<span>
&copy; {new Date().getFullYear()} Tenant First Aid
<span className="mx-2">|</span>
UI Version {__APP_VERSION__}
</span>
</footer>
</Router>
);
}
69 changes: 61 additions & 8 deletions frontend/src/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,60 @@
import MessageWindow from "./pages/Chat/components/MessageWindow";
import useMessages from "./hooks/useMessages";
import useSyncJurisdiction from "./hooks/useSyncJurisdiction";
import { useLetterContent } from "./hooks/useLetterContent";
import ChatDisclaimer from "./pages/Chat/components/ChatDisclaimer";
import FrequentInquiries from "./pages/Chat/components/FrequentInquiries";
import MessageContainer from "./shared/components/MessageContainer";
import FeatureSnippet from "./shared/components/FeatureSnippet";
import MobilePanel from "./shared/components/MobilePanel";
import { Navigate, useParams } from "react-router-dom";
import { classifyStateSegment } from "./shared/utils/jurisdiction";
import { DEFAULT_JURISDICTION } from "./shared/constants/jurisdictions";
import clsx from "clsx";

/**
* Routes /chat requests by classifying the :state segment: an out-of-state
* state is redirected to Oregon with a flag so the page can explain the
* switch, a non-state typo is quietly canonicalized to Oregon, and supported
* states render ChatView.
*/
export default function Chat() {
const { state: stateParam } = useParams();
const kind = classifyStateSegment(stateParam);

if (kind === "out-of-state") {
return (
<Navigate
to={`/chat${DEFAULT_JURISDICTION.pathSuffix}`}
replace
state={{ unsupportedRegion: true }}
/>
);
}

if (kind === "unknown") {
return <Navigate to={`/chat${DEFAULT_JURISDICTION.pathSuffix}`} replace />;
}

return <ChatView />;
}

function ChatView() {
useSyncJurisdiction();
const { addMessage, messages, setMessages } = useMessages();
const isOngoing = messages.length > 0;
const { letterContent } = useLetterContent(messages);

return (
<div className="h-full w-full flex flex-col lg:flex-row gap-4 transition-all duration-300 md:px-4 max-w-[1400px]">
<div className="my-auto w-full flex">
<div className="min-h-full lg:h-full w-full flex flex-col lg:flex-row transition-all duration-300">
<div className="flex-1 lg:flex-none lg:my-0 w-full lg:w-3/5 flex lg:order-2">
<MessageContainer isOngoing={isOngoing} letterContent={letterContent}>
<div
className={clsx(
"flex flex-col min-h-0",
letterContent === "" ? "flex-1" : "flex-1/3",
!isOngoing &&
"lg:justify-center [@media(max-height:800px)]:justify-start! [@media(max-height:800px)]:overflow-y-auto",
)}
>
<MessageWindow
Expand All @@ -32,16 +68,33 @@ export default function Chat() {
</div>
<div
className={clsx(
"flex flex-col m-auto w-full rounded-lg bg-paper-background",
"lg:self-start lg:max-w-[300px]",
"flex flex-col w-full bg-paper-background",
"border-b lg:border-b-0 border-gray-light",
"lg:order-1 lg:my-0 lg:w-1/5 lg:border-r",
"[@media(max-height:800px)]:my-0 [@media(max-height:800px)]:self-stretch [@media(max-height:800px)]:overflow-hidden",
)}
>
<MobilePanel title="Frequent Inquiries">
<div className="flex-1 min-h-0 lg:overflow-y-auto [@media(max-height:800px)]:overflow-y-auto">
<FrequentInquiries />
</div>
</MobilePanel>
</div>
<div
className={clsx(
"flex flex-col w-full bg-paper-background",
"border-b lg:border-b-0 border-gray-light",
"lg:order-3 lg:my-0 lg:w-1/5 lg:border-l",
"[@media(max-height:800px)]:my-0 [@media(max-height:800px)]:self-stretch [@media(max-height:800px)]:overflow-hidden",
)}
>
<div className="[@media(max-height:800px)]:overflow-y-auto">
<FeatureSnippet />
<div className="p-4">
<ChatDisclaimer isOngoing={isOngoing} />
<MobilePanel title="Features">
<div className="flex flex-col flex-1 min-h-0 lg:overflow-y-auto [@media(max-height:800px)]:overflow-y-auto">
<FeatureSnippet />
</div>
</MobilePanel>
<div className="p-4 [@media(min-width:1024px)_and_(min-height:801px)]:mt-auto">
<ChatDisclaimer isOngoing={isOngoing} />
</div>
</div>
</div>
Expand Down
117 changes: 102 additions & 15 deletions frontend/src/Letter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,115 @@ import type { UiMessage } from "./shared/types/messages";
import MessageWindow from "./pages/Chat/components/MessageWindow";
import useMessages from "./hooks/useMessages";
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { Navigate, useParams, useSearchParams } from "react-router-dom";
import { useLetterContent } from "./hooks/useLetterContent";
import { streamText } from "./pages/Chat/utils/streamHelper";
import LetterGenerationDialog from "./pages/Letter/components/LetterGenerationDialog";
import { buildLetterUserMessage } from "./pages/Letter/utils/letterHelper";
import {
classifyStateSegment,
jurisdictionByKey,
resolveJurisdiction,
toLocation,
} from "./shared/utils/jurisdiction";
import {
DEFAULT_JURISDICTION,
type JurisdictionOption,
} from "./shared/constants/jurisdictions";
import LetterDisclaimer from "./pages/Letter/components/LetterDisclaimer";
import MessageContainer from "./shared/components/MessageContainer";
import useHousingContext from "./hooks/useHousingContext";
import { buildChatUserMessage } from "./pages/Chat/utils/formHelper";
import type { Location } from "./types/models";
import FeatureSnippet from "./shared/components/FeatureSnippet";
import FrequentInquiries from "./pages/Chat/components/FrequentInquiries";
import MobilePanel from "./shared/components/MobilePanel";
import clsx from "clsx";

/**
* Routes /letter requests by classifying the leading segment: an out-of-state
* state is redirected to Oregon with a flag so the page can explain the switch,
* a legacy /letter/:org/:loc partner link is redirected to the canonical
* /letter/:state/:city?org= form, and supported states render LetterView.
*/
export default function Letter() {
const { state: seg1, city: seg2 } = useParams();
const [searchParams] = useSearchParams();
const kind = classifyStateSegment(seg1);

if (kind === "out-of-state") {
return (
<Navigate
to={`/letter${DEFAULT_JURISDICTION.pathSuffix}`}
replace
state={{ unsupportedRegion: true }}
/>
);
}

// A non-state leading segment is a legacy /letter/:org/:loc partner link;
// the loc shares the JurisdictionKey naming, so look it up directly.
if (kind === "unknown") {
const { pathSuffix } = jurisdictionByKey(seg2);
const search = seg1 ? `?org=${encodeURIComponent(seg1)}` : "";
return <Navigate to={`/letter${pathSuffix}${search}`} replace />;
}

const jurisdiction = resolveJurisdiction(seg1, seg2);
const org = searchParams.get("org") ?? undefined;
// Remount on jurisdiction/org change so the letter regenerates from scratch
// (the init guard and message state are per-mount).
return (
<LetterView
key={`${jurisdiction.key}|${org ?? ""}`}
jurisdiction={jurisdiction}
org={org}
/>
);
}

interface LetterViewProps {
jurisdiction: JurisdictionOption;
org: string | undefined;
}

function LetterView({ jurisdiction, org }: LetterViewProps) {
const { addMessage, messages, setMessages } = useMessages();
const isOngoing = messages.length > 0;
const { letterContent } = useLetterContent(messages);
const { org, loc } = useParams();
const [startStreaming, setStartStreaming] = useState(false);
const streamLocationRef = useRef<Location | null>(null);
const [isGenerating, setIsGenerating] = useState(true);
const dialogRef = useRef<HTMLDialogElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasInitialized = useRef(false);
const LOADING_DISPLAY_DELAY_MS = 1000;
const { housingLocation, housingType, tenantTopic, issueDescription } =
useHousingContext();
const {
housingLocation,
housingType,
tenantTopic,
issueDescription,
handleHousingLocation,
handleCityChange,
} = useHousingContext();
const { userMessage: initialUserMessage } = buildChatUserMessage(
housingLocation,
housingType,
tenantTopic,
issueDescription,
);

// Keep the URL the source of truth for follow-up messages and the navbar
// location picker on this page.
useEffect(() => {
handleHousingLocation(toLocation(jurisdiction));
handleCityChange(jurisdiction.key);
}, [jurisdiction, handleHousingLocation, handleCityChange]);

// Adds the initial user message once and triggers streaming.
useEffect(() => {
if (hasInitialized.current) return;
const output = buildLetterUserMessage(org, loc);
if (output === null) return;
const output = buildLetterUserMessage(org, toLocation(jurisdiction));
hasInitialized.current = true;
const hasIssueContext = issueDescription !== "";

Expand All @@ -58,7 +128,7 @@ export default function Letter() {
]);
streamLocationRef.current = output.selectedLocation;
setStartStreaming(true);
}, [loc, org, setMessages, issueDescription, initialUserMessage]);
}, [jurisdiction, org, setMessages, issueDescription, initialUserMessage]);

useEffect(() => {
if (startStreaming === false || streamLocationRef.current === null) return;
Expand Down Expand Up @@ -118,8 +188,8 @@ export default function Letter() {
return (
<>
<LetterGenerationDialog ref={dialogRef} />
<div className="h-full w-full flex flex-col lg:flex-row gap-4 transition-all duration-300 sm:px-4 max-w-[1400px]">
<div className="my-auto w-full flex">
<div className="min-h-full lg:h-full w-full flex flex-col lg:flex-row transition-all duration-300">
<div className="flex-1 lg:flex-none lg:my-0 w-full lg:w-3/5 flex lg:order-2">
<MessageContainer isOngoing={isOngoing} letterContent={letterContent}>
<div
className={clsx(
Expand All @@ -146,16 +216,33 @@ export default function Letter() {
</div>
<div
className={clsx(
"flex flex-col m-auto w-full rounded-lg bg-paper-background",
"lg:self-start lg:max-w-[300px]",
"flex flex-col w-full bg-paper-background",
"border-b lg:border-b-0 border-gray-light",
"lg:order-1 lg:my-0 lg:w-1/5 lg:border-r",
"[@media(max-height:800px)]:my-0 [@media(max-height:800px)]:self-stretch [@media(max-height:800px)]:overflow-hidden",
)}
>
<MobilePanel title="Frequent Inquiries">
<div className="flex-1 min-h-0 lg:overflow-y-auto [@media(max-height:800px)]:overflow-y-auto">
<FrequentInquiries />
</div>
</MobilePanel>
</div>
<div
className={clsx(
"flex flex-col w-full bg-paper-background",
"border-b lg:border-b-0 border-gray-light",
"lg:order-3 lg:my-0 lg:w-1/5 lg:border-l",
"[@media(max-height:800px)]:my-0 [@media(max-height:800px)]:self-stretch [@media(max-height:800px)]:overflow-hidden",
)}
>
<div className="[@media(max-height:800px)]:overflow-y-auto">
<FeatureSnippet />
<div className="p-4">
<LetterDisclaimer isOngoing={isOngoing} />
<MobilePanel title="Features">
<div className="flex flex-col flex-1 min-h-0 lg:overflow-y-auto [@media(max-height:800px)]:overflow-y-auto">
<FeatureSnippet />
</div>
</MobilePanel>
<div className="p-4 [@media(min-width:1024px)_and_(min-height:801px)]:mt-auto">
<LetterDisclaimer isOngoing={isOngoing} />
</div>
</div>
</div>
Expand Down
18 changes: 7 additions & 11 deletions frontend/src/contexts/HousingContext.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { createContext, useCallback, useMemo, useState } from "react";
import DOMPurify, { SANITIZE_USER_SETTINGS } from "../shared/utils/dompurify";
import type { Location } from "../types/models";
import type {
CitySelectKey,
HousingType,
TenantTopic,
} from "../shared/constants/constants";
import type { HousingType, TenantTopic } from "../shared/constants/constants";
import type { JurisdictionKey } from "../shared/constants/jurisdictions";

export interface HousingContextType {
housingLocation: Location;
city: CitySelectKey | null;
city: JurisdictionKey | null;
housingType: HousingType | null;
tenantTopic: TenantTopic | null;
issueDescription: string;
handleHousingLocation: ({ city, state }: Location) => void;
handleCityChange: (option: CitySelectKey | null) => void;
handleCityChange: (option: JurisdictionKey | null) => void;
handleHousingChange: (option: HousingType | null) => void;
handleTenantTopic: (option: TenantTopic | null) => void;
handleIssueDescription: (
Expand All @@ -30,7 +27,7 @@ interface Props {
}

export default function HousingContextProvider({ children }: Props) {
const [city, setCity] = useState<CitySelectKey | null>(null);
const [city, setCity] = useState<JurisdictionKey | null>(null);
const [housingLocation, setHousingLocation] = useState<Location>({
city: null,
state: null,
Expand All @@ -43,7 +40,7 @@ export default function HousingContextProvider({ children }: Props) {
setHousingLocation({ city, state });
}, []);

const handleCityChange = useCallback((option: CitySelectKey | null) => {
const handleCityChange = useCallback((option: JurisdictionKey | null) => {
setCity(option);
}, []);

Expand All @@ -64,9 +61,8 @@ export default function HousingContextProvider({ children }: Props) {
[],
);

// Location is owned by the navbar picker / URL, so a form reset leaves it.
const handleFormReset = useCallback(() => {
setCity(null);
setHousingLocation({ city: null, state: null });
setHousingType(null);
setTenantTopic(null);
setIssueDescription("");
Expand Down
Loading