Unverified 提交 c4cdf7b3 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Allow selecting an app in home chat input (#2832)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2832" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 ab1542e0
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("home chat - start new chat in existing app", async ({ po }) => {
await po.setUp({ autoApprove: true });
// Create an app first
await po.sendPrompt("create a todo application");
// Go back to home page
await po.navigation.goToAppsTab();
await expect(po.chatActions.getHomeChatInputContainer()).toBeVisible();
// Click the app selector button in the home chat input
const appSelector = po.page.getByTestId("home-app-selector");
await appSelector.click();
// Wait for the search dialog and select the first app
await po.page.getByTestId("app-search-dialog").waitFor({ state: "visible" });
const firstApp = po.page.getByTestId(/^app-search-item-/).first();
await expect(firstApp).toBeVisible();
const appName = await firstApp.textContent();
await firstApp.click();
// Dialog should close after selection
await po.page
.getByTestId("app-search-dialog")
.waitFor({ state: "hidden", timeout: 5000 });
// The app selector should now show the selected app name
await expect(appSelector).toContainText(appName!.trim());
// The clear button should be visible
await expect(po.page.getByTestId("home-app-selector-clear")).toBeVisible();
// Type a message and send it to the existing app
const chatInput = po.page.locator('[data-lexical-editor="true"]');
await chatInput.click();
await chatInput.fill("add a new feature");
await po.page.getByRole("button", { name: "Send message" }).click();
// Should navigate to the app's chat page
await po.chatActions.waitForChatCompletion();
// Verify we're in the app's chat — the title bar should show the app name
const currentAppName = await po.appManagement.getCurrentAppName();
expect(currentAppName).toBeTruthy();
});
test("home chat - clear selected app", async ({ po }) => {
await po.setUp({ autoApprove: true });
// Create an app first
await po.sendPrompt("create a todo application");
// Go back to home page
await po.navigation.goToAppsTab();
await expect(po.chatActions.getHomeChatInputContainer()).toBeVisible();
// Select an app via the app selector
const appSelector = po.page.getByTestId("home-app-selector");
await appSelector.click();
await po.page.getByTestId("app-search-dialog").waitFor({ state: "visible" });
await po.page
.getByTestId(/^app-search-item-/)
.first()
.click();
await po.page
.getByTestId("app-search-dialog")
.waitFor({ state: "hidden", timeout: 5000 });
// The app selector should show the selected app
await expect(appSelector).not.toContainText("No app selected");
// Click the clear button to deselect the app
await po.page.getByTestId("home-app-selector-clear").click();
// The app selector should now show "No app selected"
await expect(appSelector).toContainText("No app selected");
// The clear button should no longer be visible
await expect(po.page.getByTestId("home-app-selector-clear")).toBeHidden();
});
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
AgentTodo, AgentTodo,
ComponentSelection, ComponentSelection,
} from "@/ipc/types"; } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import type { Getter, Setter } from "jotai"; import type { Getter, Setter } from "jotai";
import { atom } from "jotai"; import { atom } from "jotai";
...@@ -17,6 +18,7 @@ export const selectedChatIdAtom = atom<number | null>(null); ...@@ -17,6 +18,7 @@ export const selectedChatIdAtom = atom<number | null>(null);
export const isStreamingByIdAtom = atom<Map<number, boolean>>(new Map()); export const isStreamingByIdAtom = atom<Map<number, boolean>>(new Map());
export const chatInputValueAtom = atom<string>(""); export const chatInputValueAtom = atom<string>("");
export const homeChatInputValueAtom = atom<string>(""); export const homeChatInputValueAtom = atom<string>("");
export const homeSelectedAppAtom = atom<ListedApp | null>(null);
// Used for scrolling to the bottom of the chat messages (per chat) // Used for scrolling to the bottom of the chat messages (per chat)
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map()); export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
......
...@@ -15,6 +15,7 @@ type AppSearchDialogProps = { ...@@ -15,6 +15,7 @@ type AppSearchDialogProps = {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSelectApp: (appId: number) => void; onSelectApp: (appId: number) => void;
allApps: AppSearchResult[]; allApps: AppSearchResult[];
disableShortcut?: boolean;
}; };
export function AppSearchDialog({ export function AppSearchDialog({
...@@ -22,6 +23,7 @@ export function AppSearchDialog({ ...@@ -22,6 +23,7 @@ export function AppSearchDialog({
onOpenChange, onOpenChange,
onSelectApp, onSelectApp,
allApps, allApps,
disableShortcut,
}: AppSearchDialogProps) { }: AppSearchDialogProps) {
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
function useDebouncedValue<T>(value: T, delay: number): T { function useDebouncedValue<T>(value: T, delay: number): T {
...@@ -87,6 +89,7 @@ export function AppSearchDialog({ ...@@ -87,6 +89,7 @@ export function AppSearchDialog({
} }
useEffect(() => { useEffect(() => {
if (disableShortcut) return;
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
...@@ -95,7 +98,7 @@ export function AppSearchDialog({ ...@@ -95,7 +98,7 @@ export function AppSearchDialog({
}; };
document.addEventListener("keydown", down); document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down); return () => document.removeEventListener("keydown", down);
}, [open, onOpenChange]); }, [open, onOpenChange, disableShortcut]);
return ( return (
<CommandDialog <CommandDialog
......
import { SendHorizontalIcon, StopCircleIcon } from "lucide-react"; import {
SendHorizontalIcon,
StopCircleIcon,
FolderOpenIcon,
XIcon,
} from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
...@@ -6,8 +11,9 @@ import { ...@@ -6,8 +11,9 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { homeChatInputValueAtom } from "@/atoms/chatAtoms"; // Use a different atom for home input import { homeChatInputValueAtom, homeSelectedAppAtom } from "@/atoms/chatAtoms";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useState } from "react";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { useAttachments } from "@/hooks/useAttachments"; import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList"; import { AttachmentsList } from "./AttachmentsList";
...@@ -21,6 +27,8 @@ import { useChatModeToggle } from "@/hooks/useChatModeToggle"; ...@@ -21,6 +27,8 @@ import { useChatModeToggle } from "@/hooks/useChatModeToggle";
import { useTypingPlaceholder } from "@/hooks/useTypingPlaceholder"; import { useTypingPlaceholder } from "@/hooks/useTypingPlaceholder";
import { AuxiliaryActionsMenu } from "./AuxiliaryActionsMenu"; import { AuxiliaryActionsMenu } from "./AuxiliaryActionsMenu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps";
import { AppSearchDialog } from "../AppSearchDialog";
export function HomeChatInput({ export function HomeChatInput({
onSubmit, onSubmit,
...@@ -29,18 +37,24 @@ export function HomeChatInput({ ...@@ -29,18 +37,24 @@ export function HomeChatInput({
}) { }) {
const posthog = usePostHog(); const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom); const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const [selectedApp, setSelectedApp] = useAtom(homeSelectedAppAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const { isStreaming } = useStreamChat({ const { isStreaming } = useStreamChat({
hasChatId: false, hasChatId: false,
}); // eslint-disable-line @typescript-eslint/no-unused-vars }); // eslint-disable-line @typescript-eslint/no-unused-vars
useChatModeToggle(); useChatModeToggle();
const [appSearchOpen, setAppSearchOpen] = useState(false);
const { apps } = useLoadApps();
const typingText = useTypingPlaceholder([ const typingText = useTypingPlaceholder([
"an ecommerce store...", "an ecommerce store...",
"an information page...", "an information page...",
"a landing page...", "a landing page...",
]); ]);
const placeholder = `Ask Dyad to build ${typingText ?? ""}`; const placeholder = selectedApp
? `Send a message to ${selectedApp.name}...`
: `Ask Dyad to build ${typingText ?? ""}`;
// Use the attachments hook // Use the attachments hook
const { const {
...@@ -58,6 +72,14 @@ export function HomeChatInput({ ...@@ -58,6 +72,14 @@ export function HomeChatInput({
cancelPendingFiles, cancelPendingFiles,
} = useAttachments(); } = useAttachments();
const handleSelectApp = (appId: number) => {
const app = apps.find((a) => a.id === appId);
if (app) {
setSelectedApp(app);
}
setAppSearchOpen(false);
};
// Custom submit function that wraps the provided onSubmit // Custom submit function that wraps the provided onSubmit
const handleCustomSubmit = () => { const handleCustomSubmit = () => {
if ( if (
...@@ -68,13 +90,18 @@ export function HomeChatInput({ ...@@ -68,13 +90,18 @@ export function HomeChatInput({
return; return;
} }
// Call the parent's onSubmit handler with attachments // Call the parent's onSubmit handler with attachments and selected app
onSubmit({ attachments }); onSubmit({
attachments,
selectedApp: selectedApp ?? undefined,
});
// Clear attachments as part of submission process // Clear attachments and selected app as part of submission process
clearAttachments(); clearAttachments();
setSelectedApp(null);
posthog.capture("chat:home_submit", { posthog.capture("chat:home_submit", {
chatMode: settings?.selectedChatMode, chatMode: settings?.selectedChatMode,
existingApp: !!selectedApp,
}); });
}; };
...@@ -162,6 +189,46 @@ export function HomeChatInput({ ...@@ -162,6 +189,46 @@ export function HomeChatInput({
<div className="px-2 flex items-center justify-between pb-0.5 pt-0.5"> <div className="px-2 flex items-center justify-between pb-0.5 pt-0.5">
<div className="flex items-center"> <div className="flex items-center">
<ChatInputControls showContextFilesPicker={false} /> <ChatInputControls showContextFilesPicker={false} />
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setAppSearchOpen(true)}
className={cn(
"cursor-pointer px-2 py-1 ml-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1",
selectedApp
? "bg-primary/10 text-primary hover:bg-primary/15"
: "text-foreground/80 hover:text-foreground hover:bg-muted/60",
)}
data-testid="home-app-selector"
/>
}
>
<FolderOpenIcon size={14} />
<span className="truncate max-w-[150px]">
{selectedApp ? selectedApp.name : "No app selected"}
</span>
{selectedApp && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedApp(null);
}}
className="hover:bg-primary/20 rounded-sm p-0.5 transition-colors"
aria-label="Deselect app"
data-testid="home-app-selector-clear"
>
<XIcon size={12} />
</button>
)}
</TooltipTrigger>
<TooltipContent>
{selectedApp
? "Change selected app"
: "Select an existing app"}
</TooltipContent>
</Tooltip>
</div> </div>
<AuxiliaryActionsMenu <AuxiliaryActionsMenu
...@@ -171,6 +238,22 @@ export function HomeChatInput({ ...@@ -171,6 +238,22 @@ export function HomeChatInput({
</div> </div>
</div> </div>
</div> </div>
{appSearchOpen && (
<AppSearchDialog
open={appSearchOpen}
onOpenChange={setAppSearchOpen}
onSelectApp={handleSelectApp}
disableShortcut
allApps={apps.map((a) => ({
id: a.id,
name: a.name,
createdAt: a.createdAt,
matchedChatTitle: null,
matchedChatMessage: null,
}))}
/>
)}
</> </>
); );
} }
...@@ -5,6 +5,9 @@ ...@@ -5,6 +5,9 @@
"moreIdeas": "More ideas", "moreIdeas": "More ideas",
"buildMeA": "Build me a {{label}}", "buildMeA": "Build me a {{label}}",
"failedCreateApp": "Failed to create app. {{error}}", "failedCreateApp": "Failed to create app. {{error}}",
"failedCreateChat": "Failed to create chat. {{error}}",
"startingChat": "Starting new chat",
"creatingNewChat": "Creating a new chat in your existing app.",
"whatsNew": "What's new in v{{version}}?", "whatsNew": "What's new in v{{version}}?",
"releaseNotesTitle": "Release notes for v{{version}}", "releaseNotesTitle": "Release notes for v{{version}}",
"importApp": "Import App", "importApp": "Import App",
......
...@@ -34,6 +34,7 @@ import { ForceCloseDialog } from "@/components/ForceCloseDialog"; ...@@ -34,6 +34,7 @@ import { ForceCloseDialog } from "@/components/ForceCloseDialog";
import { useSelectChat } from "@/hooks/useSelectChat"; import { useSelectChat } from "@/hooks/useSelectChat";
import type { FileAttachment } from "@/ipc/types"; import type { FileAttachment } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import { NEON_TEMPLATE_IDS } from "@/shared/templates"; import { NEON_TEMPLATE_IDS } from "@/shared/templates";
import { neonTemplateHook } from "@/client_logic/template_hook"; import { neonTemplateHook } from "@/client_logic/template_hook";
import { import {
...@@ -47,6 +48,7 @@ import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; ...@@ -47,6 +48,7 @@ import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
// Adding an export for attachments // Adding an export for attachments
export interface HomeSubmitOptions { export interface HomeSubmitOptions {
attachments?: FileAttachment[]; attachments?: FileAttachment[];
selectedApp?: ListedApp;
} }
export default function HomePage() { export default function HomePage() {
...@@ -61,6 +63,7 @@ export default function HomePage() { ...@@ -61,6 +63,7 @@ export default function HomePage() {
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { selectChat } = useSelectChat(); const { selectChat } = useSelectChat();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadingMode, setLoadingMode] = useState<"new" | "existing">("new");
const [forceCloseDialogOpen, setForceCloseDialogOpen] = useState(false); const [forceCloseDialogOpen, setForceCloseDialogOpen] = useState(false);
const [performanceData, setPerformanceData] = useState<any>(undefined); const [performanceData, setPerformanceData] = useState<any>(undefined);
const { streamMessage } = useStreamChat({ hasChatId: false }); const { streamMessage } = useStreamChat({ hasChatId: false });
...@@ -163,37 +166,52 @@ export default function HomePage() { ...@@ -163,37 +166,52 @@ export default function HomePage() {
const handleSubmit = async (options?: HomeSubmitOptions) => { const handleSubmit = async (options?: HomeSubmitOptions) => {
const attachments = options?.attachments || []; const attachments = options?.attachments || [];
const selectedApp = options?.selectedApp;
if (!inputValue.trim() && attachments.length === 0) return; if (!inputValue.trim() && attachments.length === 0) return;
try { try {
setLoadingMode(selectedApp ? "existing" : "new");
setIsLoading(true); setIsLoading(true);
// Create the chat and navigate
const result = await ipc.app.createApp({
name: generateCuteAppName(),
});
if (
settings?.selectedTemplateId &&
NEON_TEMPLATE_IDS.has(settings.selectedTemplateId)
) {
await neonTemplateHook({
appId: result.app.id,
appName: result.app.name,
});
}
// Apply selected theme to the new app (if one is set) let chatId: number;
if (settings?.selectedThemeId) { let appId: number;
await ipc.template.setAppTheme({
appId: result.app.id, if (selectedApp) {
themeId: settings.selectedThemeId || null, // Existing app flow: create a new chat in the selected app
chatId = await ipc.chat.createChat(selectedApp.id);
appId = selectedApp.id;
} else {
// New app flow (default behavior)
const result = await ipc.app.createApp({
name: generateCuteAppName(),
}); });
chatId = result.chatId;
appId = result.app.id;
if (
settings?.selectedTemplateId &&
NEON_TEMPLATE_IDS.has(settings.selectedTemplateId)
) {
await neonTemplateHook({
appId: result.app.id,
appName: result.app.name,
});
}
// Apply selected theme to the new app (if one is set)
if (settings?.selectedThemeId) {
await ipc.template.setAppTheme({
appId: result.app.id,
themeId: settings.selectedThemeId || null,
});
}
} }
// Stream the message with attachments // Stream the message with attachments
streamMessage({ streamMessage({
prompt: inputValue, prompt: inputValue,
chatId: result.chatId, chatId,
attachments, attachments,
}); });
await new Promise((resolve) => await new Promise((resolve) =>
...@@ -202,19 +220,22 @@ export default function HomePage() { ...@@ -202,19 +220,22 @@ export default function HomePage() {
setInputValue(""); setInputValue("");
setIsPreviewOpen(false); setIsPreviewOpen(false);
await refreshApps(); // Ensure refreshApps is awaited if it's async await refreshApps();
await invalidateAppQuery(queryClient, { appId: result.app.id }); await invalidateAppQuery(queryClient, { appId });
// Invalidate chats so ChatTabs picks up the new chat immediately. // Invalidate chats so ChatTabs picks up the new chat immediately.
await queryClient.invalidateQueries({ queryKey: queryKeys.chats.all }); await queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
posthog.capture("home:chat-submit"); posthog.capture("home:chat-submit", { existingApp: !!selectedApp });
// Select newly created first chat so it appears first in tabs. // Select newly created first chat so it appears first in tabs.
selectChat({ chatId: result.chatId, appId: result.app.id }); selectChat({ chatId, appId });
} catch (error) { } catch (error) {
console.error("Failed to create chat:", error); console.error("Failed to create chat:", error);
showError(t("failedCreateApp", { error: (error as any).toString() })); showError(
setIsLoading(false); // Ensure loading state is reset on error t(selectedApp ? "failedCreateChat" : "failedCreateApp", {
error: (error as any).toString(),
}),
);
setIsLoading(false);
} }
// No finally block needed for setIsLoading(false) here if navigation happens on success
}; };
// Loading overlay for app creation // Loading overlay for app creation
...@@ -228,11 +249,17 @@ export default function HomePage() { ...@@ -228,11 +249,17 @@ export default function HomePage() {
<div className="absolute top-0 left-0 w-full h-full border-8 border-t-primary rounded-full animate-spin"></div> <div className="absolute top-0 left-0 w-full h-full border-8 border-t-primary rounded-full animate-spin"></div>
</div> </div>
<h2 className="text-2xl font-bold mb-2 text-gray-800 dark:text-gray-200"> <h2 className="text-2xl font-bold mb-2 text-gray-800 dark:text-gray-200">
{t("buildingApp")} {loadingMode === "existing" ? t("startingChat") : t("buildingApp")}
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-8"> <p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-8">
{t("settingUp")} <br /> {loadingMode === "existing" ? (
{t("mightTakeMoment")} t("creatingNewChat")
) : (
<>
{t("settingUp")} <br />
{t("mightTakeMoment")}
</>
)}
</p> </p>
</div> </div>
</div> </div>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论