Skip to content

Commit 865aac8

Browse files
committed
feat(ask): start chat from selected code range
1 parent 472692a commit 865aac8

7 files changed

Lines changed: 167 additions & 21 deletions

File tree

packages/web/src/app/(app)/components/editorContextMenu.tsx

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useToast } from "@/components/hooks/use-toast";
44
import { Button } from "@/components/ui/button";
5+
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
56
import useCaptureEvent from "@/hooks/useCaptureEvent";
67
import { createPathWithQueryParams } from "@/lib/utils";
78
import { autoPlacement, computePosition, offset, shift, VirtualElement } from "@floating-ui/react";
@@ -28,6 +29,7 @@ export const EditorContextMenu = ({
2829
const ref = useRef<HTMLDivElement>(null);
2930
const { toast } = useToast();
3031
const captureEvent = useCaptureEvent();
32+
const { createChatFromSource } = useCreateNewChatThread();
3133
useEffect(() => {
3234
if (selection.empty) {
3335
ref.current?.classList.add('hidden');
@@ -126,19 +128,47 @@ export const EditorContextMenu = ({
126128
)
127129
}, [selection.from, selection.to, repoName, revisionName, path, toast, captureEvent, view]);
128130

131+
const onAskSourcebot = useCallback(() => {
132+
if (selection.empty) {
133+
return;
134+
}
135+
136+
const startLine = view.state.doc.lineAt(selection.from).number;
137+
const endLine = view.state.doc.lineAt(selection.to - 1).number;
138+
139+
void createChatFromSource({
140+
type: 'file',
141+
repo: repoName,
142+
path,
143+
name: path.split('/').pop() ?? path,
144+
revision: revisionName,
145+
range: { startLine, endLine },
146+
});
147+
}, [createChatFromSource, path, repoName, revisionName, selection, view]);
148+
129149
return (
130-
<div
131-
ref={ref}
132-
className="absolute z-10 flex flex-col gap-2 bg-background border border-gray-300 dark:border-gray-700 rounded-md shadow-lg p-2"
133-
>
134-
<Button
135-
variant="ghost"
136-
size="sm"
137-
onClick={onCopyLinkToSelection}
138-
>
139-
<Link2Icon className="h-4 w-4 mr-1" />
140-
Share selection
141-
</Button>
142-
</div>
150+
<div
151+
ref={ref}
152+
className="absolute z-10 flex items-center gap-1 rounded-lg border border-border bg-popover p-1 shadow-xl"
153+
>
154+
<Button
155+
variant="secondary"
156+
size="sm"
157+
onClick={onCopyLinkToSelection}
158+
className="h-8 px-3 hover:bg-black/50"
159+
>
160+
<Link2Icon className="mr-2 h-4 w-4" />
161+
Share selection
162+
</Button>
163+
164+
<Button
165+
variant="secondary"
166+
size="sm"
167+
onClick={onAskSourcebot}
168+
className="h-8 px-3 hover:bg-black/50"
169+
>
170+
Ask SourceBot
171+
</Button>
172+
</div>
143173
)
144-
}
174+
}

packages/web/src/ee/features/chat/agent.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ vi.mock('ai', async (importOriginal) => {
121121
};
122122
});
123123

124-
const { createMessageStream } = await import('./agent');
124+
const { createMessageStream, sliceFileSourceForPrompt } = await import('./agent');
125125
const { getPromptCacheStrategy } = await import('./promptCaching');
126126

127127
// Strategies reused across the prompt-caching tests below.
@@ -135,6 +135,33 @@ const listReposInput = {
135135
direction: 'asc',
136136
} as const;
137137

138+
describe('sliceFileSourceForPrompt', () => {
139+
test('slices an inclusive range and preserves its original line offset', () => {
140+
expect(sliceFileSourceForPrompt('one\ntwo\nthree\nfour', {
141+
startLine: 2,
142+
endLine: 3,
143+
})).toEqual({
144+
source: 'two\nthree',
145+
lineOffset: 2,
146+
});
147+
});
148+
149+
test('keeps the full file when there is no selected range', () => {
150+
expect(sliceFileSourceForPrompt('one\ntwo', undefined)).toEqual({
151+
source: 'one\ntwo',
152+
lineOffset: 1,
153+
});
154+
});
155+
156+
test.each([
157+
{ startLine: 0, endLine: 1 },
158+
{ startLine: 3, endLine: 2 },
159+
{ startLine: 1, endLine: 4 },
160+
])('ignores invalid ranges safely', (range) => {
161+
expect(sliceFileSourceForPrompt('one\ntwo\nthree', range)).toBeUndefined();
162+
});
163+
});
164+
138165
const dynamicApprovalRespondedPart = {
139166
type: 'dynamic-tool',
140167
toolName: 'mcp_linear__save_issue',

packages/web/src/ee/features/chat/agent.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
import { randomUUID } from "crypto";
2222
import _dedent from "dedent";
2323
import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "@/features/chat/constants";
24-
import { Source } from "@/features/chat/types";
24+
import { FileSource, Source } from "@/features/chat/types";
2525
import { addLineNumbers, fileReferenceToString, formatAttachmentsForPrompt, getAnswerPartFromAssistantMessage, getTurnProgressState, getUserMessageAttachments, getUserMessageText } from "@/features/chat/utils";
2626
import { createTools } from "./tools";
2727
import { getConnectedMcpClients } from "@/ee/features/chat/mcp/mcpClientFactory";
@@ -551,6 +551,29 @@ interface AgentOptions {
551551
orgId?: number;
552552
}
553553

554+
export const sliceFileSourceForPrompt = (
555+
source: string,
556+
range: FileSource['range'],
557+
): { source: string; lineOffset: number } | undefined => {
558+
if (!range) {
559+
return { source, lineOffset: 1 };
560+
}
561+
562+
const lines = source.split('\n');
563+
if (
564+
range.startLine < 1 ||
565+
range.endLine < range.startLine ||
566+
range.endLine > lines.length
567+
) {
568+
return undefined;
569+
}
570+
571+
return {
572+
source: lines.slice(range.startLine - 1, range.endLine).join('\n'),
573+
lineOffset: range.startLine,
574+
};
575+
};
576+
554577
const createAgentStream = async ({
555578
model,
556579
promptCacheStrategy,
@@ -591,12 +614,20 @@ const createAgentStream = async ({
591614
return undefined;
592615
}
593616

617+
const selectedSource = sliceFileSourceForPrompt(fileSource.source, source.range);
618+
if (!selectedSource) {
619+
logger.warn(`Ignoring invalid selected range for ${source.repo}:${source.path}`);
620+
return undefined;
621+
}
622+
594623
return {
595624
path: fileSource.path,
596-
source: fileSource.source,
625+
source: selectedSource.source,
597626
repo: fileSource.repo,
598627
language: fileSource.language,
599628
revision: source.revision,
629+
lineOffset: selectedSource.lineOffset,
630+
range: source.range,
600631
};
601632
}))
602633
).filter((source) => source !== undefined);
@@ -999,8 +1030,8 @@ const createPrompt = ({
9991030
<files>
10001031
The user has mentioned the following files, which are automatically included for analysis.
10011032
1002-
${files.map(file => `<file path="${file.path}" repository="${file.repo}" language="${file.language}" revision="${file.revision}">
1003-
${addLineNumbers(file.source)}
1033+
${files.map(file => `<file path="${file.path}" repository="${file.repo}" language="${file.language}" revision="${file.revision}"${file.range ? ` selected_lines="${file.range.startLine}-${file.range.endLine}"` : ''}>
1034+
${addLineNumbers(file.source, file.lineOffset)}
10041035
</file>`).join('\n\n')}
10051036
</files>
10061037
`);

packages/web/src/features/chat/useCreateNewChatThread.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useRouter } from "next/navigation";
99
import { createChat } from "./actions";
1010
import { isServiceError } from "@/lib/utils";
1111
import { createPathWithQueryParams } from "@/lib/utils";
12-
import { AttachmentData, SearchScope, SetChatStatePayload } from "./types";
12+
import { AttachmentData, SearchScope, SetChatStatePayload, Source } from "./types";
1313
import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY, SET_CHAT_STATE_SESSION_STORAGE_KEY } from "./constants";
1414
import { useSessionStorage } from "usehooks-ts";
1515

@@ -64,8 +64,38 @@ export const useCreateNewChatThread = () => {
6464
router.push(url);
6565
}, [router, toast, setChatState]);
6666

67+
const createChatFromSource = useCallback(async (source: Source) => {
68+
const inputMessage = createUIMessage(
69+
'Explain this selected code.',
70+
[],
71+
[],
72+
[],
73+
[],
74+
[source],
75+
);
76+
77+
setIsLoading(true);
78+
const response = await createChat({ source: 'sourcebot-web-client' });
79+
if (isServiceError(response)) {
80+
toast({
81+
description: `❌ Failed to create chat. Reason: ${response.message}`,
82+
});
83+
setIsLoading(false);
84+
return;
85+
}
86+
87+
setChatState({
88+
inputMessage,
89+
selectedSearchScopes: [],
90+
disabledMcpServerIds: [],
91+
});
92+
93+
router.push(`/chat/${response.id}`);
94+
}, [router, setChatState, toast]);
95+
6796
return {
6897
createNewChatThread,
98+
createChatFromSource,
6999
isLoading,
70100
};
71101
}

packages/web/src/features/chat/utils.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,29 @@ test('repairReferences handles malformed inline code blocks', () => {
769769
});
770770

771771
describe('createUIMessage', () => {
772+
test('includes an explicit ranged file source', () => {
773+
const result = createUIMessage('Explain this selected code.', [], [], [], [], [{
774+
type: 'file',
775+
repo: 'github.com/sourcebot-dev/sourcebot',
776+
path: 'packages/web/src/auth.ts',
777+
name: 'auth.ts',
778+
revision: 'main',
779+
range: { startLine: 12, endLine: 30 },
780+
}]);
781+
782+
expect(result.parts).toContainEqual({
783+
type: 'data-source',
784+
data: {
785+
type: 'file',
786+
repo: 'github.com/sourcebot-dev/sourcebot',
787+
path: 'packages/web/src/auth.ts',
788+
name: 'auth.ts',
789+
revision: 'main',
790+
range: { startLine: 12, endLine: 30 },
791+
},
792+
});
793+
});
794+
772795
test('includes disabledMcpServerIds in metadata when provided', () => {
773796
const result = createUIMessage('hello', [], [], ['server1', 'server2']);
774797

packages/web/src/features/chat/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export const addLineNumbers = (source: string, lineOffset = 1) => {
200200
return source.split('\n').map((line, index) => `${index + lineOffset}: ${line}`).join('\n');
201201
}
202202

203-
export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[], disabledMcpServerIds: string[] = [], attachments: AttachmentData[] = []): CreateUIMessage<SBChatMessage> => {
203+
export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[], disabledMcpServerIds: string[] = [], attachments: AttachmentData[] = [], explicitSources: Source[] = []): CreateUIMessage<SBChatMessage> => {
204204
// Converts applicable mentions into sources.
205205
const sources: Source[] = mentions
206206
.map((mention) => {
@@ -218,6 +218,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedS
218218
return undefined;
219219
})
220220
.filter((source) => source !== undefined);
221+
sources.push(...explicitSources);
221222
const commandInvocation = createCommandInvocationData(
222223
text,
223224
mentions.filter((mention) => mention.type === 'command'),

packages/web/src/features/tools/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const fileSourceSchema = z.object({
66
path: z.string(),
77
name: z.string(),
88
revision: z.string(),
9+
range: z.object({
10+
startLine: z.number().int().positive(),
11+
endLine: z.number().int().positive(),
12+
}).refine(({ startLine, endLine }) => endLine >= startLine).optional(),
913
});
1014
export type FileSource = z.infer<typeof fileSourceSchema>;
1115

0 commit comments

Comments
 (0)