Skip to content

Commit 79d66da

Browse files
authored
Rework selection inclusion; new Send button UX (#905)
* remove include selection checkbox * add new selection types to backend * add new TooltippedButton component * add `prompt` field to `HumanChatMessage` This allows chat handlers to distinguish between the message prompt, selection, and body (prompt + selection). - The backend builds the message body from each `ChatRequest`, which consists of a `prompt` and a `selection`. - The body will be used by most chat handlers, and will be used to render the messages in the UI. - `/fix` uses `prompt` and `selection` separately as it does additional processing on each component. `/fix` does not use `body`. * add new include selection icon * implement new send button * pre-commit * fix unit tests * pre-commit * edit cell selection tooltip to indicate output is not included * allow Enter to open dropdown menu * fix double-send bug when using keyboard * re-enable auto focus for first item
1 parent fb88723 commit 79d66da

File tree

14 files changed

+355
-112
lines changed

14 files changed

+355
-112
lines changed

packages/jupyter-ai/jupyter_ai/chat_handlers/fix.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async def process_message(self, message: HumanChatMessage):
8989
selection: CellWithErrorSelection = message.selection
9090

9191
# parse additional instructions specified after `/fix`
92-
extra_instructions = message.body[4:].strip() or "None."
92+
extra_instructions = message.prompt[4:].strip() or "None."
9393

9494
self.get_llm_chain()
9595
with self.pending("Analyzing error"):

packages/jupyter-ai/jupyter_ai/handlers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,17 @@ async def on_message(self, message):
247247
self.log.error(e)
248248
return
249249

250+
message_body = chat_request.prompt
251+
if chat_request.selection:
252+
message_body += f"\n\n```\n{chat_request.selection.source}\n```\n"
253+
250254
# message broadcast to chat clients
251255
chat_message_id = str(uuid.uuid4())
252256
chat_message = HumanChatMessage(
253257
id=chat_message_id,
254258
time=time.time(),
255-
body=chat_request.prompt,
259+
body=message_body,
260+
prompt=chat_request.prompt,
256261
selection=chat_request.selection,
257262
client=self.chat_client,
258263
)

packages/jupyter-ai/jupyter_ai/models.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,28 @@ class CellError(BaseModel):
1414
traceback: List[str]
1515

1616

17+
class TextSelection(BaseModel):
18+
type: Literal["text"] = "text"
19+
source: str
20+
21+
22+
class CellSelection(BaseModel):
23+
type: Literal["cell"] = "cell"
24+
source: str
25+
26+
1727
class CellWithErrorSelection(BaseModel):
1828
type: Literal["cell-with-error"] = "cell-with-error"
1929
source: str
2030
error: CellError
2131

2232

23-
Selection = Union[CellWithErrorSelection]
33+
Selection = Union[TextSelection, CellSelection, CellWithErrorSelection]
2434

2535

2636
# the type of message used to chat with the agent
2737
class ChatRequest(BaseModel):
2838
prompt: str
29-
# TODO: This currently is only used when a user runs the /fix slash command.
30-
# In the future, the frontend should set the text selection on this field in
31-
# the `HumanChatMessage` it sends to JAI, instead of appending the text
32-
# selection to `body` in the frontend.
3339
selection: Optional[Selection]
3440

3541

@@ -88,8 +94,13 @@ class HumanChatMessage(BaseModel):
8894
id: str
8995
time: float
9096
body: str
91-
client: ChatClient
97+
"""The formatted body of the message to be rendered in the UI. Includes both
98+
`prompt` and `selection`."""
99+
prompt: str
100+
"""The prompt typed into the chat input by the user."""
92101
selection: Optional[Selection]
102+
"""The selection included with the prompt, if any."""
103+
client: ChatClient
93104

94105

95106
class ClearMessage(BaseModel):

packages/jupyter-ai/jupyter_ai/tests/test_handlers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,13 @@ def chat_client():
8989

9090
@pytest.fixture
9191
def human_chat_message(chat_client):
92-
return HumanChatMessage(id="test", time=0, body="test message", client=chat_client)
92+
return HumanChatMessage(
93+
id="test",
94+
time=0,
95+
body="test message",
96+
prompt="test message",
97+
client=chat_client,
98+
)
9399

94100

95101
def test_learn_index_permissions(tmp_path):

packages/jupyter-ai/src/components/chat-input.tsx

Lines changed: 23 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import {
66
SxProps,
77
TextField,
88
Theme,
9-
FormGroup,
10-
FormControlLabel,
11-
Checkbox,
129
InputAdornment,
1310
Typography
1411
} from '@mui/material';
@@ -27,15 +24,11 @@ import { ISignal } from '@lumino/signaling';
2724
import { AiService } from '../handler';
2825
import { SendButton, SendButtonProps } from './chat-input/send-button';
2926
import { useActiveCellContext } from '../contexts/active-cell-context';
27+
import { ChatHandler } from '../chat_handler';
3028

3129
type ChatInputProps = {
32-
value: string;
33-
onChange: (newValue: string) => unknown;
34-
onSend: (selection?: AiService.Selection) => unknown;
35-
hasSelection: boolean;
36-
includeSelection: boolean;
30+
chatHandler: ChatHandler;
3731
focusInputSignal: ISignal<unknown, void>;
38-
toggleIncludeSelection: () => unknown;
3932
sendWithShiftEnter: boolean;
4033
sx?: SxProps<Theme>;
4134
/**
@@ -105,6 +98,7 @@ function renderSlashCommandOption(
10598
}
10699

107100
export function ChatInput(props: ChatInputProps): JSX.Element {
101+
const [input, setInput] = useState('');
108102
const [slashCommandOptions, setSlashCommandOptions] = useState<
109103
SlashCommandOption[]
110104
>([]);
@@ -159,24 +153,24 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
159153
* chat input. Close the autocomplete when the user clears the chat input.
160154
*/
161155
useEffect(() => {
162-
if (props.value === '/') {
156+
if (input === '/') {
163157
setOpen(true);
164158
return;
165159
}
166160

167-
if (props.value === '') {
161+
if (input === '') {
168162
setOpen(false);
169163
return;
170164
}
171-
}, [props.value]);
165+
}, [input]);
172166

173167
/**
174168
* Effect: Set current slash command
175169
*/
176170
useEffect(() => {
177-
const matchedSlashCommand = props.value.match(/^\s*\/\w+/);
171+
const matchedSlashCommand = input.match(/^\s*\/\w+/);
178172
setCurrSlashCommand(matchedSlashCommand && matchedSlashCommand[0]);
179-
}, [props.value]);
173+
}, [input]);
180174

181175
/**
182176
* Effect: ensure that the `highlighted` is never `true` when `open` is
@@ -190,25 +184,27 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
190184
}
191185
}, [open, highlighted]);
192186

193-
// TODO: unify the `onSend` implementation in `chat.tsx` and here once text
194-
// selection is refactored.
195-
function onSend() {
196-
// case: /fix
187+
function onSend(selection?: AiService.Selection) {
188+
const prompt = input;
189+
setInput('');
190+
191+
// if the current slash command is `/fix`, we always include a code cell
192+
// with error output in the selection.
197193
if (currSlashCommand === '/fix') {
198194
const cellWithError = activeCell.manager.getContent(true);
199195
if (!cellWithError) {
200196
return;
201197
}
202198

203-
props.onSend({
204-
...cellWithError,
205-
type: 'cell-with-error'
199+
props.chatHandler.sendMessage({
200+
prompt,
201+
selection: { ...cellWithError, type: 'cell-with-error' }
206202
});
207203
return;
208204
}
209205

210-
// default case
211-
props.onSend();
206+
// otherwise, send a ChatRequest with the prompt and selection
207+
props.chatHandler.sendMessage({ prompt, selection });
212208
}
213209

214210
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
@@ -244,7 +240,7 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
244240
</span>
245241
);
246242

247-
const inputExists = !!props.value.trim();
243+
const inputExists = !!input.trim();
248244
const sendButtonProps: SendButtonProps = {
249245
onSend,
250246
sendWithShiftEnter: props.sendWithShiftEnter,
@@ -258,9 +254,9 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
258254
<Autocomplete
259255
autoHighlight
260256
freeSolo
261-
inputValue={props.value}
257+
inputValue={input}
262258
onInputChange={(_, newValue: string) => {
263-
props.onChange(newValue);
259+
setInput(newValue);
264260
}}
265261
onHighlightChange={
266262
/**
@@ -322,23 +318,10 @@ export function ChatInput(props: ChatInputProps): JSX.Element {
322318
FormHelperTextProps={{
323319
sx: { marginLeft: 'auto', marginRight: 0 }
324320
}}
325-
helperText={props.value.length > 2 ? helperText : ' '}
321+
helperText={input.length > 2 ? helperText : ' '}
326322
/>
327323
)}
328324
/>
329-
{props.hasSelection && (
330-
<FormGroup sx={{ display: 'flex', flexDirection: 'row' }}>
331-
<FormControlLabel
332-
control={
333-
<Checkbox
334-
checked={props.includeSelection}
335-
onChange={props.toggleIncludeSelection}
336-
/>
337-
}
338-
label="Include selection"
339-
/>
340-
</FormGroup>
341-
)}
342325
</Box>
343326
);
344327
}

0 commit comments

Comments
 (0)