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
39 changes: 20 additions & 19 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

44 changes: 21 additions & 23 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,15 @@ summary::-webkit-details-marker {
min-width: 0;
}

/* Answer-thread follow-ups: the label reads as an eyebrow above the chips so
wrapped chip rows stay ragged-right without a floating side label. Children
must stretch so the chip row wraps at the column edge instead of overflowing. */
.answer-suggestion-row-stacked {
flex-direction: column;
align-items: stretch;
gap: 0.375rem;
}

.smart-search-rotating-text,
.smart-search-prompt-row,
.smart-search-suggestion-row {
Expand Down Expand Up @@ -1116,10 +1125,17 @@ summary::-webkit-details-marker {
letter-spacing: 0.01em;
}

.answer-suggestion-label-eyebrow {
font-size: 0.625rem;
letter-spacing: 0.06em;
line-height: 1rem;
text-transform: uppercase;
}

.answer-suggestion-chips {
display: flex;
min-width: 0;
gap: 0.375rem;
gap: 0.4375rem;
}

.answer-suggestion-chips-wrap {
Expand Down Expand Up @@ -1149,32 +1165,20 @@ summary::-webkit-details-marker {
display: none;
}

@media (min-width: 768px) {
.cross-mode-links-rail {
scrollbar-width: none;
-ms-overflow-style: none;
mask-image: linear-gradient(90deg, black calc(100% - 1rem), transparent);
-webkit-mask-image: linear-gradient(90deg, black calc(100% - 1rem), transparent);
}

.cross-mode-links-rail::-webkit-scrollbar {
display: none;
}
}

.answer-suggestion-chip {
display: inline-flex;
max-width: min(100%, 18rem);
min-height: 1.75rem;
min-height: 2rem;
flex-shrink: 0;
align-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--surface) 92%, transparent);
padding-inline: 0.625rem;
box-shadow: var(--shadow-inset);
padding-inline: 0.75rem;
color: var(--text-muted);
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25rem;
text-align: left;
Expand Down Expand Up @@ -1265,12 +1269,6 @@ summary::-webkit-details-marker {
margin-bottom: -0.125rem;
}

@media (min-width: 640px) {
.answer-suggestion-chip {
font-size: 0.75rem;
}
}

@media (max-width: 639px) {
.answer-footer-search-dock .answer-suggestion-chip {
background: var(--surface);
Expand Down
45 changes: 30 additions & 15 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,15 +517,17 @@ function PriorAnswerTurnSurface({
[turn.answer, turn.sources],
);
const safeText = useMemo(() => sanitizeAnswerDisplayText(turn.answer.answer), [turn.answer.answer]);
const weakEvidence = renderModel.trust === "unsupported" || renderModel.trust === "low";
const grounded =
turn.answer.grounded === true && turn.answer.confidence !== "unsupported" && renderModel.trust !== "unsupported";
const sourceCount =
renderModel.primarySources.length ||
turn.sources.length ||
turn.answer.sources?.length ||
turn.answer.citations.length;
const previewText = safeText || turn.answer.answer;
const needsSourceReview =
turn.answer.answerQualityTier === "source_only" ||
turn.answer.grounded === false ||
renderModel.trust === "low" ||
renderModel.trust === "unsupported";

return (
<div
Expand All @@ -551,18 +553,31 @@ function PriorAnswerTurnSurface({
{collapsed ? (
<p className={cn("line-clamp-2 text-sm leading-6", textMuted)}>{previewText}</p>
) : (
<NaturalLanguageAnswer
text={previewText}
sourceCount={sourceCount}
weakEvidence={weakEvidence}
grounded={grounded}
sourceOnly={turn.answer.answerQualityTier === "source_only"}
bestSource={renderModel.bestSource}
sources={renderModel.reviewSources}
sourceLinks={renderModel.primarySources}
copied={copied}
onCopy={() => onCopy(renderModel.copyText || previewText)}
/>
<>
<NaturalLanguageAnswer
text={previewText}
sourceCount={sourceCount}
sourceOnly={turn.answer.answerQualityTier === "source_only"}
bestSource={renderModel.bestSource}
sources={renderModel.reviewSources}
sourceLinks={renderModel.primarySources}
copied={copied}
onCopy={() => onCopy(renderModel.copyText || previewText)}
/>
{needsSourceReview ? (
<div
role="note"
data-testid="prior-answer-source-review"
className="mt-2 flex items-start gap-2 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-xs text-[color:var(--text-muted)]"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-[color:var(--warning)]" aria-hidden />
<span>
<strong className="text-[color:var(--text-heading)]">Review source match.</strong> Verify cited
passages before relying on this previous answer.
</span>
</div>
) : null}
</>
)}
</div>
</div>
Expand Down
31 changes: 12 additions & 19 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,18 +258,14 @@ function primaryAnswerDisplayText(value: string) {
.replace(/[;,:-]\s*$/, "")}...`;
}

function sourceCapsuleDisplay({
sourceCount,
weakEvidence,
grounded,
}: {
sourceCount: number;
weakEvidence: boolean;
grounded: boolean;
}): { label: string; showCountBadge: boolean } {
// One compact "Sources" pill in every state: the amber Source-only pill and the
// "Review source match" banner already carry the verify-first caveat, so the
// capsule label no longer restates grounding strength.
function sourceCapsuleDisplay({ sourceCount }: { sourceCount: number }): {
label: string;
showCountBadge: boolean;
} {
if (sourceCount <= 0) return { label: "No direct source found", showCountBadge: false };
if (!grounded) return { label: "Review nearby sources", showCountBadge: false };
if (weakEvidence) return { label: "Review sources", showCountBadge: false };
return { label: "Sources", showCountBadge: true };
Comment thread
BigSimmo marked this conversation as resolved.
}

Expand Down Expand Up @@ -540,8 +536,6 @@ function SourcePreviewContent({
export function NaturalLanguageAnswer({
text,
sourceCount,
weakEvidence,
grounded,
sourceOnly,
bestSource,
sources,
Expand All @@ -551,8 +545,6 @@ export function NaturalLanguageAnswer({
}: {
text: string;
sourceCount: number;
weakEvidence: boolean;
grounded: boolean;
sourceOnly: boolean;
bestSource: BestSourceRecommendation | null;
sources: SearchResult[];
Expand All @@ -573,7 +565,7 @@ export function NaturalLanguageAnswer({
}, []);
const cleaned = primaryAnswerDisplayText(text);
if (!cleaned) return null;
const capsuleDisplay = sourceCapsuleDisplay({ sourceCount, weakEvidence, grounded });
const capsuleDisplay = sourceCapsuleDisplay({ sourceCount });
const previewSources = capsulePreviewSources(bestSource, sources, sourceLinks);
const quoteText = sourceLinks.find((source) => source.snippet)?.snippet || bestSource?.quote || bestSource?.snippet;
const canOpenSourcePreview = previewSources.length > 0;
Expand Down Expand Up @@ -637,14 +629,15 @@ export function NaturalLanguageAnswer({
data-testid="source-only-disclosure"
role="note"
className={cn(
"w-fit max-w-full overflow-hidden rounded-md border border-[color:var(--warning)]/20 border-l-2 border-l-[color:var(--warning)] bg-[color:var(--warning-soft)]/30 text-xs",
"w-fit max-w-full overflow-hidden border border-[color:var(--warning)]/30 bg-[color:var(--warning-soft)]/40 text-xs transition-[border-radius] duration-150",
sourceOnlyNoticeOpen ? "rounded-lg" : "rounded-full",
textMuted,
)}
>
<button
type="button"
onClick={() => setSourceOnlyNoticeOpen((current) => !current)}
className="inline-flex min-h-7 w-full max-w-[68ch] items-center gap-1.5 px-2 py-1 text-left transition hover:bg-[color:var(--warning-soft)]/55 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"
className="inline-flex min-h-7 w-full max-w-[68ch] items-center gap-1.5 px-2.5 py-1 text-left transition hover:bg-[color:var(--warning-soft)]/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"
aria-expanded={sourceOnlyNoticeOpen}
aria-controls="source-only-disclosure-detail"
>
Expand All @@ -662,7 +655,7 @@ export function NaturalLanguageAnswer({
{sourceOnlyNoticeOpen ? (
<div
id="source-only-disclosure-detail"
className="border-t border-[color:var(--warning)]/15 px-2.5 py-1.5 text-2xs leading-4 text-[color:var(--text-muted)] motion-safe:animate-fade-up"
className="border-t border-[color:var(--warning)]/15 px-3 py-2 text-2xs leading-5 text-[color:var(--text-muted)] motion-safe:animate-fade-up"
>
<p>
This answer was assembled from your documents without the AI model, so it may be less complete.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function AnswerFollowUpSuggestions({
onPick={onPick}
disabled={disabled}
label="Try next"
labelPlacement={layout === "wrap" ? "above" : "inline"}
testId={testId}
layout={layout}
className={className}
Expand Down
2 changes: 0 additions & 2 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,6 @@ function StagedAnswerResultSurfaceImpl({
<NaturalLanguageAnswer
text={safeAnswerText || answer.answer}
sourceCount={sourceCount}
weakEvidence={weakEvidence}
grounded={answerGrounded}
sourceOnly={answer.answerQualityTier === "source_only"}
bestSource={bestSource}
sources={sources}
Expand Down
18 changes: 16 additions & 2 deletions src/components/clinical-dashboard/answer-suggestion-chips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function AnswerSuggestionChips({
onPick,
disabled = false,
label,
labelPlacement = "inline",
testId,
layout = "wrap",
className,
Expand All @@ -21,6 +22,9 @@ export function AnswerSuggestionChips({
onPick: (suggestion: string) => void;
disabled?: boolean;
label?: string;
// "above" stacks the label as an eyebrow over the chips (answer-thread
// follow-ups); "inline" keeps it beside them (composer rows, empty state).
labelPlacement?: "inline" | "above";
testId?: string;
layout?: "wrap" | "scroll";
className?: string;
Expand All @@ -29,13 +33,23 @@ export function AnswerSuggestionChips({
icon?: LucideIcon;
}) {
if (!suggestions.length) return null;
const stacked = Boolean(label) && labelPlacement === "above";

return (
<div
data-testid={testId}
className={cn("answer-suggestion-row", layout === "scroll" && "answer-suggestion-row-scroll", className)}
className={cn(
"answer-suggestion-row",
layout === "scroll" && "answer-suggestion-row-scroll",
stacked && "answer-suggestion-row-stacked",
className,
)}
>
{label ? <span className="answer-suggestion-label shrink-0">{label}</span> : null}
{label ? (
<span className={cn("answer-suggestion-label shrink-0", stacked && "answer-suggestion-label-eyebrow")}>
{label}
</span>
) : null}
<div
className={cn(
"answer-suggestion-chips",
Expand Down
Loading
Loading