Skip to content

Commit 7aca0fc

Browse files
committed
Enforce exclusive discuss grounding and refine thread-row controls
- Make Discuss grounding a mutually exclusive selector (None/Library/Sandbox) across UI and session reducer/tests - Prevent new-thread grounding from carrying Library state alongside Sandbox and keep defaults mutually exclusive - Remove repository-badge plumbing from thread rows and adjust action-row behavior styling/visibility - Improve stream scroll behavior by adding a follow-threshold and hiding jump button while autoscrolling
1 parent ac2126d commit 7aca0fc

8 files changed

Lines changed: 152 additions & 103 deletions

src/components/ai-elements/conversation.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import type { UseChatScrollResult } from "./use-chat-scroll";
2727
*/
2828
const ConversationScrollContext = createContext<UseChatScrollResult | null>(null);
2929

30+
const STREAM_FOLLOW_SCROLL_EDGE_THRESHOLD_PX = 64;
31+
3032
function useConversationScroll(): UseChatScrollResult {
3133
const ctx = useContext(ConversationScrollContext);
3234
if (!ctx) {
@@ -57,7 +59,11 @@ export const Conversation = ({
5759
}: ConversationProps) => {
5860
return (
5961
<ConversationScrollContext.Provider value={scroll}>
60-
<MessageScrollerProvider autoScroll defaultScrollPosition="last-anchor">
62+
<MessageScrollerProvider
63+
autoScroll
64+
defaultScrollPosition="last-anchor"
65+
scrollEdgeThreshold={STREAM_FOLLOW_SCROLL_EDGE_THRESHOLD_PX}
66+
>
6167
<MessageScroller
6268
role={role}
6369
aria-live={ariaLive}

src/components/chat-shell-shared/use-chat-composer-session.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,11 @@ describe("useChatComposerSession", () => {
131131
capabilities: capabilities({ defaultGroundLibrary: true, defaultGroundSandbox: true }),
132132
});
133133

134-
expect(result.current.tools.grounding?.groundLibrary).toBe(true);
134+
expect(result.current.tools.grounding?.groundLibrary).toBe(false);
135135
expect(result.current.tools.grounding?.groundSandbox).toBe(true);
136136
});
137137

138-
test("same new-thread id does not share grounding across repositories", () => {
138+
test("same new-thread id keeps grounding exclusive and does not share it across repositories", () => {
139139
const { result, rerender } = renderHook(
140140
({ activeRepositoryId }) =>
141141
useChatComposerSession({
@@ -162,7 +162,7 @@ describe("useChatComposerSession", () => {
162162
result.current.tools.grounding?.setGroundLibrary(true);
163163
result.current.tools.grounding?.setGroundSandbox(true);
164164
});
165-
expect(result.current.tools.grounding?.groundLibrary).toBe(true);
165+
expect(result.current.tools.grounding?.groundLibrary).toBe(false);
166166
expect(result.current.tools.grounding?.groundSandbox).toBe(true);
167167

168168
rerender({ activeRepositoryId: secondRepositoryId });
@@ -208,7 +208,7 @@ describe("useChatComposerSession", () => {
208208
}),
209209
});
210210

211-
expect(result.current.tools.grounding?.groundLibrary).toBe(true);
211+
expect(result.current.tools.grounding?.groundLibrary).toBe(false);
212212
expect(result.current.tools.grounding?.groundSandbox).toBe(true);
213213
});
214214

src/components/grounding-toggle-bar.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ afterEach(() => {
99
});
1010

1111
describe("GroundingToggleBar", () => {
12-
test("recoverable Sandbox state toggles desired grounding and shows prepare-on-send copy", () => {
12+
test("recoverable Sandbox state selects desired grounding and shows prepare-on-send copy", () => {
1313
const setGroundSandbox = vi.fn();
1414

1515
render(
@@ -40,6 +40,54 @@ describe("GroundingToggleBar", () => {
4040
expect(setGroundSandbox).toHaveBeenCalledWith(true);
4141
});
4242

43+
test("selecting Library clears active Sandbox grounding", () => {
44+
const setGroundLibrary = vi.fn();
45+
const setGroundSandbox = vi.fn();
46+
47+
render(
48+
<GroundingToggleBar
49+
axes={createDiscussGroundingAxes({
50+
groundLibrary: false,
51+
groundSandbox: true,
52+
setGroundLibrary,
53+
setGroundSandbox,
54+
grounding: {
55+
library: { enabled: true },
56+
sandbox: { enabled: true },
57+
},
58+
})}
59+
/>,
60+
);
61+
62+
fireEvent.click(screen.getByTestId("grounding-toggle-library"));
63+
64+
expect(setGroundSandbox).toHaveBeenCalledWith(false);
65+
expect(setGroundLibrary).toHaveBeenCalledWith(true);
66+
});
67+
68+
test("None clears whichever grounding option is active", () => {
69+
const setGroundLibrary = vi.fn();
70+
71+
render(
72+
<GroundingToggleBar
73+
axes={createDiscussGroundingAxes({
74+
groundLibrary: true,
75+
groundSandbox: false,
76+
setGroundLibrary,
77+
setGroundSandbox: vi.fn(),
78+
grounding: {
79+
library: { enabled: true },
80+
sandbox: { enabled: true },
81+
},
82+
})}
83+
/>,
84+
);
85+
86+
fireEvent.click(screen.getByTestId("grounding-toggle-none"));
87+
88+
expect(setGroundLibrary).toHaveBeenCalledWith(false);
89+
});
90+
4391
test("discuss axis helper returns loading verdicts while availability loads", () => {
4492
const axes = createDiscussGroundingAxes({
4593
groundLibrary: false,

src/components/grounding-toggle-bar.tsx

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Fragment } from "react";
21
import { BookOpenIcon, FlaskIcon } from "@phosphor-icons/react";
32
import type { RepositoryModeDisabledReasonCode } from "../../convex/lib/chatEligibility";
43
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -79,15 +78,15 @@ export function createDiscussGroundingAxes(input: {
7978
}
8079

8180
/**
82-
* Per-message grounding toggle bar for Discuss Mode.
81+
* Per-message grounding selector for Discuss Mode.
8382
*
84-
* Two independent pill toggles — Library (artifact RAG) and Sandbox
85-
* (live source tools) — that compose freely. When both axes are off the
86-
* reply is unbound LLM training-only chat; either or both can be on for
87-
* a grounded reply with the matching citation contract.
83+
* A single-select control for Library (artifact RAG), Sandbox (live source
84+
* tools), or no per-message grounding. Disabled options expose their reason
85+
* through a tooltip. Recoverable Sandbox liveness states stay selectable and
86+
* prepare on send.
8887
*
89-
* Disabled toggles expose their reason through a tooltip. Recoverable
90-
* Sandbox liveness states stay selectable and prepare on send.
88+
* The session reducer also enforces mutual exclusion so this component and
89+
* send-time payloads cannot drift apart.
9190
*/
9291
export function GroundingToggleBar({ axes, hidden = false, className }: GroundingToggleBarProps) {
9392
if (hidden) {
@@ -97,22 +96,39 @@ export function GroundingToggleBar({ axes, hidden = false, className }: Groundin
9796
return (
9897
<TooltipProvider delayDuration={150}>
9998
<div
100-
role="group"
101-
aria-label="Discuss grounding toggles"
102-
className={cn("flex flex-wrap items-center gap-2", className)}
99+
role="radiogroup"
100+
aria-label="Discuss grounding source"
101+
className={cn("inline-flex min-w-0 flex-wrap items-center border border-border bg-background", className)}
103102
>
104-
{axes.map((axis, index) => (
105-
<Fragment key={axis.id}>
106-
{index > 0 ? <span aria-hidden="true" className="h-5 w-px shrink-0 bg-border" /> : null}
107-
<GroundingAxisPill axis={axis} />
108-
</Fragment>
103+
<GroundingNoneOption axes={axes} />
104+
{axes.map((axis) => (
105+
<GroundingAxisOption key={axis.id} axis={axis} axes={axes} />
109106
))}
110107
</div>
111108
</TooltipProvider>
112109
);
113110
}
114111

115-
function GroundingAxisPill({ axis }: { axis: GroundingAxisControl }) {
112+
function GroundingNoneOption({ axes }: { axes: readonly GroundingAxisControl[] }) {
113+
const active = axes.every((axis) => !axis.active);
114+
return (
115+
<GroundingOption
116+
label="None"
117+
active={active}
118+
available
119+
onSelect={() => {
120+
axes.forEach((axis) => {
121+
if (axis.active) {
122+
axis.onActiveChange(false);
123+
}
124+
});
125+
}}
126+
testId="grounding-toggle-none"
127+
/>
128+
);
129+
}
130+
131+
function GroundingAxisOption({ axis, axes }: { axis: GroundingAxisControl; axes: readonly GroundingAxisControl[] }) {
116132
const enabled = axis.verdict.enabled;
117133
const activatable = !axis.verdict.enabled && axis.verdict.isActivatable === true;
118134
const available = enabled || (axis.id === "sandbox" && activatable);
@@ -123,55 +139,62 @@ function GroundingAxisPill({ axis }: { axis: GroundingAxisControl }) {
123139
const iconFilled = axis.active && available;
124140

125141
return (
126-
<GroundingPill
142+
<GroundingOption
127143
label={axis.label}
128144
icon={<Icon size={14} weight={iconFilled ? "fill" : "regular"} />}
129145
active={axis.active}
130146
available={available}
131147
reason={reason}
132148
suffix={suffix}
133-
onToggle={() => {
134-
if (available) {
135-
axis.onActiveChange(!axis.active);
149+
onSelect={() => {
150+
if (!available || axis.active) {
151+
return;
136152
}
153+
axes.forEach((otherAxis) => {
154+
if (otherAxis.id !== axis.id && otherAxis.active) {
155+
otherAxis.onActiveChange(false);
156+
}
157+
});
158+
axis.onActiveChange(true);
137159
}}
138160
testId={`grounding-toggle-${axis.id}`}
139161
/>
140162
);
141163
}
142164

143-
type GroundingPillProps = {
165+
type GroundingOptionProps = {
144166
label: string;
145-
icon: React.ReactNode;
167+
icon?: React.ReactNode;
146168
active: boolean;
147169
available: boolean;
148170
reason?: string;
149171
suffix?: string;
150-
onToggle: () => void;
172+
onSelect: () => void;
151173
testId: string;
152174
};
153175

154-
function GroundingPill({ label, icon, active, available, reason, suffix, onToggle, testId }: GroundingPillProps) {
176+
function GroundingOption({ label, icon, active, available, reason, suffix, onSelect, testId }: GroundingOptionProps) {
155177
const title = available ? (suffix ? `${label} grounding (${suffix})` : `${label} grounding`) : reason;
156178
const button = (
157179
<button
158180
type="button"
159-
aria-pressed={active}
181+
role="radio"
182+
aria-checked={active}
160183
aria-disabled={!available}
161-
onClick={onToggle}
184+
onClick={onSelect}
162185
title={title}
163186
data-testid={testId}
164187
className={cn(
165-
"inline-flex h-7 items-center gap-1.5 border px-2 text-xs font-medium transition-colors",
188+
"inline-flex h-7 items-center gap-1.5 border-0 border-r border-border px-2 text-xs font-medium transition-colors last:border-r-0",
166189
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
167190
active && available
168-
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/15"
191+
? "bg-primary/10 text-primary hover:bg-primary/15"
169192
: available
170-
? "border-border bg-background text-muted-foreground hover:bg-muted/40 hover:text-foreground"
171-
: "cursor-not-allowed border-dashed border-border bg-muted/30 text-muted-foreground/70",
193+
? "bg-background text-muted-foreground hover:bg-muted/40 hover:text-foreground"
194+
: "cursor-not-allowed bg-muted/30 text-muted-foreground/70",
172195
)}
173196
>
174-
{icon}
197+
{icon ?? null}
175198
<span>{label}</span>
176199
{suffix && available ? (
177200
<span aria-hidden="true" className="text-[10px] text-muted-foreground/80">

0 commit comments

Comments
 (0)