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
858 changes: 654 additions & 204 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
Expand Down
102 changes: 65 additions & 37 deletions frontend/src/Letter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useLetterContent } from "./hooks/useLetterContent";
import { streamText } from "./pages/Chat/utils/streamHelper";
import { buildLetterUserMessage } from "./pages/Chat/utils/letterHelper";
import LetterGenerationDialog from "./pages/Letter/components/LetterGenerationDialog";
import { buildLetterUserMessage } from "./pages/Letter/utils/letterHelper";
import LetterDisclaimer from "./pages/Letter/components/LetterDisclaimer";

export default function Letter() {
const { addMessage, messages, setMessages } = useMessages();
Expand All @@ -15,6 +17,9 @@ export default function Letter() {
const { org, loc } = useParams();
const [startStreaming, setStartStreaming] = useState(false);
const streamLocationRef = useRef<ILocation | null>(null);
const [isLoading, setIsLoading] = useState(true);
const dialogRef = useRef<HTMLDialogElement>(null);
const LOADING_DISPLAY_DELAY_MS = 1000;

useEffect(() => {
if (org === undefined) return;
Expand Down Expand Up @@ -49,55 +54,78 @@ export default function Letter() {
runGenerateLetter();
}, [messages, startStreaming, addMessage, setMessages]);

useEffect(() => {
// Wait for the second message (index 1) which contains the initial AI response
if (messages.length > 1 && messages[1]?.content !== "") {
// Include 1s delay for smoother transition
const timeoutId = setTimeout(
() => setIsLoading(false),
LOADING_DISPLAY_DELAY_MS,
);
return () => clearTimeout(timeoutId);
}
}, [messages]);

useEffect(() => {
dialogRef.current?.showModal();
}, []);

return (
<div className="h-dvh pt-16 flex items-center">
<div className="flex w-full items-center ">
<div className="flex-1 transition-all duration-300">
<div
className={`container relative flex flex-col sm:flex-row gap-4 mx-auto p-6 bg-[#F4F4F2] rounded-lg shadow-[0_4px_6px_rgba(0,0,0,0.1)]
<>
<div className="h-dvh pt-16 flex items-center">
<LetterGenerationDialog ref={dialogRef} />
<div className="flex w-full items-center">
<div className="flex-1 transition-all duration-300 relative">
<div
className={`container relative flex flex-col sm:flex-row gap-4 mx-auto p-6 bg-[#F4F4F2] rounded-lg shadow-[0_4px_6px_rgba(0,0,0,0.1)]
${
isOngoing
? "justify-between h-[calc(100dvh-4rem-64px)] max-h-[calc(100dvh-4rem-64px)] sm:h-[calc(100dvh-10rem-64px)]"
: "justify-center max-w-[600px]"
}`}
>
{letterContent !== "" ? (
<div className="flex flex-col gap-4 items-center flex-2/3 h-[40%] sm:h-full">
<div className="overflow-y-scroll pr-4 w-full">
<span
className="whitespace-pre-wrap generated-letter"
dangerouslySetInnerHTML={{
__html: letterContent,
}}
/>
>
{letterContent !== "" ? (
<div className="flex flex-col gap-4 items-center flex-2/3 h-[40%] sm:h-full">
<div className="overflow-y-scroll pr-4 w-full">
<span
className="whitespace-pre-wrap generated-letter"
dangerouslySetInnerHTML={{
__html: letterContent,
}}
/>
</div>
</div>
) : null}
<div
className={`flex flex-col ${letterContent === "" ? "flex-1" : "flex-1/3"} h-[60%] sm:h-full`}
>
{isLoading ? (
<div className="flex flex-1 items-center justify-center animate-pulse text-lg">
Generating letter...
</div>
) : (
<MessageWindow
messages={messages}
addMessage={addMessage}
location={location}
setLocation={setLocation}
setMessages={setMessages}
isOngoing={isOngoing}
/>
)}
</div>
) : null}
</div>
<div
className={`flex flex-col ${letterContent === "" ? "flex-1" : "flex-1/3"} h-[60%] sm:h-full`}
className={`container mx-auto text-xs px-4 text-center ${isOngoing ? "max-w-auto my-2" : "max-w-[600px] my-4"}`}
>
<MessageWindow
messages={messages}
addMessage={addMessage}
location={location}
setLocation={setLocation}
setMessages={setMessages}
isOngoing={isOngoing}
/>
<p className={`${isOngoing ? "mb-0" : "mb-2"}`}>
<LetterDisclaimer isOngoing={isOngoing} />
</p>
<p>For questions, contact michael@qiu-qiulaw.com</p>
</div>
</div>
<div
className={`container mx-auto text-xs px-4 text-center ${isOngoing ? "max-w-auto my-2" : "max-w-[600px] my-4"}`}
>
<p className={`${isOngoing ? "mb-0" : "mb-2"}`}>
{isOngoing
? "This chatbot offers general housing law info and is not legal advice. For help with your situation, contact a lawyer."
: "The information provided by this chatbot is general information only and does not constitute legal advice. While Tenant First Aid strives to keep the content accurate and up to date, completeness and accuracy is not guaranteed. If you have a specific legal issue or question, consider contacting a qualified attorney or a local legal aid clinic for personalized assistance."}
</p>
<p>For questions, contact michael@qiu-qiulaw.com</p>
</div>
</div>
</div>
</div>
</>
);
}
40 changes: 25 additions & 15 deletions frontend/src/pages/Chat/components/MessageWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import CitySelectField from "./CitySelectField";
import SuggestedPrompts from "./SuggestedPrompts";
import { ILocation } from "../../../hooks/useLocation";
import FeedbackModal from "./FeedbackModal";
import { useLocation } from "react-router-dom";

interface Props {
messages: IMessage[];
Expand All @@ -33,6 +34,13 @@ export default function MessageWindow({
const [openFeedback, setOpenFeedback] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const messagesRef = useRef<HTMLDivElement | null>(null);
const loc = useLocation();

// To hide initial prompt and response for letter generation
const LETTER_PAGE_HIDDEN_MESSAGES = 2;
const displayedMessages = loc.pathname.startsWith("/letter")
? messages.slice(LETTER_PAGE_HIDDEN_MESSAGES)
: messages;

const handleClearSession = () => {
window.location.reload();
Expand Down Expand Up @@ -69,25 +77,27 @@ export default function MessageWindow({
>
<div className="max-h-[calc(100dvh-240px)] sm:max-h-[calc(100dvh-20rem)] mx-auto max-w-[700px]">
{isOngoing ? (
<div className="flex flex-col gap-4">
{messages.map((message) => (
<div
className={`flex w-full ${
message.role === "model" ? "justify-start" : "justify-end"
}`}
key={message.messageId}
>
<div className="flex flex-col gap-4 relative">
{displayedMessages.map((message) => {
return (
<div
className={`message-bubble p-3 rounded-2xl max-w-[95%] ${
message.role === "model"
? "bg-slate-200 rounded-tl-sm"
: "bg-[#1F584F] text-white rounded-tr-sm"
className={`flex w-full ${
message.role === "model" ? "justify-start" : "justify-end"
}`}
key={message.messageId}
>
<MessageContent message={message} />
<div
className={`message-bubble p-3 rounded-2xl max-w-[95%] ${
message.role === "model"
? "bg-slate-200 rounded-tl-sm"
: "bg-[#1F584F] text-white rounded-tr-sm"
}`}
>
<MessageContent message={message} />
</div>
</div>
</div>
))}
);
})}
</div>
) : null}
</div>
Expand Down
28 changes: 0 additions & 28 deletions frontend/src/pages/Chat/utils/letterHelper.ts

This file was deleted.

31 changes: 31 additions & 0 deletions frontend/src/pages/Letter/components/LetterDisclaimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Link } from "react-router-dom";

interface Props {
isOngoing: boolean;
}

export default function LetterDisclaimer({ isOngoing }: Props) {
return isOngoing ? (
<span>
<strong>Disclaimer</strong>: This tool provides general information and
drafts letters based solely on what you enter. It is not legal advice and
does not create an attorney–client relationship. As explained further in
the{" "}
<Link to="/privacy-policy" target="_blank" className="underline">
Privacy Policy
</Link>
, we do not save any data from these conversations, but you can enter your
personal information into the chatbox and it will appear in the
corresponding brackets of the letter.
</span>
) : (
<span>
The information provided by this chatbot is general information only and
does not constitute legal advice. While Tenant First Aid strives to keep
the content accurate and up to date, completeness and accuracy is not
guaranteed. If you have a specific legal issue or question, consider
contacting a qualified attorney or a local legal aid clinic for
personalized assistance.
</span>
);
}
34 changes: 34 additions & 0 deletions frontend/src/pages/Letter/components/LetterGenerationDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
interface Props {
ref: React.RefObject<HTMLDialogElement | null>;
}

export default function LetterGenerationDialog({ ref }: Props) {
return (
<dialog
ref={ref}
aria-label="letter-dialog-modal"
aria-labelledby="letter-dialog-title"
aria-describedby="letter-dialog-description"
className="rounded-lg p-6 min-w-[300px] max-w-[600px] fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
>
<div className="flex flex-col items-end">
<h2 id="letter-dialog-title" className="sr-only">
Letter Generation Notice
</h2>
<p id="letter-dialog-description">
You've been redirected here so we can help you create a letter to your
landlord. It'll take a few seconds to complete your initial letter.
You could instruct the tool to update the letter to your liking after
it's generated. Once your letter is complete, you should go back to
your previous page and follow the remaining steps.
Comment on lines +18 to +23
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we store this in a const?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When we start making more modals, think it'll be a good time to move this to a const

It should be fine for a one off

</p>
<button
onClick={() => ref.current?.close()}
className="cursor-pointer underline text-blue-600 hover:text-blue-500 text-sm"
>
close
</button>
</div>
</dialog>
);
}
44 changes: 44 additions & 0 deletions frontend/src/pages/Letter/utils/letterHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
CitySelectOptions,
type CitySelectOptionType,
} from "../../Chat/components/CitySelectField";

interface IBuildLetterReturnType {
userMessage: string;
selectedLocation: CitySelectOptionType;
}

function buildLetterUserMessage(
org: string,
loc: string | undefined,
): IBuildLetterReturnType | null {
const selectedLocation = CitySelectOptions[loc || "oregon"];
if (selectedLocation === undefined) return null;
const locationString =
selectedLocation.city && selectedLocation.state
? `${selectedLocation.city}, ${selectedLocation.state}`
: selectedLocation.city || selectedLocation.state?.toUpperCase() || "";
const sanitizedOrg = org
.replace(/[<>'"{}[\]]/g, "")
.trim()
.slice(0, 100);
const promptParts = [
`Hello, I've been redirected from ${sanitizedOrg}.`,
`Draft a letter related to housing issues for my area${locationString ? ` (${locationString})` : ""} to my landlord.`,
`Use the information in this prompt to generate a letter to my landlord.`,
`The issue could be maintenance issues, unsafe conditions, or anything else affecting my home, use a broken faucet as an example.`,
`Update the letter as we discuss.`,
`Update all placeholders for city and state in the letter with${locationString ? ` (${locationString})` : ""}`,
`When all but the signature placeholder have been replaced, please confirm that I have proof-read the letter for accuracy in content and tone,`,
`provide instructions for how to copy and paste(formatted) the letter from the browser into a application of my choice,`,
`the necessary and optional notification / deliveries to the recipient(s), and retention / receipt best practices.`,
`Have the user follow the steps mention from ${sanitizedOrg} first after letter completion, if there were any.`,
];

return {
userMessage: promptParts.join(" "),
selectedLocation,
};
}

export { buildLetterUserMessage };
Loading
Loading