Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/atoms/chatAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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());
Expand Down
99 changes: 93 additions & 6 deletions src/components/chat/HomeChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { SendHorizontalIcon, StopCircleIcon } from "lucide-react";
import {
SendHorizontalIcon,
StopCircleIcon,
FolderOpenIcon,
XIcon,
} from "lucide-react";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} 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";
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM | unnecessary complexity

handleSelectApp does a redundant ID round-trip through apps array

AppSearchDialog calls onSelectApp(appId) with just the numeric ID, then handleSelectApp immediately does apps.find(a => a.id === appId) to recover the full app object. The dialog already has the full AppSearchResult (id, name, createdAt) available at the call site.

This pattern has two issues:

  1. If apps hasn't finished loading or is stale, find() returns undefined and the selection is silently dropped — no feedback to the user
  2. It requires HomeChatInput to call useLoadApps() solely to support this lookup, creating an unnecessary dependency

Found by 2/3 reviewers (Correctness Expert, Code Health Expert).

💡 Suggestion: Change onSelectApp to accept the full app object (or { id, name }) instead of just the ID. This eliminates the lookup and the need for useLoadApps() in this component.


// Custom submit function that wraps the provided onSubmit
const handleCustomSubmit = () => {
if (
Expand All @@ -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);
Comment on lines 100 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain selected app until submit succeeds

handleCustomSubmit clears homeSelectedAppAtom immediately, before the async submit flow in HomePage.handleSubmit completes. When ipc.chat.createChat(selectedApp.id) fails (for example, if the selected app was removed or IPC returns an error), the catch path in src/pages/home.tsx leaves the user on the home screen with their prompt still present but no selected app, so a retry silently falls back to creating a new app instead of targeting the originally chosen one.

Useful? React with 👍 / 👎.

posthog.capture("chat:home_submit", {
chatMode: settings?.selectedChatMode,
existingApp: !!selectedApp,
});
};

Expand Down Expand Up @@ -162,6 +189,53 @@ 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 && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSelectedApp(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
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} />
</span>
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH | accessibility / interaction

Nested <button> elements are invalid HTML

The clear (X) <button> on line 212 is nested inside the outer <button> rendered by TooltipTrigger (line 193). Nested interactive elements are invalid per the HTML spec and cause:

  • Unpredictable click handling: Clicking the X may fire both the inner click (clear) and the outer click (open dialog) simultaneously in some browsers
  • Broken keyboard navigation: Screen readers and keyboard users cannot reliably interact with the inner button
  • DOM parsing issues: Browsers may restructure the DOM in unexpected ways when encountering nested buttons

Found by 2/3 reviewers (Code Health Expert, UX Wizard).

💡 Suggestion: Restructure so the clear button is a sibling of the selector button, not a child. For example, wrap both in a flex <div> — the tooltip wraps just the selector button, and the X button sits alongside it:

<div className="flex items-center gap-1">
  <Tooltip>
    <TooltipTrigger render={<button ... />}>
      <FolderOpenIcon size={14} />
      <span className="truncate max-w-[150px]">{...}</span>
    </TooltipTrigger>
    <TooltipContent>{...}</TooltipContent>
  </Tooltip>
  {selectedApp && (
    <button type="button" onClick={() => setSelectedApp(null)} ...>
      <XIcon size={12} />
    </button>
  )}
</div>

</TooltipTrigger>
Comment on lines +193 to +225
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested <button> inside <button> — invalid HTML

The clear/deselect button (data-testid="home-app-selector-clear") is a child of <TooltipTrigger render={<button .../>}>. Because @base-ui/react's render prop uses the provided element as the container and injects TooltipTrigger's children into it, the rendered DOM ends up as:

<button data-testid="home-app-selector">   <!-- outer trigger -->
  ...
  <button data-testid="home-app-selector-clear">   <!-- NESTED – invalid HTML -->
    <XIcon />
  </button>
</button>

Nesting interactive elements (<button> inside <button>) is explicitly forbidden by the HTML spec. Browsers handle it inconsistently: some silently break the nesting and move the inner button outside, which means e.stopPropagation() in the clear handler may not behave as expected everywhere.

Fix: Lift the clear button out of TooltipTrigger and position it alongside (not inside) the trigger, e.g. by making the whole row a flex container with the TooltipTrigger button and the clear <button> as siblings. This preserves the same visual layout while producing valid HTML:

<div className="flex items-center gap-1">
  <Tooltip>
    <TooltipTrigger render={<button onClick={() => setAppSearchOpen(true)} ... />}>
      <FolderOpenIcon size={14} />
      <span className="truncate max-w-[150px]">
        {selectedApp ? selectedApp.name : "No app selected"}
      </span>
    </TooltipTrigger>
    <TooltipContent>...</TooltipContent>
  </Tooltip>
  {selectedApp && (
    <button
      type="button"
      onClick={() => setSelectedApp(null)}
      ...
      data-testid="home-app-selector-clear"
    >
      <XIcon size={12} />
    </button>
  )}
</div>
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/chat/HomeChatInput.tsx
Line: 193-225

Comment:
**Nested `<button>` inside `<button>` — invalid HTML**

The clear/deselect button (`data-testid="home-app-selector-clear"`) is a child of `<TooltipTrigger render={<button .../>}>`. Because `@base-ui/react`'s `render` prop uses the provided element as the container and injects `TooltipTrigger`'s children into it, the rendered DOM ends up as:

```html
<button data-testid="home-app-selector">   <!-- outer trigger -->
  ...
  <button data-testid="home-app-selector-clear">   <!-- NESTED – invalid HTML -->
    <XIcon />
  </button>
</button>
```

Nesting interactive elements (`<button>` inside `<button>`) is explicitly forbidden by the HTML spec. Browsers handle it inconsistently: some silently break the nesting and move the inner button outside, which means `e.stopPropagation()` in the clear handler may not behave as expected everywhere.

**Fix**: Lift the clear button out of `TooltipTrigger` and position it alongside (not inside) the trigger, e.g. by making the whole row a `flex` container with the `TooltipTrigger` button and the clear `<button>` as siblings. This preserves the same visual layout while producing valid HTML:

```jsx
<div className="flex items-center gap-1">
  <Tooltip>
    <TooltipTrigger render={<button onClick={() => setAppSearchOpen(true)} ... />}>
      <FolderOpenIcon size={14} />
      <span className="truncate max-w-[150px]">
        {selectedApp ? selectedApp.name : "No app selected"}
      </span>
    </TooltipTrigger>
    <TooltipContent>...</TooltipContent>
  </Tooltip>
  {selectedApp && (
    <button
      type="button"
      onClick={() => setSelectedApp(null)}
      ...
      data-testid="home-app-selector-clear"
    >
      <XIcon size={12} />
    </button>
  )}
</div>
```

How can I resolve this? If you propose a fix, please make it concise.

<TooltipContent>
{selectedApp
? "Change selected app"
: "Select an existing app"}
</TooltipContent>
</Tooltip>
</div>

<AuxiliaryActionsMenu
Expand All @@ -171,6 +245,19 @@ export function HomeChatInput({
</div>
</div>
</div>

<AppSearchDialog
open={appSearchOpen}
onOpenChange={setAppSearchOpen}
onSelectApp={handleSelectApp}
allApps={apps.map((a) => ({
id: a.id,
name: a.name,
createdAt: a.createdAt,
matchedChatTitle: null,
matchedChatMessage: null,
}))}
/>
</>
);
}
2 changes: 2 additions & 0 deletions src/i18n/locales/en/home.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"moreIdeas": "More ideas",
"buildMeA": "Build me a {{label}}",
"failedCreateApp": "Failed to create app. {{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",
Expand Down
79 changes: 51 additions & 28 deletions src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand All @@ -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 });
Expand Down Expand Up @@ -163,37 +166,52 @@ 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
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)
if (settings?.selectedThemeId) {
await ipc.template.setAppTheme({
appId: result.app.id,
themeId: settings.selectedThemeId || null,
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)
) {
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
streamMessage({
prompt: inputValue,
chatId: result.chatId,
chatId,
attachments,
});
await new Promise((resolve) =>
Expand All @@ -202,19 +220,18 @@ 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
setIsLoading(false);
}
// No finally block needed for setIsLoading(false) here if navigation happens on success
};

// Loading overlay for app creation
Expand All @@ -228,11 +245,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">
{t("settingUp")} <br />
{t("mightTakeMoment")}
{loadingMode === "existing" ? (
t("creatingNewChat")
) : (
<>
{t("settingUp")} <br />
{t("mightTakeMoment")}
</>
)}
</p>
</div>
</div>
Expand Down
Loading