diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index fa095232dc..4f2060b08a 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -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", diff --git a/docs/decisions/2026-10-26-sign-in-keeps-signed-out-shift-work.md b/docs/decisions/2026-10-26-sign-in-keeps-signed-out-shift-work.md new file mode 100644 index 0000000000..96af387755 --- /dev/null +++ b/docs/decisions/2026-10-26-sign-in-keeps-signed-out-shift-work.md @@ -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. diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index cb6cce9b24..b1711152a6 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -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 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index d2f609baf1..c064a993e0 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -841,6 +841,7 @@ "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", @@ -848,6 +849,7 @@ "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", @@ -861,6 +863,7 @@ "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", @@ -868,6 +871,7 @@ "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", diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 3a8e2d408e..2ceca04e24 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -449,6 +449,7 @@ function ClinicalDashboardContent({ // while `error` is truthy, and set alongside every `setError()` so a // stale value can never leak into a later, unrelated error. const [errorKind, setErrorKind] = useState(null); + const [errorDetail, setErrorDetail] = useState<{ message: string; detail: string } | null>(null); const [lastFailedQuery, setLastFailedQuery] = useState(null); const [setupWarning, setSetupWarning] = useState(null); const [setupChecks, setSetupChecks] = useState(fallbackSetupChecks); @@ -954,6 +955,7 @@ function ClinicalDashboardContent({ setRestoredAnswerSavedAt, setError, setErrorKind, + setErrorDetail, setLastFailedQuery, setDemoMode, setActionNotice, @@ -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(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; @@ -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()} @@ -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(); + }} /> )} diff --git a/src/components/app-helpers-deferred.tsx b/src/components/app-helpers-deferred.tsx index c892a2a81c..ea96fb06c0 100644 --- a/src/components/app-helpers-deferred.tsx +++ b/src/components/app-helpers-deferred.tsx @@ -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, }); @@ -36,6 +40,8 @@ export function AppHelpersDeferred() { {/* Says when an ended sign-in cleared this device's private notes. */} + {/* Asks whether a first sign-in keeps the shift's signed-out work on this phone. */} + ); } diff --git a/src/components/clinical-dashboard/answer-request.ts b/src/components/clinical-dashboard/answer-request.ts index 25b9739e55..12a87708ed 100644 --- a/src/components/clinical-dashboard/answer-request.ts +++ b/src/components/clinical-dashboard/answer-request.ts @@ -5,6 +5,7 @@ import { isAbortError, } from "@/components/clinical-dashboard/clinical-dashboard-helpers"; import { + answerApiFailure, makeSearchError, readAnswerStream, SEARCH_SESSION_EXPIRED_MESSAGE, @@ -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 { diff --git a/src/components/clinical-dashboard/clinical-ask-workspace.tsx b/src/components/clinical-dashboard/clinical-ask-workspace.tsx index a57874df5e..aa43ee05ef 100644 --- a/src/components/clinical-dashboard/clinical-ask-workspace.tsx +++ b/src/components/clinical-dashboard/clinical-ask-workspace.tsx @@ -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 = { answered: "Clinical Ask answer ready.", @@ -32,6 +57,7 @@ export function ClinicalAskWorkspace({ const [contextOpen, setContextOpen] = useState(false); const contextTriggerRef = useRef(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. @@ -68,6 +94,21 @@ export function ClinicalAskWorkspace({

Remove identifiable details before using Smart mode.

) : null} {session.submitted ?

Clinical Ask is gathering governed evidence…

: null} + {stall.stalled ? ( +
+

+ This is taking longer than usual. The service may be busy. +

+
+ + +
+
+ ) : null} {session.response ? ( 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 ( + onSignInAndSend()} + className={cn(primaryControl, "text-xs")} + > +