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
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# CI: build and test.
#
# Adapted from the amal66/mike fork's .github/workflows/ci.yml (monorepo
# layout) to this repository's backend/ + frontend/ layout. Test steps use
# `npm test --if-present`, and the eval job checks for evals/run.mjs, so this
# workflow is safe to merge before or after the test-harness and evals PRs:
# on a tree without those pieces the test steps no-op and the build still
# gates the merge. Beyond the fork version this also builds the frontend
# (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and
# runs eslint as a blocking gate (the error backlog is at zero).
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
backend:
name: Backend build and tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: backend/package-lock.json

- run: npm ci

# No-ops on a tree without a "test" script (e.g. before the vitest
# harness PR merges); runs the suite once it exists.
- run: npm test --if-present

- run: npm run build

frontend:
name: Frontend build and tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json

- run: npm ci

# No-ops on a tree without a "test" script (e.g. before the vitest
# harness PR merges); runs the suite once it exists.
- run: npm test --if-present

# Blocking gate: the eslint error backlog was burned down in this PR
# (0 errors; warnings do not fail the step), so any new error fails CI.
- run: npm run lint

# Production build. NEXT_PUBLIC_* values are inlined at build time and
# only need to be well-formed here — nothing is contacted during build.
# This catches type errors (next build runs tsc) and broken routes/imports.
- run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sb_publishable_placeholder
NEXT_PUBLIC_API_BASE_URL: http://localhost:3001

# -----------------------------------------------------------------------
# Offline eval harness: deterministic scorecard for citation accuracy,
# prompt-injection resistance, and privilege/PII leakage. Runs against
# committed fixtures (no network, no LLM calls, no secrets), so it is cheap
# enough to gate every PR. --threshold 1.0 = every case must pass.
# Skips gracefully until the evals PR merges.
# -----------------------------------------------------------------------
evals:
name: Eval harness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Run eval harness (skips if evals/ not present)
run: |
if [ -f evals/run.mjs ]; then
node evals/run.mjs --threshold 1.0
else
echo "evals/run.mjs not present on this tree; skipping"
fi
1 change: 1 addition & 0 deletions frontend/src/app/components/assistant/AskInputPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export function AskInputPopup({
};

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- auto-submit when every question is answered; submit() sets state as part of the side effect
if (canSubmit) submit();
});

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/components/assistant/CaseLawPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export function CaseLawPanel({

useEffect(() => {
if (tab.opinions?.length) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync path of an async fetch effect: serve prop/cache data without a loading flash
setOpinions(tab.opinions);
setLoading(false);
setError(null);
Expand Down Expand Up @@ -269,10 +270,12 @@ export function CaseLawPanel({
orderOpinions(opinions).find(
({ opinion }) => typeof opinion.opinionId === "number",
)?.opinion.opinionId ?? null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset active opinion after opinions load
setActiveOpinionId(firstOpinionId);
}, [opinions]);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync quote list when the tab prop changes
setRelevantQuotes(tab.quotes ?? []);
}, [tab.quotes]);

Expand Down Expand Up @@ -321,6 +324,7 @@ export function CaseLawPanel({
);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset quote selection when the quote set changes
setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 });
const firstQuote = relevantQuotes[0];
setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null);
Expand Down
15 changes: 9 additions & 6 deletions frontend/src/app/components/assistant/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function ChatView({
const panelCloseTimerRef = useRef<number | null>(null);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset per-chat UI state when switching chats
setHiddenAskInputKeys(new Set());
}, [chatId]);

Expand Down Expand Up @@ -519,6 +520,7 @@ export function ChatView({
const c = messagesContainerRef.current;
if (!c) return;
c.addEventListener("scroll", updateScrollButton);
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial scroll-button state must be measured from the live DOM
updateScrollButton();
return () => c.removeEventListener("scroll", updateScrollButton);
}, [messages, updateScrollButton]);
Expand Down Expand Up @@ -553,6 +555,7 @@ export function ChatView({
useEffect(() => {
if (messages.length === 0) {
hasScrolledRef.current = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- hide messages until scroll position is restored to avoid a visible jump
setMessagesVisible(false);
} else if (!hasScrolledRef.current) {
const userMsgCount = messages.filter(
Expand Down Expand Up @@ -682,8 +685,8 @@ export function ChatView({
{msg.role === "user" ? (
<UserMessage
content={msg.content ?? ""}
files={(msg as any).files}
workflow={(msg as any).workflow}
files={msg.files}
workflow={msg.workflow}
/>
) : (
<AssistantMessage
Expand All @@ -692,11 +695,11 @@ export function ChatView({
i === messages.length - 1 &&
isResponseLoading
}
isError={!!(msg as any).error}
isError={!!msg.error}
errorMessage={
typeof (msg as any)
.error === "string"
? (msg as any).error
typeof msg.error ===
"string"
? msg.error
: undefined
}
citations={msg.citations}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function CitationQuotesHeader({

useEffect(() => {
if (!hasMultipleQuotes && viewMode === "list") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- collapse list view when quotes drop to a single item
setViewMode("single");
}
}, [hasMultipleQuotes, viewMode]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function PreResponseWrapper({

useEffect(() => {
if (forceOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- streaming open/minimize latch (see comment above)
setIsOpen(true);
return;
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/components/modals/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function Modal({
// Portals can't render during SSR, so a keep-mounted modal only renders
// (hidden) after the first client mount.
const [hasMounted, setHasMounted] = useState(false);
// eslint-disable-next-line react-hooks/set-state-in-effect -- SSR portal gate: must flip after first client mount
useEffect(() => setHasMounted(true), []);
const hasHeader = breadcrumbs?.length;
const hasFooter =
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/components/shared/MfaLoginGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {

useEffect(() => {
if (!user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync fast paths of the async MFA check effect
setGateState("idle");
return;
}
Expand Down Expand Up @@ -64,6 +65,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {

if (gateState === "required" && !isVerifyPage) {
if (hasRecentMfaVerification()) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear gate when a recent MFA verification exists instead of redirecting
setGateState("verified");
return;
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/components/tabular/TRChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ function TRResponseStatus({ isActive }: { isActive: boolean }) {

useEffect(() => {
if (wasActiveRef.current && !isActive) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- timed 'Done' flash on the active->idle transition
setShowDone(true);
setDoneVisible(true);
const t = setTimeout(() => setDoneVisible(false), 1500);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/contexts/ChatHistoryContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {

useEffect(() => {
if (!user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear chat state on logout inside the effect that loads chats
setChats([]);
setChatLimit(INITIAL_CHAT_LIMIT);
setHasMoreChats(false);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/hooks/useFetchDocxBytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function useFetchDocxBytes(

useEffect(() => {
if (!documentId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale bytes when documentId is removed, within the fetch effect
setBytes(null);
setDownloadUrl(null);
return;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/hooks/useSelectedModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function useSelectedModel(): [string, (id: string) => void] {
const [model, setModelState] = useState<string>(DEFAULT_MODEL_ID);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydration-safe localStorage read; SSR must render the default model
setModelState(readStored());
}, []);

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/support/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export default function SupportPage() {
{/* Email Display (if logged in) */}
{user?.email && (
<div className="text-sm text-gray-500">
We'll respond to:{" "}
We&apos;ll respond to:{" "}
<span className="font-medium">
{user.email}
</span>
Expand Down