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
58 changes: 42 additions & 16 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1525,7 +1525,9 @@
const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false);
const [query, setQuery] = useState(initialQuery);
const [searchMode, setSearchMode] = useState<AppModeId>(initialSearchMode);
const [modeSearchSubmitted, setModeSearchSubmitted] = useState(false);
const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() =>
Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"),
);
const [answer, setAnswer] = useState<RagAnswer | null>(null);
const [sources, setSources] = useState<SearchResult[]>([]);
// Answer-mode conversation thread. `priorAnswerTurns` holds completed
Expand All @@ -1549,6 +1551,14 @@
const activeModeSearch = appModeSearchConfig(searchMode);
const activeModeResultKind = appModeResultKind(searchMode);
const requestQueryMode = appModeQueryMode(searchMode, queryMode);
const submittedUrlMode = searchParams.get("mode");
const submittedUrlModeMatchesActive =
!submittedUrlMode ||
(isAppModeId(submittedUrlMode) && isAppModeVisible(submittedUrlMode) && submittedUrlMode === searchMode);
const submittedUrlQuery =
autoRunSearch && searchParams.get("run") === "1" && submittedUrlModeMatchesActive
? (searchParams.get("q") ?? searchParams.get("query") ?? "").trim()
: "";

// Record matches come from the owner-scoped registry API (mock fixtures in
// demo mode); ranking stays client-side so live-typing behaviour is
Expand Down Expand Up @@ -2443,7 +2453,7 @@
urlDocumentSearchBootstrappedRef.current = true;
void executeSearch(searchText, mode, scopeFilters);
// URL search intentionally runs once when the selected mode can execute.
}, [canRunSearch, answerThreadBootstrapped]);

Check warning on line 2456 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions / verify

React Hook useEffect has missing dependencies: 'executeSearch' and 'scopeFilters'. Either include them or remove the dependency array

useEffect(() => {
const updateHash = () => {
Expand Down Expand Up @@ -2846,42 +2856,53 @@
if (updateUrl) router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText }));
}

async function ask() {
const trimmedQuery = query.trim();
async function ask(searchText = query) {
const trimmedQuery = searchText.trim();
if (searchMode === "documents" && trimmedQuery) {
rememberRecentQuery(trimmedQuery);
router.push(documentsSearchHref({ query: trimmedQuery, focus: true, run: true }));
return;
}
if (searchMode === "prescribing") {
setMedicationSearchQuery(query);
setMedicationSearchQuery(searchText);
return;
}
await executeSearch(query, searchMode, scopeFilters);
await executeSearch(searchText, searchMode, scopeFilters);
}
const askRef = useRef(ask);
askRef.current = ask;

useEffect(() => {
const trimmedQuery = query.trim();
const submittedSearchText = searchMode === "answer" && submittedUrlQuery ? submittedUrlQuery : trimmedQuery;
const canAutoRunMode = searchMode === "documents" || searchMode === "prescribing" || canRunSearch;
if (!autoRunSearch || !trimmedQuery || !canAutoRunMode || loading) return;
if (!autoRunSearch || !submittedSearchText || !canAutoRunMode || loading) return;
if (searchMode === "answer" && !answerThreadBootstrapped) return;
// Once an answer is on screen, composer edits are follow-up drafts and must
// only run on explicit submit — not on every query keystroke while run=1
// keeps autoRunSearch enabled from the URL.
if (searchMode === "answer" && answer) return;
// After reload, the URL query matches the restored latest turn — do not
// archive it again into a duplicate prior turn.
if (searchMode === "answer" && latestAnswerQuery?.trim() === trimmedQuery) {
autoRunSearchSignatureRef.current = `${searchMode}:${trimmedQuery}`;
if (searchMode === "answer" && latestAnswerQuery?.trim() === submittedSearchText) {
autoRunSearchSignatureRef.current = `${searchMode}:${submittedSearchText}`;
return;
}
const signature = `${searchMode}:${trimmedQuery}`;
const signature = `${searchMode}:${submittedSearchText}`;
if (autoRunSearchSignatureRef.current === signature) return;
autoRunSearchSignatureRef.current = signature;
void askRef.current();
}, [autoRunSearch, canRunSearch, loading, query, searchMode, answer, answerThreadBootstrapped, latestAnswerQuery]);
void askRef.current(submittedSearchText);
}, [
autoRunSearch,
canRunSearch,
loading,
query,
submittedUrlQuery,
searchMode,
answer,
answerThreadBootstrapped,
latestAnswerQuery,
]);

function pickRecentQuery(recentQuery: string) {
if (searchMode === "prescribing") {
Expand Down Expand Up @@ -3556,12 +3577,17 @@
const showAuthPanel = false;
const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch);
const hasMobileBottomSearch = searchMode !== "answer";
const submittedAnswerSearchActive =
activeModeResultKind === "answer" && !answer && canRunSearch && (modeSearchSubmitted || Boolean(submittedUrlQuery));
const showAnswerHome = activeModeResultKind === "answer" && !answer && !loading && !submittedAnswerSearchActive;
const showAnswerPending =
activeModeResultKind === "answer" && !answer && (loading || (submittedAnswerSearchActive && !error));
const showDesktopHomeComposer =
!error &&
(activeModeResultKind === "tools" ||
activeModeResultKind === "favourites" ||
(!loading &&
((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
(showAnswerHome ||
(searchMode === "documents" &&
activeModeResultKind === "documents" &&
documentMatches.length === 0 &&
Expand Down Expand Up @@ -3865,7 +3891,7 @@
<section
className={cn(
"min-h-[calc(100dvh-12.5rem)] sm:min-h-[calc(100dvh-11rem)]",
centeredModeHome || (activeModeResultKind === "answer" && !answer && !loading)
centeredModeHome || showAnswerHome
? // On tall phones the centred home leans slightly toward the
// bottom composer (matches the committed vertical-weighting
// guard); short phones skip the bias so content still fits.
Expand Down Expand Up @@ -3992,7 +4018,7 @@
/>
</>
)
) : loading && !answer ? (
) : showAnswerPending ? (
<AnswerSkeleton />
) : answer && answerRenderModel ? (
stagedDashboardExtraction.answerSurface ? (
Expand Down Expand Up @@ -4047,14 +4073,14 @@
/>
</>
) : null
) : (
) : showAnswerHome ? (
<AnswerEmptyState
onPickSample={setQuery}
onSearchDocuments={() => setSearchMode("documents")}
onUploadDocument={openUploadDrawer}
desktopComposerSlotId={desktopHomeComposerSlotId}
/>
)}
) : null}
</section>

{showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null}
Expand Down
43 changes: 40 additions & 3 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,13 @@ async function fulfillAnswerResponse(route: Route, payload: unknown) {
}

type DemoAnswerOverride = (query: string, documentId?: string, documentIds?: string[]) => ReturnType<typeof demoAnswer>;
type MockDemoApiOptions = {
answerOverride?: DemoAnswerOverride;
answerDelayMs?: number;
onAnswerRequest?: (query: string) => void;
};

async function mockDemoApi(page: Page, options: { answerOverride?: DemoAnswerOverride } = {}) {
async function mockDemoApi(page: Page, options: MockDemoApiOptions = {}) {
await mockLocalProjectIdentity(page);
await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
Expand Down Expand Up @@ -273,9 +278,14 @@ async function mockDemoApi(page: Page, options: { answerOverride?: DemoAnswerOve
documentId?: string;
documentIds?: string[];
};
const query = body.query ?? "What monitoring is required?";
options.onAnswerRequest?.(query);
if (options.answerDelayMs) {
await new Promise((resolve) => setTimeout(resolve, options.answerDelayMs));
}
const answer =
options.answerOverride?.(body.query ?? "What monitoring is required?", body.documentId, body.documentIds) ??
demoAnswer(body.query ?? "What monitoring is required?", body.documentId, body.documentIds);
options.answerOverride?.(query, body.documentId, body.documentIds) ??
demoAnswer(query, body.documentId, body.documentIds);
await fulfillAnswerResponse(route, {
...answer,
demoMode: true,
Expand Down Expand Up @@ -1317,6 +1327,33 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectNoPageHorizontalOverflow(page);
});

test("answer search URL opens chat without the answer home copy", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
const answerRequests: string[] = [];
const question = "What clozapine monitoring items are shown in the table image?";
await mockDemoApi(page, {
answerDelayMs: 1500,
onAnswerRequest: (query) => answerRequests.push(query),
});

await page.goto(`/?mode=answer&q=${encodeURIComponent(question)}&focus=1&run=1`, { waitUntil: "domcontentloaded" });

await expect(page.getByTestId("answer-empty-state")).toHaveCount(0);
await expect(page.getByText("How can I help?", { exact: true })).toHaveCount(0);
await expect(page.getByLabel("Loading answer")).toBeVisible();
await expect.poll(() => answerRequests[0]).toBe(question);

const questionBubble = page.getByTestId("user-question-bubble");
await expect(questionBubble).toBeVisible({ timeout: uiAssertionTimeoutMs });
await expect(questionBubble).toContainText(question);
await expect(page.getByTestId("plain-answer-response")).toContainText("synthetic clozapine table image highlights");
await expect(visibleQuestionInput(page)).toHaveValue("");
await expect(page.getByTestId("answer-empty-state")).toHaveCount(0);
await expect(page.getByText("How can I help?", { exact: true })).toHaveCount(0);
expect(answerRequests).toEqual([question]);
await expectNoPageHorizontalOverflow(page);
});

test("answer mode keeps prior turns visible for follow-up questions", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoApi(page);
Expand Down
Loading