Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions data/repo-awareness-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -4946,6 +4946,11 @@
"section": "decisions",
"catalogued": true
},
{
"path": "docs/decisions/2026-10-26-sign-in-keeps-signed-out-shift-work.md",
"section": "decisions",
"catalogued": true
},
{
"path": "docs/development-speed-playbook.md",
"section": "root",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Decision: Signing in asks before clearing this shift's signed-out work, and closing the question keeps it

- **Status:** decided 2026-10-26
- **Source:** `src/components/front-door/signed-out-work-prompt.tsx`, find "Closing the sheet keeps it, because clearing cannot be undone"

On 26 Oct 2026 the owner approved the design wording in pull request 3836 and chose, by decision card,
that closing the sign-in question keeps the work ("Approve, closing keeps"). When someone signs in on a
phone holding this shift's call notes, handover drafts or timers made while signed out, the app asks
whether to keep them with the account or clear them. Closing without choosing keeps them. Kept labels
still clear at the end of the shift, at sign-out, at expiry and when a different account signs in.
Before this they were cleared at sign-in without a word.

If this summary and the source ever differ, the source wins.
2 changes: 1 addition & 1 deletion docs/design-system/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `ChoiceChip` | controls | yes | yes | inherited-global-root | yes | no | 7 |
| `Citation` | source | yes | yes | inherited-global-root | yes | no | 1 |
| `CitationList` | source | yes | yes | inherited-global-root | yes | no | 1 |
| `ConfirmDialog` | layout | yes | yes | inherited-global-root | yes | no | 18 |
| `ConfirmDialog` | layout | yes | yes | inherited-global-root | yes | no | 20 |
| `DateDisplay` | source | yes | yes | inherited-global-root | yes | no | 4 |
| `Disclosure` | layout | yes | yes | inherited-global-root | yes | no | 3 |
| `DisclosureGroup` | layout | yes | yes | inherited-global-root | yes | no | 1 |
Expand Down
4 changes: 4 additions & 0 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -841,13 +841,15 @@
"src/components/calendar/calendar-subscribe.tsx",
"src/components/clinical-dashboard/favourites-command-library-page.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/clinical-dashboard/sign-out-confirm.tsx",
"src/components/cme/cme-drafts-section.tsx",
"src/components/cme/cme-entry-form.tsx",
"src/components/cme/cme-entry-route-client.tsx",
"src/components/cme/cme-missed-sessions-section.tsx",
"src/components/cme/cme-training-page.tsx",
"src/components/cme/cme-year-close-panel.tsx",
"src/components/front-door/ask-terms-step.tsx",
"src/components/front-door/signed-out-work-prompt.tsx",
"src/components/on-call/on-call-entry-editor.tsx",
"src/components/on-call/service-admin-panel.tsx",
"src/components/on-call/service-governance-panel.tsx",
Expand All @@ -861,13 +863,15 @@
"src/components/calendar/calendar-subscribe.tsx",
"src/components/clinical-dashboard/favourites-command-library-page.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/clinical-dashboard/sign-out-confirm.tsx",
"src/components/cme/cme-drafts-section.tsx",
"src/components/cme/cme-entry-form.tsx",
"src/components/cme/cme-entry-route-client.tsx",
"src/components/cme/cme-missed-sessions-section.tsx",
"src/components/cme/cme-training-page.tsx",
"src/components/cme/cme-year-close-panel.tsx",
"src/components/front-door/ask-terms-step.tsx",
"src/components/front-door/signed-out-work-prompt.tsx",
"src/components/on-call/on-call-entry-editor.tsx",
"src/components/on-call/service-admin-panel.tsx",
"src/components/on-call/service-governance-panel.tsx",
Expand Down
24 changes: 24 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ function ClinicalDashboardContent({
// while `error` is truthy, and set alongside every `setError(<message>)` so a
// stale value can never leak into a later, unrelated error.
const [errorKind, setErrorKind] = useState<AnswerErrorKind | null>(null);
const [errorDetail, setErrorDetail] = useState<{ message: string; detail: string } | null>(null);
const [lastFailedQuery, setLastFailedQuery] = useState<string | null>(null);
const [setupWarning, setSetupWarning] = useState<string | null>(null);
const [setupChecks, setSetupChecks] = useState<SetupCheck[]>(fallbackSetupChecks);
Expand Down Expand Up @@ -954,6 +955,7 @@ function ClinicalDashboardContent({
setRestoredAnswerSavedAt,
setError,
setErrorKind,
setErrorDetail,
setLastFailedQuery,
setDemoMode,
setActionNotice,
Expand Down Expand Up @@ -1103,6 +1105,21 @@ function ClinicalDashboardContent({
askRef.current = ask;
});

// Sign in and send (D26-05): a question an expired sign-in held back goes once the person is
// signed in again. Any newer search or cleared error drops the pending send.
const pendingSignInResendRef = useRef<string | null>(null);
useEffect(() => {
if (!error) pendingSignInResendRef.current = null;
}, [error]);
useEffect(() => {
if (authStatus !== "authenticated") return;
const held = pendingSignInResendRef.current;
if (!held) return;
pendingSignInResendRef.current = null;
setError(null);
void askRef.current(held);
}, [authStatus]);

useEffect(() => {
const trimmedQuery = query.trim();
const submittedSearchText = searchMode === "answer" && submittedUrlQuery ? submittedUrlQuery : trimmedQuery;
Expand Down Expand Up @@ -2173,6 +2190,7 @@ function ClinicalDashboardContent({
errorKind={errorKind}
activeModeResultKind={activeModeResultKind}
documentsResultsOwnError={documentsResultsOwnError}
errorDetail={errorDetail?.message === error ? errorDetail.detail : null}
lastFailedQuery={lastFailedQuery}
query={query}
onRephrase={() => focusComposerInput()}
Expand All @@ -2182,6 +2200,12 @@ function ClinicalDashboardContent({
void ask(retryQuery);
}}
onSearchDocuments={() => crossModeSearch("documents", (lastFailedQuery ?? query).trim())}
onSignInAndSend={() => {
const held = lastFailedQuery ?? query;
pendingSignInResendRef.current = held;
setQuery(held);
openSettings();
}}
/>
)}

Expand Down
6 changes: 6 additions & 0 deletions src/components/app-helpers-deferred.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const SignInEndedNotice = dynamic(
() => import("@/components/front-door/sign-in-ended-notice").then((m) => m.SignInEndedNotice),
{ ssr: false },
);
const SignedOutWorkPrompt = dynamic(
() => import("@/components/front-door/signed-out-work-prompt").then((m) => m.SignedOutWorkPrompt),
{ ssr: false },
);
const MotionEnter = dynamic(() => import("@/components/motion/motion-enter").then((m) => m.MotionEnter), {
ssr: false,
});
Expand All @@ -36,6 +40,8 @@ export function AppHelpersDeferred() {
<TermsAcceptancePrompt />
{/* Says when an ended sign-in cleared this device's private notes. */}
<SignInEndedNotice />
{/* Asks whether a first sign-in keeps the shift's signed-out work on this phone. */}
<SignedOutWorkPrompt />
</>
);
}
3 changes: 2 additions & 1 deletion src/components/clinical-dashboard/answer-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isAbortError,
} from "@/components/clinical-dashboard/clinical-dashboard-helpers";
import {
answerApiFailure,
makeSearchError,
readAnswerStream,
SEARCH_SESSION_EXPIRED_MESSAGE,
Expand Down Expand Up @@ -73,7 +74,7 @@ export async function requestAnswerStream({
throw makeSearchError(SEARCH_SESSION_EXPIRED_MESSAGE, 401, false);
}
if (!response.ok) {
throw await parseApiErrorResponse(response);
throw answerApiFailure(await parseApiErrorResponse(response));
}

try {
Expand Down
41 changes: 41 additions & 0 deletions src/components/clinical-dashboard/clinical-ask-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ import { ClinicalAskAnswerSurface } from "./clinical-ask-answer-surface";
import { useClinicalAskSession } from "./clinical-ask-session-context";
import { clinicalAskWorkspaceVisible } from "./use-clinical-ask-shell-state";

/** How long a Clinical Ask may run with no result before the reader is offered a choice (D26-10). */
export const CLINICAL_ASK_STALL_PROMPT_MS = 45_000;

/**
* True once a submitted Clinical Ask has waited `CLINICAL_ASK_STALL_PROMPT_MS` with no result.
* Keep waiting restarts the wait. Before this a stalled answer spun with no end (design audit D26-10).
*/
function useClinicalAskStall(waiting: boolean) {
const [stalled, setStalled] = useState(false);
const [round, setRound] = useState(0);
useEffect(() => {
if (!waiting) return;
const timer = window.setTimeout(() => {
setStalled(true);
// A status node inserted already filled is not read out reliably, so say it through the announcer.
announce("This is taking longer than usual. You can keep waiting or stop.");
}, CLINICAL_ASK_STALL_PROMPT_MS);
return () => {
window.clearTimeout(timer);
setStalled(false);
};
}, [waiting, round]);
return { stalled: waiting && stalled, keepWaiting: () => setRound((value) => value + 1) };
}

/** What a screen reader hears when each kind of Clinical Ask result lands. */
const responseAnnouncements: Record<ClinicalAskResponse["state"], string> = {
answered: "Clinical Ask answer ready.",
Expand All @@ -32,6 +57,7 @@ export function ClinicalAskWorkspace({
const [contextOpen, setContextOpen] = useState(false);
const contextTriggerRef = useRef<HTMLButtonElement>(null);
const visible = clinicalAskWorkspaceVisible(session);
const stall = useClinicalAskStall(visible && session.submitted && !session.response);
// Stage changes go through the app's single announcer. A status node inserted
// with its text already inside is often not read, and the result replaced it
// with no live region at all, so a screen-reader user heard nothing either way.
Expand Down Expand Up @@ -68,6 +94,21 @@ export function ClinicalAskWorkspace({
<p role="alert">Remove identifiable details before using Smart mode.</p>
) : null}
{session.submitted ? <p>Clinical Ask is gathering governed evidence…</p> : null}
{stall.stalled ? (
<div role="status" data-testid="clinical-ask-stall">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<p>
<strong>This is taking longer than usual.</strong> The service may be busy.
</p>
<div className="flex gap-2">
<button type="button" onClick={stall.keepWaiting}>
Keep waiting
</button>
<button type="button" onClick={session.cancel}>
Stop
</button>
</div>
</div>
) : null}
{session.response ? (
<ClinicalAskAnswerSurface
response={session.response}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { CircleAlert, FileText, RefreshCw, Search } from "lucide-react";
import { CircleAlert, FileText, LogIn, RefreshCw, Search } from "lucide-react";

import { generateQuerySuggestions, type AnswerErrorKind } from "@/components/clinical-dashboard/search-utils";
import { cn, EmptyState, floatingControl, primaryControl } from "@/components/ui-primitives";
Expand All @@ -17,22 +17,80 @@ export function DashboardSearchErrorState({
errorKind,
activeModeResultKind,
documentsResultsOwnError,
errorDetail,
lastFailedQuery,
query,
onRephrase,
onRetry,
onSearchDocuments,
onSignInAndSend,
}: {
error: string | null;
errorKind: AnswerErrorKind | null;
activeModeResultKind: AppModeResultKind;
documentsResultsOwnError: boolean;
/** The server's own words for this failure, shown only behind a tap (D26-08). */
errorDetail?: string | null;
lastFailedQuery: string | null;
query: string;
onRephrase: () => void;
onRetry: () => void;
onSearchDocuments: () => void;
/** Opens sign-in and sends the held question once signed in (D26-05). */
onSignInAndSend?: () => void;
}) {
const answerMode = activeModeResultKind === "answer";
if (error && answerMode && errorKind === "session-expired") {
// The question was never sent, so say so and keep it: one button signs in and sends it,
// instead of a dead end that left the reader to find Sign in and retype (D26-05).
return (
<EmptyState
icon={LogIn}
title="You were signed out, so this wasn’t sent"
body="Your question is kept. Sign in and it will send straight away."
live="assertive"
tone="info"
testId="answer-error"
actions={
onSignInAndSend && lastFailedQuery ? (
<button
type="button"
data-testid="answer-error-sign-in-and-send"
onClick={() => onSignInAndSend()}
className={cn(primaryControl, "text-xs")}
>
<LogIn aria-hidden="true" className="h-4 w-4" />
Sign in and send
</button>
) : undefined
}
/>
);
}
if (error && answerMode && errorKind === "daily-limit") {
// Retry cannot clear the daily cap, so it is not offered (D26-09).
return (
<EmptyState
icon={CircleAlert}
title="Today’s answers are used up"
body={error}
live="polite"
tone="info"
testId="answer-error"
actions={
<button
type="button"
data-testid="answer-error-search-documents"
onClick={() => onSearchDocuments()}
className={cn(floatingControl, "text-xs")}
>
<FileText aria-hidden="true" className="h-4 w-4" />
{answerRecovery.searchDocuments}
</button>
}
/>
);
}
return error && errorKind === "no-results" && activeModeResultKind === "answer" ? (
<EmptyState
icon={Search}
Expand Down Expand Up @@ -99,6 +157,12 @@ export function DashboardSearchErrorState({
<FileText aria-hidden="true" className="h-4 w-4" />
{answerRecovery.searchDocuments}
</button>
{errorDetail ? (
<details className="w-full text-xs text-[color:var(--text-muted)]" data-testid="answer-error-detail">
<summary className="inline-flex min-h-tap cursor-pointer items-center">Show technical detail</summary>
<span className="break-words">{errorDetail}</span>
</details>
) : null}
</>
) : undefined
}
Expand Down
38 changes: 37 additions & 1 deletion src/components/clinical-dashboard/search-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
import { isAnswerStreamEventName, type VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
import { projectClientAnswerPayload, type ClientRagAnswerPayload } from "@/lib/answer-client-payload";
import type { RagAnswer } from "@/lib/types";
import { ANSWER_DAILY_LIMIT_CODE, answerBurstLimitMessage, answerDailyLimitMessage } from "@/lib/answer-limit-copy";
import type { ApiClientError } from "@/lib/api-client-error";

export { keywordQueryFromNaturalLanguage } from "@/lib/keyword-query";

Expand Down Expand Up @@ -49,6 +51,8 @@ export type SearchError = Error & {
retryable?: boolean;
code?: string;
retryAfterMs?: number | null;
/** The server's own words, kept behind "Show technical detail" rather than shown as the message. */
detail?: string;
};

export const searchRetryDelaysMs = [500, 1000, 2000] as const;
Expand Down Expand Up @@ -354,7 +358,7 @@ export function createAnswerRequestWatchdog(
};
}

export type AnswerErrorKind = "no-results" | "session-expired" | "failure";
export type AnswerErrorKind = "no-results" | "session-expired" | "daily-limit" | "failure";

/** Shown when the server answers 401: retrying with the expired session cannot help (audit F-15). */
export const SEARCH_SESSION_EXPIRED_MESSAGE = "Your session expired. Sign in again, then try again.";
Expand All @@ -375,9 +379,41 @@ export function classifyAnswerError(error: unknown): AnswerErrorKind {
if (error instanceof Error && (error as SearchError).status === 401) {
return "session-expired";
}
if (
error instanceof Error &&
(error as SearchError).status === 429 &&
(error as SearchError).code === ANSWER_DAILY_LIMIT_CODE
) {
return "daily-limit";
}
return "failure";
}

/** Shown when the answer service itself fails; the server's words move behind a tap (D26-08). */
export const ANSWER_SERVICE_FAILED_MESSAGE =
"This answer didn’t finish. Your question is kept. Try again, or search your documents for the same topic.";

/**
* Turn a failed answer response into plain words. The daily cap gets its reset time and no Retry,
* a burst limit gets its wait in minutes, and a server fault keeps its own text only as `detail`.
* Other 4xx messages are already written for the reader and pass through unchanged.
*/
export function answerApiFailure(apiError: ApiClientError, now = Date.now()): SearchError {
const daily = apiError.status === 429 && apiError.code === ANSWER_DAILY_LIMIT_CODE;
const message = daily
? answerDailyLimitMessage(apiError.retryAfterMs, now)
: apiError.status === 429
? answerBurstLimitMessage(apiError.retryAfterMs)
: apiError.status >= 500
? ANSWER_SERVICE_FAILED_MESSAGE
: apiError.message;
const error = makeSearchError(message, apiError.status, daily ? false : apiError.retryable);
error.code = apiError.code;
error.retryAfterMs = apiError.retryAfterMs;
if (message !== apiError.message && apiError.message) error.detail = apiError.message;
return error;
}

/**
* Generate intelligent query rephrasing suggestions for zero-result states.
*/
Expand Down
Loading
Loading