mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix: localize document summaries and refine mobile coach
This commit is contained in:
1 parent
f1a1887a01
commit
3aa8ccd541
22 files changed
+434
-417
No files matched your search
@@ -55,11 +55,32 @@ describe("DocumentSummaryBlock — a stored summary", () => {
|
||||
expect(html).toContain("A discharge letter from a city hospital.");
|
||||
expect(html).toContain("14 Feb 2026");
|
||||
expect(html).toContain('data-slot="document-detail-summary"');
|
||||
// Nothing claims it is being made — it already exists.
|
||||
// A stored result stays visible and can be explicitly regenerated.
|
||||
expect(html).not.toContain("being prepared");
|
||||
expect(html).toContain('data-slot="document-detail-summary-regenerate"');
|
||||
expect(html).toContain("Generate again");
|
||||
const header = html.match(
|
||||
/<div[^>]*data-slot="document-detail-summary-header"[^>]*>/,
|
||||
);
|
||||
expect(header?.[0]).toMatch(/\bflex-col\b/);
|
||||
expect(header?.[0]).toMatch(/\bsm:flex-row\b/);
|
||||
expect(html).not.toContain("document-detail-summary-generate");
|
||||
});
|
||||
|
||||
it("disables regeneration while a replacement is being generated", () => {
|
||||
const html = base({
|
||||
summary: "Stored prose.",
|
||||
summaryState: "READY",
|
||||
isGenerating: true,
|
||||
});
|
||||
|
||||
const action = html.match(
|
||||
/<button[^>]*data-slot="document-detail-summary-regenerate"[^>]*>/,
|
||||
);
|
||||
expect(action?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
|
||||
expect(html).toContain("Generating");
|
||||
});
|
||||
|
||||
it("shows a stored summary even if the state disagrees", () => {
|
||||
// The bytes are the truth. A stale state must never hide a real summary.
|
||||
const html = base({ summary: "Stored prose.", summaryState: "PENDING" });
|
||||
|
||||
@@ -260,6 +260,9 @@ export function DocumentDetailSheet({
|
||||
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
// Stored generation has an independent mutation state so transient
|
||||
// Summarise/Show text requests cannot change this block's spinner or result.
|
||||
const storedSummary = useDocumentSummary();
|
||||
const indexDoc = useIndexDocument();
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
@@ -296,6 +299,7 @@ export function DocumentDetailSheet({
|
||||
useEffect(() => {
|
||||
suggest.reset();
|
||||
summary.reset();
|
||||
storedSummary.reset();
|
||||
indexDoc.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentId]);
|
||||
@@ -399,17 +403,29 @@ export function DocumentDetailSheet({
|
||||
summary.mutate({ mode: aiMode, target: aiTarget, output });
|
||||
};
|
||||
|
||||
// Generate the STORED summary from the detail block. Same endpoint as the
|
||||
// action row, but the result is not routed into the transient panel: the
|
||||
// route persists a screened-clean summary, so refetching the document shows
|
||||
// it in place. This runs only on a click — nothing here generates on open.
|
||||
// Generate or regenerate the STORED summary from the detail block. Only the
|
||||
// stored-summary branch requests replacement; the empty-state action keeps
|
||||
// first-write-wins if another worker or tab finishes while AI is running.
|
||||
const generateStoredSummary = () => {
|
||||
if (!aiTarget || !documentId) return;
|
||||
summary.reset();
|
||||
summary.mutate(
|
||||
{ mode: aiMode, target: aiTarget, output: "summary" },
|
||||
storedSummary.reset();
|
||||
storedSummary.mutate(
|
||||
{
|
||||
onSuccess: () => {
|
||||
mode: aiMode,
|
||||
target: aiTarget,
|
||||
output: "summary",
|
||||
persist: true,
|
||||
replace: doc?.summary != null,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if ("summary" in result) {
|
||||
if (result.persistence === "withheld") {
|
||||
toast.error(result.summary);
|
||||
} else if (result.persistence === "failed") {
|
||||
toast.error(t("documents.detail.saveError"));
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
@@ -611,7 +627,7 @@ export function DocumentDetailSheet({
|
||||
: null
|
||||
}
|
||||
aiEnabled={aiEnabled}
|
||||
isGenerating={summary.isPending}
|
||||
isGenerating={storedSummary.isPending}
|
||||
actionsDisabled={capability.isPending}
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* provider call. With no AI provider configured the block renders the state
|
||||
* without an action rather than an button that would 422.
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -63,13 +63,42 @@ export function DocumentSummaryBlock({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A stored summary wins over every state: it is shown from storage.
|
||||
// A stored summary wins over every state: it is shown from storage. The
|
||||
// adjacent action is explicit, so opening the sheet never spends a provider
|
||||
// call; a successful replacement is persisted by the summary route.
|
||||
if (summary) {
|
||||
return (
|
||||
<section className="space-y-1.5" data-slot="document-detail-summary">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.summary.title")}
|
||||
</p>
|
||||
<section className="space-y-2" data-slot="document-detail-summary">
|
||||
<div
|
||||
data-slot="document-detail-summary-header"
|
||||
className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.summary.title")}
|
||||
</p>
|
||||
{aiEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="document-detail-summary-regenerate"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || actionsDisabled}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
isGenerating
|
||||
? "size-4 animate-spin motion-reduce:animate-none"
|
||||
: "size-4"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
{isGenerating
|
||||
? t("documents.detail.summary.generating")
|
||||
: t("documents.detail.summary.regenerate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-foreground text-sm">{summary}</p>
|
||||
{generatedAtLabel ? (
|
||||
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
|
||||
|
||||
@@ -30,8 +30,13 @@ import {
|
||||
type DocumentAiTarget,
|
||||
} from "./document-ai-transport";
|
||||
|
||||
/** The session-only describe result — a summary XOR the raw extracted text. */
|
||||
export type DocumentDescribeResult = { summary: string } | { text: string };
|
||||
/** The describe result: raw text, or a summary plus its storage outcome. */
|
||||
export type DocumentDescribeResult =
|
||||
| {
|
||||
summary: string;
|
||||
persistence?: "stored" | "withheld" | "failed";
|
||||
}
|
||||
| { text: string };
|
||||
|
||||
/**
|
||||
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
|
||||
@@ -95,8 +100,8 @@ export function useSuggestDetails() {
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand, session-only summary or extracted text. The result is rendered
|
||||
* once and never persisted (P2-D4); closing the panel discards it.
|
||||
* On-demand summary or extracted text. Both stay transient unless the document
|
||||
* summary block explicitly requests persistence or replacement.
|
||||
*/
|
||||
export function useDocumentSummary() {
|
||||
return useMutation<
|
||||
@@ -106,11 +111,13 @@ export function useDocumentSummary() {
|
||||
mode: DocumentAiMode;
|
||||
target: DocumentAiTarget;
|
||||
output: DocumentSummaryMode;
|
||||
persist?: boolean;
|
||||
replace?: boolean;
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ mode, target, output }) =>
|
||||
mutationFn: ({ mode, target, output, persist = false, replace = false }) =>
|
||||
runDocumentAi<DocumentDescribeResult>({
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`,
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
|
||||
mode,
|
||||
target,
|
||||
}),
|
||||
|
||||
@@ -24,12 +24,7 @@ describe("<CoachInput>", () => {
|
||||
expect(html).not.toContain("Coach replies are generated");
|
||||
});
|
||||
|
||||
it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => {
|
||||
// v1.25.0 — the mic only mounts once the client confirms the Web Speech
|
||||
// API exists AND the page runs in a secure context. SSR (and any
|
||||
// unsupported / non-secure environment) renders NO mic at all, so the
|
||||
// composer never ships an advertised-but-erroring control. The earlier
|
||||
// "render it disabled" affordance is gone.
|
||||
it("does not render the retired voice-input control", () => {
|
||||
const html = render(
|
||||
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||
);
|
||||
@@ -233,11 +228,7 @@ describe("<CoachInput>", () => {
|
||||
expect(html).toContain('data-slot="coach-input-send"');
|
||||
});
|
||||
|
||||
it("renders the one-row page composer with showHub", () => {
|
||||
// v1.21.0 — the page composer is ONE baseline row: a leading `+` actions
|
||||
// menu (new chat + open conversations), the textarea, then mic + send.
|
||||
// The settings gear moved OUT of the composer (now the page toolbar's
|
||||
// top-right), so the composer's front is only the `+`.
|
||||
it("puts the textarea above a split control row on phones", () => {
|
||||
const html = render(
|
||||
<CoachInput
|
||||
value=""
|
||||
@@ -248,21 +239,29 @@ describe("<CoachInput>", () => {
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-input-hub"');
|
||||
expect(html).toContain('data-slot="coach-input-controls"');
|
||||
expect(html).toContain('data-slot="coach-input-leading"');
|
||||
expect(html).toContain('data-slot="coach-input-actions"');
|
||||
// The settings gear is gone from the composer entirely.
|
||||
expect(html).not.toContain('data-slot="coach-input-settings"');
|
||||
// Send remains in the trailing cluster. The mic is client-and-secure-
|
||||
// context only, so it never appears in SSR markup.
|
||||
expect(html).not.toContain('data-slot="coach-input-mic"');
|
||||
expect(html).toContain('data-slot="coach-input-send"');
|
||||
// The leading `+` precedes the textarea; the textarea precedes the send.
|
||||
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
|
||||
|
||||
// DOM and visual order agree at every breakpoint: the textarea comes first,
|
||||
// followed by the leading actions and then Send.
|
||||
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
|
||||
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
|
||||
const sendIdx = html.indexOf('data-slot="coach-input-send"');
|
||||
expect(plusIdx).toBeGreaterThan(-1);
|
||||
expect(plusIdx).toBeLessThan(textareaIdx);
|
||||
expect(textareaIdx).toBeLessThan(sendIdx);
|
||||
expect(textareaIdx).toBeGreaterThan(-1);
|
||||
expect(textareaIdx).toBeLessThan(plusIdx);
|
||||
expect(plusIdx).toBeLessThan(sendIdx);
|
||||
|
||||
const controls = html.match(
|
||||
/<div[^>]*data-slot="coach-input-controls"[^>]*>/,
|
||||
);
|
||||
expect(controls?.[0]).toMatch(/\bw-full\b/);
|
||||
expect(controls?.[0]).toMatch(/\bjustify-between\b/);
|
||||
expect(controls?.[0]).toMatch(/\bsm:w-auto\b/);
|
||||
expect(controls?.[0]).not.toMatch(/\bsm:contents\b/);
|
||||
});
|
||||
|
||||
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
|
||||
|
||||
@@ -6,15 +6,12 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Send,
|
||||
@@ -23,7 +20,6 @@ import {
|
||||
Target,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -57,29 +53,14 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* the guard stack (system prompt, prose-grounding check, outbound
|
||||
* refusal), not a rendered disclaimer line.
|
||||
*
|
||||
* v1.18.7 (W-coach C-UI): voice dictation returns to the web composer.
|
||||
* The earlier placeholder mic was dropped in v1.4.25 because it did
|
||||
* nothing on tap; this one is wired to the Web Speech API
|
||||
* (`SpeechRecognition`). While the user dictates, interim + final
|
||||
* transcripts append into the controlled `value`; the button toggles
|
||||
* listening on/off and is fully keyboard- and screen-reader-accessible.
|
||||
*
|
||||
* v1.25.0: the mic only renders when dictation is genuinely usable —
|
||||
* the Web Speech API exists AND the page runs in a secure context (the
|
||||
* recogniser refuses to start over plain HTTP). On SSR, an unsupported
|
||||
* browser (Firefox), a non-secure self-host, or after the user denies the
|
||||
* mic permission, the control HIDES rather than sitting as an
|
||||
* advertised-but-erroring affordance. A real failure mid-use surfaces a
|
||||
* single quiet toast and then retires the button for the session.
|
||||
*
|
||||
* v1.18.11 (W11): the composer is the conversation's control hub on the
|
||||
* full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a
|
||||
* second action row INSIDE the rounded field: a leading `+` actions menu
|
||||
* (new chat + open conversations), a settings deep-link, the dictation mic,
|
||||
* and the send / stop control on the same baseline. The drawer surface omits
|
||||
* `showHub` and keeps the single-row composer (its own header carries new
|
||||
* chat + settings), so this is purely additive — the existing composer
|
||||
* behaviour and markup are untouched when the hub is off.
|
||||
* full-page Coach surface. When `showHub` is set it includes a `+` actions menu
|
||||
* for a new chat or conversation history. The drawer omits `showHub` because
|
||||
* its header already carries those actions.
|
||||
*
|
||||
* Voice input was removed after repeated browser and permission failures made
|
||||
* the control unreliable. The composer now exposes only actions that work
|
||||
* consistently across supported browsers.
|
||||
*/
|
||||
export interface CoachInputProps {
|
||||
value: string;
|
||||
@@ -184,74 +165,6 @@ export function computeAutoGrowHeight(args: {
|
||||
|
||||
const AUTO_GROW_MAX_LINES = 6;
|
||||
|
||||
/**
|
||||
* v1.18.7 — resolve the browser's `SpeechRecognition` constructor
|
||||
* (prefixed `webkit` on Chromium / Safari, unprefixed on the spec
|
||||
* track). Returns `null` server-side and on browsers without the API
|
||||
* (Firefox) so the mic control hides rather than rendering dead. Kept
|
||||
* tiny + typed locally — the project ships no DOM lib entry for the
|
||||
* Web Speech API.
|
||||
*/
|
||||
type SpeechRecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
type SpeechRecognitionErrorEventLike = { error?: string };
|
||||
type SpeechRecognitionResultEventLike = {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }>;
|
||||
};
|
||||
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
|
||||
|
||||
function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
// v1.18.7 — `useSyncExternalStore` resolves Web Speech API support
|
||||
// without a setState-in-effect: the server snapshot is `false` (no
|
||||
// mic at SSR) and the client snapshot reflects whether the constructor
|
||||
// exists. The store never changes after hydration, so the subscribe
|
||||
// callback is a no-op.
|
||||
//
|
||||
// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition`
|
||||
// throws / fires a `not-allowed` error the instant it starts over plain HTTP,
|
||||
// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic
|
||||
// that can never work. Gating support on `window.isSecureContext` hides it
|
||||
// there cleanly instead.
|
||||
const noopSubscribe = () => () => {};
|
||||
function useVoiceSupported(): boolean {
|
||||
return useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
() => getSpeechRecognitionCtor() !== null && window.isSecureContext,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.25.0 — map the app locale onto a BCP-47 tag with a region the
|
||||
// recogniser accepts. The bare language subtag works in most engines, but
|
||||
// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy
|
||||
// on Chromium / Safari. Unknown locales fall back to the bare subtag.
|
||||
const SPEECH_RECOGNITION_LANG: Record<string, string> = {
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
es: "es-ES",
|
||||
fr: "fr-FR",
|
||||
it: "it-IT",
|
||||
pl: "pl-PL",
|
||||
};
|
||||
|
||||
export function CoachInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -269,7 +182,7 @@ export function CoachInput({
|
||||
onPickFromVault,
|
||||
onUploadNew,
|
||||
}: CoachInputProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const attachFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -319,106 +232,6 @@ export function CoachInput({
|
||||
el.style.height = `${height}px`;
|
||||
}, [value]);
|
||||
|
||||
// v1.18.7 — voice dictation. The mic control only mounts once the
|
||||
// client confirms the Web Speech API exists (resolved through
|
||||
// `useSyncExternalStore`, server snapshot `false`), so SSR +
|
||||
// unsupported browsers render no button at all — never a dead
|
||||
// affordance. `onChange` / `value` are mirrored into refs (in an
|
||||
// effect, never during render) so the long-lived recognition handlers
|
||||
// always read the freshest composer state without re-subscribing.
|
||||
const voiceSupported = useVoiceSupported();
|
||||
const [listening, setListening] = useState(false);
|
||||
// v1.25.0 — once the user denies the mic permission (or the engine reports
|
||||
// it cannot serve), retire the control for the session rather than leaving a
|
||||
// button that errors on every tap. Combined with `voiceSupported` this is
|
||||
// the single gate the mic renders behind.
|
||||
const [voiceBlocked, setVoiceBlocked] = useState(false);
|
||||
const voiceAvailable = voiceSupported && !voiceBlocked;
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
valueRef.current = value;
|
||||
});
|
||||
|
||||
// Tear the recogniser down on unmount so a live mic never outlives
|
||||
// the composer (e.g. the drawer closing mid-dictation).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recognitionRef.current?.abort();
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopDictation = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const startDictation = useCallback(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return;
|
||||
const recognition = new Ctor();
|
||||
recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
// Anchor every transcript onto the text present when dictation
|
||||
// began so interim results replace cleanly rather than stacking.
|
||||
const base = valueRef.current;
|
||||
recognition.onresult = (event) => {
|
||||
let finalText = "";
|
||||
let interimText = "";
|
||||
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
||||
const result = event.results[i];
|
||||
const transcript = result[0]?.transcript ?? "";
|
||||
if (result.isFinal) finalText += transcript;
|
||||
else interimText += transcript;
|
||||
}
|
||||
const spoken = (finalText + interimText).trim();
|
||||
if (!spoken) return;
|
||||
const joiner = base && !base.endsWith(" ") ? " " : "";
|
||||
onChangeRef.current(`${base}${joiner}${spoken}`);
|
||||
};
|
||||
recognition.onerror = (event) => {
|
||||
setListening(false);
|
||||
// `aborted` / `no-speech` are the benign codes that fire on a normal
|
||||
// stop or a quiet pause — stay silent.
|
||||
const code = event?.error;
|
||||
if (!code || code === "aborted" || code === "no-speech") return;
|
||||
// v1.25.0 — a permission refusal (or an engine that reports it cannot
|
||||
// serve) is terminal: surface one quiet toast and HIDE the mic for the
|
||||
// session so the user is not left tapping a control that always errors.
|
||||
if (code === "not-allowed" || code === "service-not-allowed") {
|
||||
setVoiceBlocked(true);
|
||||
}
|
||||
toast.error(t("insights.coach.dictateError"));
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
recognitionRef.current = recognition;
|
||||
try {
|
||||
recognition.start();
|
||||
setListening(true);
|
||||
} catch {
|
||||
// start() throws if already running; treat as a no-op.
|
||||
setListening(false);
|
||||
}
|
||||
}, [locale, t]);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
if (!voiceAvailable) return;
|
||||
if (listening) stopDictation();
|
||||
else startDictation();
|
||||
}, [voiceAvailable, listening, startDictation, stopDictation]);
|
||||
|
||||
// The mic only renders when dictation is available, so the label tracks the
|
||||
// two live states: listening (tap to stop) and idle (tap to dictate).
|
||||
const micLabel = listening
|
||||
? t("insights.coach.dictateStop")
|
||||
: t("insights.coach.dictate");
|
||||
|
||||
const canSubmit = !disabled && value.trim().length > 0;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -439,42 +252,7 @@ export function CoachInput({
|
||||
[canSubmit, onSubmit],
|
||||
);
|
||||
|
||||
// v1.18.11 — the dictation mic. Lifted to a const so it can sit in the
|
||||
// single-row composer (drawer) OR in the hub action row (page) without
|
||||
// duplicating the wiring. v1.25.0 — `null` when dictation is unavailable
|
||||
// (no API, non-secure context, or a denied permission) so the surface hides
|
||||
// the control rather than advertising a button that errors on tap.
|
||||
const micButton = voiceAvailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleDictation}
|
||||
disabled={disabled}
|
||||
data-slot="coach-input-mic"
|
||||
data-listening={listening ? "true" : undefined}
|
||||
aria-label={micLabel}
|
||||
aria-pressed={listening}
|
||||
title={micLabel}
|
||||
className={cn(
|
||||
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
|
||||
listening
|
||||
? "text-brand-pink bg-brand-pink/10 hover:text-brand-pink"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Mic
|
||||
className={cn(
|
||||
"size-4",
|
||||
listening && "animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// v1.18.11 — the send / stop control. Same const-lift rationale as the
|
||||
// mic: one definition, two mount points.
|
||||
// Shared send / stop control for both page and drawer surfaces.
|
||||
const sendButton =
|
||||
isStreaming && onCancel ? (
|
||||
// While a reply streams, swap the send button for a Stop control bound
|
||||
@@ -513,11 +291,9 @@ export function CoachInput({
|
||||
</Button>
|
||||
);
|
||||
|
||||
// v1.21.0 — the leading `+` actions menu (page surface only). Opens the
|
||||
// attachment/scope menu: new chat + open conversations. The settings gear
|
||||
// no longer lives here — it moved to the page toolbar's top-right corner,
|
||||
// so the composer's front is ONLY the `+`. Const-lifted like the mic / send
|
||||
// so it slots into the single-row composer without forking the layout.
|
||||
// Leading `+` actions menu for the full-page Coach surface. The settings
|
||||
// shortcut remains in this menu while the dedicated gear stays in the page
|
||||
// toolbar.
|
||||
const actionsButton = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -638,33 +414,16 @@ export function CoachInput({
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
|
||||
field, ONE baseline row. The drawer surface flanks the textarea with
|
||||
the mic (left) and send / stop (right). The page surface (`showHub`)
|
||||
leads with a `+` actions menu (new chat + open conversations), then
|
||||
the textarea, then the mic + send — same row, vertically centred.
|
||||
The settings gear is NOT in the composer; it lives in the page
|
||||
toolbar's top-right corner. `items-end` keeps the flanking controls
|
||||
pinned to the input's last line as it grows. Enter sends,
|
||||
Shift+Enter inserts a newline. */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 group rounded-2xl border",
|
||||
"shadow-sm transition-colors",
|
||||
"focus-within:border-primary/50 focus-within:ring-primary/50 focus-within:bg-background focus-within:ring-2",
|
||||
// Both surfaces share ONE baseline row: the drawer flanks the
|
||||
// textarea with mic (left) + send (right); the page leads with a
|
||||
// `+` actions menu, then the textarea, then mic + send. `items-end`
|
||||
// keeps the flanking controls pinned to the input's last line as it
|
||||
// grows.
|
||||
"flex items-end gap-1.5 p-1.5",
|
||||
// Mobile uses two rows. Desktop keeps the same DOM and visual order
|
||||
// (textarea, actions, Send) so keyboard focus never jumps backwards.
|
||||
"flex flex-col gap-1.5 p-1.5 sm:flex-row sm:items-end",
|
||||
)}
|
||||
>
|
||||
{/* Leading controls. The document-attach paperclip (module-gated)
|
||||
leads, then the surface control: the `+` actions menu (page) or the
|
||||
dictation mic (drawer). */}
|
||||
{attachButton}
|
||||
{showHub ? actionsButton : micButton}
|
||||
<textarea
|
||||
id={inputId}
|
||||
ref={textareaRef}
|
||||
@@ -674,56 +433,36 @@ export function CoachInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
|
||||
disabled={disabled}
|
||||
// v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard
|
||||
// return on iOS / Android instead of the generic newline
|
||||
// glyph, and lift autocapitalize to "sentences" so a
|
||||
// question typed on phone reads as one. The submit handler
|
||||
// still preserves Shift+Enter for explicit newlines.
|
||||
enterKeyHint="send"
|
||||
autoCapitalize="sentences"
|
||||
// v1.4.25 W5 — single-line initial state, Claude-web-style.
|
||||
// `rows={1}` is the SSR-stable baseline; the `useEffect`
|
||||
// above grows the textarea up to `AUTO_GROW_MAX_LINES`. Past
|
||||
// the cap the textarea scrolls internally (overflow-auto via
|
||||
// `max-h-[9.5rem]`, ≈6 lines at the current line-height).
|
||||
rows={1}
|
||||
className={cn(
|
||||
// `text-base` (16 px) on mobile so iOS Safari does not
|
||||
// zoom-on-focus — the kerned `text-sm` (14 px) trips the
|
||||
// 16 px floor and yanks the viewport on every Coach tap.
|
||||
// Desktop shrinks back to `text-sm` for the compact
|
||||
// composer.
|
||||
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
// Centre the single-line state against the flanking controls so
|
||||
// the placeholder and the icons share a baseline.
|
||||
"order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
"px-2 py-1.5",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
// v1.18.7 — calm, thin scrollbar inside the composer when
|
||||
// dictation overruns 6 lines (see also the thread/history
|
||||
// scroll regions). Scoped here, not in globals.css.
|
||||
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
|
||||
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||
// Keep the placeholder a single line on narrow phones: a long
|
||||
// hint used to wrap to two lines inside the one-row composer and
|
||||
// read as a cramped block. Clip the PLACEHOLDER pseudo only — the
|
||||
// textarea value still wraps and auto-grows for real typing.
|
||||
"placeholder:overflow-hidden placeholder:text-ellipsis placeholder:whitespace-nowrap",
|
||||
)}
|
||||
/>
|
||||
{showHub ? (
|
||||
// v1.21.0 — page surface trailing cluster: mic + send / stop on the
|
||||
// same baseline as the leading `+` and the textarea. The settings
|
||||
// gear moved out of the composer entirely (now in the page toolbar).
|
||||
<div
|
||||
data-slot="coach-input-controls"
|
||||
className="order-2 flex w-full items-center justify-between gap-1.5 sm:w-auto sm:shrink-0 sm:justify-start"
|
||||
>
|
||||
<div
|
||||
data-slot="coach-input-hub"
|
||||
data-slot="coach-input-leading"
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
{attachButton}
|
||||
{showHub ? actionsButton : null}
|
||||
</div>
|
||||
<div
|
||||
data-slot={showHub ? "coach-input-hub" : undefined}
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
{micButton}
|
||||
{sendButton}
|
||||
</div>
|
||||
) : (
|
||||
sendButton
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user