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 {
AgentTodo,
ComponentSelection,
} from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import type { Getter, Setter } from "jotai";
import { atom } from "jotai";
......@@ -17,6 +18,7 @@ export const selectedChatIdAtom = atom<number | null>(null);
export const isStreamingByIdAtom = atom<Map<number, boolean>>(new Map());
export const chatInputValueAtom = 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)
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
......
......@@ -15,6 +15,7 @@ type AppSearchDialogProps = {
onOpenChange: (open: boolean) => void;
onSelectApp: (appId: number) => void;
allApps: AppSearchResult[];
disableShortcut?: boolean;
};
export function AppSearchDialog({
......@@ -22,6 +23,7 @@ export function AppSearchDialog({
onOpenChange,
onSelectApp,
allApps,
disableShortcut,
}: AppSearchDialogProps) {
const [searchQuery, setSearchQuery] = useState<string>("");
function useDebouncedValue<T>(value: T, delay: number): T {
......@@ -87,6 +89,7 @@ export function AppSearchDialog({
}
useEffect(() => {
if (disableShortcut) return;
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
......@@ -95,7 +98,7 @@ export function AppSearchDialog({
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [open, onOpenChange]);
}, [open, onOpenChange, disableShortcut]);
return (
<CommandDialog
......
import { SendHorizontalIcon, StopCircleIcon } from "lucide-react";
import {
SendHorizontalIcon,
StopCircleIcon,
FolderOpenIcon,
XIcon,
} from "lucide-react";
import {
Tooltip,
TooltipTrigger,
......@@ -6,8 +11,9 @@ import {
} from "@/components/ui/tooltip";
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 { useState } from "react";
import { useStreamChat } from "@/hooks/useStreamChat";
import { useAttachments } from "@/hooks/useAttachments";
import { AttachmentsList } from "./AttachmentsList";
......@@ -21,6 +27,8 @@ import { useChatModeToggle } from "@/hooks/useChatModeToggle";
import { useTypingPlaceholder } from "@/hooks/useTypingPlaceholder";
import { AuxiliaryActionsMenu } from "./AuxiliaryActionsMenu";
import { cn } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps";
import { AppSearchDialog } from "../AppSearchDialog";
export function HomeChatInput({
onSubmit,
......@@ -29,18 +37,24 @@ export function HomeChatInput({
}) {
const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const [selectedApp, setSelectedApp] = useAtom(homeSelectedAppAtom);
const { settings } = useSettings();
const { isStreaming } = useStreamChat({
hasChatId: false,
}); // eslint-disable-line @typescript-eslint/no-unused-vars
useChatModeToggle();
const [appSearchOpen, setAppSearchOpen] = useState(false);
const { apps } = useLoadApps();
const typingText = useTypingPlaceholder([
"an ecommerce store...",
"an information 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
const {
......@@ -58,6 +72,14 @@ export function HomeChatInput({
cancelPendingFiles,
} = 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
const handleCustomSubmit = () => {
if (
......@@ -68,13 +90,18 @@ export function HomeChatInput({
return;
}
// Call the parent's onSubmit handler with attachments
onSubmit({ attachments });
// Call the parent's onSubmit handler with attachments and selected app
onSubmit({
attachments,
selectedApp: selectedApp ?? undefined,
});
// Clear attachments as part of submission process
// Clear attachments and selected app as part of submission process
clearAttachments();
setSelectedApp(null);
posthog.capture("chat:home_submit", {
chatMode: settings?.selectedChatMode,
existingApp: !!selectedApp,
});
};
......@@ -162,6 +189,46 @@ export function HomeChatInput({
<div className="px-2 flex items-center justify-between pb-0.5 pt-0.5">
<div className="flex items-center">
<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>
<AuxiliaryActionsMenu
......@@ -171,6 +238,22 @@ export function HomeChatInput({
</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 @@
"moreIdeas": "More ideas",
"buildMeA": "Build me a {{label}}",
"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}}?",
"releaseNotesTitle": "Release notes for v{{version}}",
"importApp": "Import App",
......
......@@ -34,6 +34,7 @@ import { ForceCloseDialog } from "@/components/ForceCloseDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
import type { FileAttachment } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import { NEON_TEMPLATE_IDS } from "@/shared/templates";
import { neonTemplateHook } from "@/client_logic/template_hook";
import {
......@@ -47,6 +48,7 @@ import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
// Adding an export for attachments
export interface HomeSubmitOptions {
attachments?: FileAttachment[];
selectedApp?: ListedApp;
}
export default function HomePage() {
......@@ -61,6 +63,7 @@ export default function HomePage() {
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { selectChat } = useSelectChat();
const [isLoading, setIsLoading] = useState(false);
const [loadingMode, setLoadingMode] = useState<"new" | "existing">("new");
const [forceCloseDialogOpen, setForceCloseDialogOpen] = useState(false);
const [performanceData, setPerformanceData] = useState<any>(undefined);
const { streamMessage } = useStreamChat({ hasChatId: false });
......@@ -163,15 +166,29 @@ export default function HomePage() {
const handleSubmit = async (options?: HomeSubmitOptions) => {
const attachments = options?.attachments || [];
const selectedApp = options?.selectedApp;
if (!inputValue.trim() && attachments.length === 0) return;
try {
setLoadingMode(selectedApp ? "existing" : "new");
setIsLoading(true);
// Create the chat and navigate
let chatId: number;
let appId: number;
if (selectedApp) {
// 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)
......@@ -189,11 +206,12 @@ export default function HomePage() {
themeId: settings.selectedThemeId || null,
});
}
}
// Stream the message with attachments
streamMessage({
prompt: inputValue,
chatId: result.chatId,
chatId,
attachments,
});
await new Promise((resolve) =>
......@@ -202,19 +220,22 @@ export default function HomePage() {
setInputValue("");
setIsPreviewOpen(false);
await refreshApps(); // Ensure refreshApps is awaited if it's async
await invalidateAppQuery(queryClient, { appId: result.app.id });
await refreshApps();
await invalidateAppQuery(queryClient, { appId });
// Invalidate chats so ChatTabs picks up the new chat immediately.
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.
selectChat({ chatId: result.chatId, appId: result.app.id });
selectChat({ chatId, appId });
} catch (error) {
console.error("Failed to create chat:", error);
showError(t("failedCreateApp", { error: (error as any).toString() }));
setIsLoading(false); // Ensure loading state is reset on error
showError(
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
......@@ -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>
<h2 className="text-2xl font-bold mb-2 text-gray-800 dark:text-gray-200">
{t("buildingApp")}
{loadingMode === "existing" ? t("startingChat") : t("buildingApp")}
</h2>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-8">
{loadingMode === "existing" ? (
t("creatingNewChat")
) : (
<>
{t("settingUp")} <br />
{t("mightTakeMoment")}
</>
)}
</p>
</div>
</div>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论