feat: multi-component-selector (#1728)
<!-- This is an auto-generated description by cubic. --> ## Summary by cubic Adds multi-component selection in the preview and sends all selected components to chat for targeted edits. Updates overlays, UI, and IPC to support arrays, smarter context focusing, and cross-platform path normalization. - **New Features** - Select multiple components in the iframe; selection mode stays active until you deactivate it. - Show a scrollable list of selections with remove buttons and a Clear all; remove from the list or click an overlay in the preview to deselect. Sending clears all overlays. - Separate hover vs selected overlays with labels on hover; overlays persist after deactivation and re-position on layout changes/resizes. - Chat input and streaming now send selectedComponents; server builds per-component snippets and focuses their files in smart context. - **Migration** - Replace selectedComponentPreviewAtom with selectedComponentsPreviewAtom (ComponentSelection[]). - ChatStreamParams now uses selectedComponents; migrate any single-selection usages. - previewIframeRefAtom added for clearing overlays from the parent. <sup>Written for commit da0d64cc9e9f83fbf4b975278f6c869f0d3a8c7d. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
c4591996ea
commit
2a7f5a8909
@@ -351,44 +351,51 @@ export function registerChatStreamHandlers() {
|
||||
} catch (e) {
|
||||
logger.error("Failed to inline referenced prompts:", e);
|
||||
}
|
||||
if (req.selectedComponent) {
|
||||
let componentSnippet = "[component snippet not available]";
|
||||
try {
|
||||
const componentFileContent = await readFile(
|
||||
path.join(
|
||||
getDyadAppPath(chat.app.path),
|
||||
req.selectedComponent.relativePath,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const lines = componentFileContent.split("\n");
|
||||
const selectedIndex = req.selectedComponent.lineNumber - 1;
|
||||
|
||||
// Let's get one line before and three after for context.
|
||||
const startIndex = Math.max(0, selectedIndex - 1);
|
||||
const endIndex = Math.min(lines.length, selectedIndex + 4);
|
||||
const componentsToProcess = req.selectedComponents || [];
|
||||
|
||||
const snippetLines = lines.slice(startIndex, endIndex);
|
||||
const selectedLineInSnippetIndex = selectedIndex - startIndex;
|
||||
if (componentsToProcess.length > 0) {
|
||||
userPrompt += "\n\nSelected components:\n";
|
||||
|
||||
if (snippetLines[selectedLineInSnippetIndex]) {
|
||||
snippetLines[selectedLineInSnippetIndex] =
|
||||
`${snippetLines[selectedLineInSnippetIndex]} // <-- EDIT HERE`;
|
||||
for (const component of componentsToProcess) {
|
||||
let componentSnippet = "[component snippet not available]";
|
||||
try {
|
||||
const componentFileContent = await readFile(
|
||||
path.join(getDyadAppPath(chat.app.path), component.relativePath),
|
||||
"utf8",
|
||||
);
|
||||
const lines = componentFileContent.split(/\r?\n/);
|
||||
const selectedIndex = component.lineNumber - 1;
|
||||
|
||||
// Let's get one line before and three after for context.
|
||||
const startIndex = Math.max(0, selectedIndex - 1);
|
||||
const endIndex = Math.min(lines.length, selectedIndex + 4);
|
||||
|
||||
const snippetLines = lines.slice(startIndex, endIndex);
|
||||
const selectedLineInSnippetIndex = selectedIndex - startIndex;
|
||||
|
||||
if (snippetLines[selectedLineInSnippetIndex]) {
|
||||
snippetLines[selectedLineInSnippetIndex] =
|
||||
`${snippetLines[selectedLineInSnippetIndex]} // <-- EDIT HERE`;
|
||||
}
|
||||
|
||||
componentSnippet = snippetLines.join("\n");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error reading selected component file content: ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
componentSnippet = snippetLines.join("\n");
|
||||
} catch (err) {
|
||||
logger.error(`Error reading selected component file content: ${err}`);
|
||||
}
|
||||
|
||||
userPrompt += `\n\nSelected component: ${req.selectedComponent.name} (file: ${req.selectedComponent.relativePath})
|
||||
userPrompt += `\n${componentsToProcess.length > 1 ? `${componentsToProcess.indexOf(component) + 1}. ` : ""}Component: ${component.name} (file: ${component.relativePath})
|
||||
|
||||
Snippet:
|
||||
\`\`\`
|
||||
${componentSnippet}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(messages)
|
||||
.values({
|
||||
@@ -460,18 +467,18 @@ ${componentSnippet}
|
||||
|
||||
const appPath = getDyadAppPath(updatedChat.app.path);
|
||||
// When we don't have smart context enabled, we
|
||||
// only include the selected component's file for codebase context.
|
||||
// only include the selected components' files for codebase context.
|
||||
//
|
||||
// If we have selected component and smart context is enabled,
|
||||
// If we have selected components and smart context is enabled,
|
||||
// we handle this specially below.
|
||||
const chatContext =
|
||||
req.selectedComponent && !isSmartContextEnabled
|
||||
req.selectedComponents &&
|
||||
req.selectedComponents.length > 0 &&
|
||||
!isSmartContextEnabled
|
||||
? {
|
||||
contextPaths: [
|
||||
{
|
||||
globPath: req.selectedComponent.relativePath,
|
||||
},
|
||||
],
|
||||
contextPaths: req.selectedComponents.map((component) => ({
|
||||
globPath: component.relativePath,
|
||||
})),
|
||||
smartContextAutoIncludes: [],
|
||||
}
|
||||
: validateChatContext(updatedChat.app.chatContext);
|
||||
@@ -482,12 +489,19 @@ ${componentSnippet}
|
||||
chatContext,
|
||||
});
|
||||
|
||||
// For smart context and selected component, we will mark the selected component's file as focused.
|
||||
// For smart context and selected components, we will mark the selected components' files as focused.
|
||||
// This means that we don't do the regular smart context handling, but we'll allow fetching
|
||||
// additional files through <dyad-read> as needed.
|
||||
if (isSmartContextEnabled && req.selectedComponent) {
|
||||
if (
|
||||
isSmartContextEnabled &&
|
||||
req.selectedComponents &&
|
||||
req.selectedComponents.length > 0
|
||||
) {
|
||||
const selectedPaths = new Set(
|
||||
req.selectedComponents.map((component) => component.relativePath),
|
||||
);
|
||||
for (const file of files) {
|
||||
if (file.path === req.selectedComponent.relativePath) {
|
||||
if (selectedPaths.has(file.path)) {
|
||||
file.focused = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ export class IpcClient {
|
||||
public streamMessage(
|
||||
prompt: string,
|
||||
options: {
|
||||
selectedComponent: ComponentSelection | null;
|
||||
selectedComponents?: ComponentSelection[];
|
||||
chatId: number;
|
||||
redo?: boolean;
|
||||
attachments?: FileAttachment[];
|
||||
@@ -401,7 +401,7 @@ export class IpcClient {
|
||||
chatId,
|
||||
redo,
|
||||
attachments,
|
||||
selectedComponent,
|
||||
selectedComponents,
|
||||
onUpdate,
|
||||
onEnd,
|
||||
onError,
|
||||
@@ -441,7 +441,7 @@ export class IpcClient {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
selectedComponent,
|
||||
selectedComponents,
|
||||
attachments: fileDataArray,
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -464,7 +464,7 @@ export class IpcClient {
|
||||
prompt,
|
||||
chatId,
|
||||
redo,
|
||||
selectedComponent,
|
||||
selectedComponents,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error streaming message:", err);
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ChatStreamParams {
|
||||
data: string; // Base64 encoded file data
|
||||
attachmentType: "upload-to-codebase" | "chat-context"; // FileAttachment type
|
||||
}>;
|
||||
selectedComponent: ComponentSelection | null;
|
||||
selectedComponents?: ComponentSelection[];
|
||||
}
|
||||
|
||||
export interface ChatResponseEnd {
|
||||
|
||||
Reference in New Issue
Block a user