Smart Context: deep (#1527)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduce a new "deep" Smart Context mode that supplies versioned files (by commit) to the engine, adds code search rendering, stores source commit hashes, improves search-replace recovery, and updates UI/tests. > > - **Smart Context (deep)**: > - Replace `conservative` with `deep`; limit context to ~200 turns; send `sourceCommitHash` per message. > - Build and pass `versioned_files` (hash-id map + per-message file refs) and `app_id` to engine. > - **DB**: > - Add `messages.source_commit_hash` (+ migration/snapshot). > - **Engine/Processing**: > - Retry Turbo Edits v2: first re-read then fallback to `dyad-write` if search-replace fails. > - Include provider options and versioned files in requests; add `getCurrentCommitHash`/`getFileAtCommit`. > - **UI**: > - Pro mode selector: new `deep` option; tooltips polish. > - Add `DyadCodeSearch` and `DyadCodeSearchResult` components; parser supports new tags. > - **Tests/E2E**: > - New `smart_context_deep` e2e; update snapshots to include `app_id` and deep mode; adjust Playwright timeout. > - Unit tests for versioned codebase context. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e3d3bffabb2bc6caf52103461f9d6f2d5ad39df8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -32,18 +32,16 @@ export function ProModeSelector() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSmartContextChange = (
|
||||
newValue: "off" | "conservative" | "balanced",
|
||||
) => {
|
||||
const handleSmartContextChange = (newValue: "off" | "deep" | "balanced") => {
|
||||
if (newValue === "off") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: false,
|
||||
proSmartContextOption: undefined,
|
||||
});
|
||||
} else if (newValue === "conservative") {
|
||||
} else if (newValue === "deep") {
|
||||
updateSettings({
|
||||
enableProSmartFilesContextMode: true,
|
||||
proSmartContextOption: "conservative",
|
||||
proSmartContextOption: "deep",
|
||||
});
|
||||
} else if (newValue === "balanced") {
|
||||
updateSettings({
|
||||
@@ -90,16 +88,23 @@ export function ProModeSelector() {
|
||||
</div>
|
||||
{!hasProKey && (
|
||||
<div className="text-sm text-center text-muted-foreground">
|
||||
<a
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://dyad.sh/pro#ai",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Unlock Pro modes
|
||||
</a>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://dyad.sh/pro#ai",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Unlock Pro modes
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Visit dyad.sh/pro to unlock Pro features
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -239,33 +244,52 @@ function TurboEditsSelector({
|
||||
className="inline-flex rounded-md border border-input"
|
||||
data-testid="turbo-edits-selector"
|
||||
>
|
||||
<Button
|
||||
variant={currentValue === "off" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("off")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentValue === "v1" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("v1")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Classic
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentValue === "v2" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("v2")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Search & replace
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentValue === "off" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("off")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Disable Turbo Edits</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentValue === "v1" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("v1")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Classic
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Uses a smaller model to complete edits
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentValue === "v2" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("v2")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Search & replace
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Find and replaces specific text blocks
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -278,19 +302,19 @@ function SmartContextSelector({
|
||||
}: {
|
||||
isTogglable: boolean;
|
||||
settings: UserSettings | null;
|
||||
onValueChange: (value: "off" | "conservative" | "balanced") => void;
|
||||
onValueChange: (value: "off" | "balanced" | "deep") => void;
|
||||
}) {
|
||||
// Determine current value based on settings
|
||||
const getCurrentValue = (): "off" | "conservative" | "balanced" => {
|
||||
const getCurrentValue = (): "off" | "conservative" | "balanced" | "deep" => {
|
||||
if (!settings?.enableProSmartFilesContextMode) {
|
||||
return "off";
|
||||
}
|
||||
if (settings?.proSmartContextOption === "deep") {
|
||||
return "deep";
|
||||
}
|
||||
if (settings?.proSmartContextOption === "balanced") {
|
||||
return "balanced";
|
||||
}
|
||||
if (settings?.proSmartContextOption === "conservative") {
|
||||
return "conservative";
|
||||
}
|
||||
// Keep in sync with getModelClient in get_model_client.ts
|
||||
// If enabled but no option set (undefined/falsey), it's balanced
|
||||
return "balanced";
|
||||
@@ -320,33 +344,53 @@ function SmartContextSelector({
|
||||
className="inline-flex rounded-md border border-input"
|
||||
data-testid="smart-context-selector"
|
||||
>
|
||||
<Button
|
||||
variant={currentValue === "off" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("off")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentValue === "conservative" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("conservative")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Conservative
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentValue === "balanced" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("balanced")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Balanced
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentValue === "off" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("off")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-r-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Off
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Disable Smart Context</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentValue === "balanced" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("balanced")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-none border-r border-input h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Balanced
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Selects most relevant files with balanced context size
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentValue === "deep" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onValueChange("deep")}
|
||||
disabled={!isTogglable}
|
||||
className="rounded-l-none h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
Deep
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<b>Experimental:</b> Keeps full conversation history for maximum
|
||||
context and cache-optimized to control costs
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
31
src/components/chat/DyadCodeSearch.tsx
Normal file
31
src/components/chat/DyadCodeSearch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileCode } from "lucide-react";
|
||||
|
||||
interface DyadCodeSearchProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export const DyadCodeSearch: React.FC<DyadCodeSearchProps> = ({
|
||||
children,
|
||||
node: _node,
|
||||
query: queryProp,
|
||||
}) => {
|
||||
const query = queryProp || (typeof children === "string" ? children : "");
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={16} className="text-purple-600" />
|
||||
<div className="text-xs text-purple-600 font-medium">Code Search</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm italic text-gray-600 dark:text-gray-300 mt-2">
|
||||
{query || children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
src/components/chat/DyadCodeSearchResult.tsx
Normal file
123
src/components/chat/DyadCodeSearchResult.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { ChevronDown, ChevronUp, FileCode, FileText } from "lucide-react";
|
||||
|
||||
interface DyadCodeSearchResultProps {
|
||||
node?: any;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DyadCodeSearchResult: React.FC<DyadCodeSearchResultProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Parse file paths from children content
|
||||
const files = useMemo(() => {
|
||||
if (typeof children !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filePaths: string[] = [];
|
||||
const lines = children.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
// Skip empty lines and lines that look like tags
|
||||
if (
|
||||
trimmedLine &&
|
||||
!trimmedLine.startsWith("<") &&
|
||||
!trimmedLine.startsWith(">")
|
||||
) {
|
||||
filePaths.push(trimmedLine);
|
||||
}
|
||||
}
|
||||
|
||||
return filePaths;
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-(--background-lightest) dark:bg-zinc-900 hover:bg-(--background-lighter) rounded-lg px-4 py-2 border border-border my-2 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Top-left label badge */}
|
||||
<div
|
||||
className="absolute top-2 left-2 flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold text-purple-600 bg-white dark:bg-zinc-900"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<FileCode size={16} className="text-purple-600" />
|
||||
<span>Code Search Result</span>
|
||||
</div>
|
||||
|
||||
{/* File count when collapsed */}
|
||||
{files.length > 0 && (
|
||||
<div className="absolute top-2 left-44 flex items-center">
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-zinc-800 text-xs rounded text-gray-600 dark:text-gray-300">
|
||||
Found {files.length} file{files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicator icon */}
|
||||
<div className="absolute top-2 right-2 p-1 text-gray-500">
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</div>
|
||||
|
||||
{/* Main content with smooth transition */}
|
||||
<div
|
||||
className="pt-6 overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
maxHeight: isExpanded ? "1000px" : "0px",
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
marginBottom: isExpanded ? "0" : "-6px",
|
||||
}}
|
||||
>
|
||||
{/* File list when expanded */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{files.map((file, index) => {
|
||||
const filePath = file.trim();
|
||||
const fileName = filePath.split("/").pop() || filePath;
|
||||
const pathPart =
|
||||
filePath.substring(0, filePath.length - fileName.length) ||
|
||||
"";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="px-2 py-1 bg-gray-100 dark:bg-zinc-800 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText
|
||||
size={14}
|
||||
className="text-gray-500 dark:text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{fileName}
|
||||
</div>
|
||||
</div>
|
||||
{pathPart && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 ml-5">
|
||||
{pathPart}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,8 @@ import { DyadMcpToolResult } from "./DyadMcpToolResult";
|
||||
import { DyadWebSearchResult } from "./DyadWebSearchResult";
|
||||
import { DyadWebSearch } from "./DyadWebSearch";
|
||||
import { DyadWebCrawl } from "./DyadWebCrawl";
|
||||
import { DyadCodeSearchResult } from "./DyadCodeSearchResult";
|
||||
import { DyadCodeSearch } from "./DyadCodeSearch";
|
||||
import { DyadRead } from "./DyadRead";
|
||||
import { mapActionToButton } from "./ChatInput";
|
||||
import { SuggestedAction } from "@/lib/schemas";
|
||||
@@ -210,6 +212,8 @@ function parseCustomTags(content: string): ContentPiece[] {
|
||||
"dyad-web-search-result",
|
||||
"dyad-web-search",
|
||||
"dyad-web-crawl",
|
||||
"dyad-code-search-result",
|
||||
"dyad-code-search",
|
||||
"dyad-read",
|
||||
"think",
|
||||
"dyad-command",
|
||||
@@ -332,6 +336,26 @@ function renderCustomTag(
|
||||
{content}
|
||||
</DyadWebCrawl>
|
||||
);
|
||||
case "dyad-code-search":
|
||||
return (
|
||||
<DyadCodeSearch
|
||||
node={{
|
||||
properties: {},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadCodeSearch>
|
||||
);
|
||||
case "dyad-code-search-result":
|
||||
return (
|
||||
<DyadCodeSearchResult
|
||||
node={{
|
||||
properties: {},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadCodeSearchResult>
|
||||
);
|
||||
case "dyad-web-search-result":
|
||||
return (
|
||||
<DyadWebSearchResult
|
||||
|
||||
Reference in New Issue
Block a user