feat(conversations): allow selecting a saved prompt in the create conversation modal#2750
Conversation
…versation modal Adds a Prompt select to the add-conversation modal, listing prompts from the prompt library. The selected prompt's text is passed as initialPrompt, which is already wired through both the PTY and ACP creation paths.
Greptile SummaryThis PR adds a Prompt select field to the create-conversation modal, allowing users to pick a pre-saved prompt from the prompt library when starting a conversation. The selected prompt's text is forwarded as
Confidence Score: 4/5The change is a self-contained UI addition to one modal file; the initialPrompt plumbing it relies on is already tested in both runtime paths. The two findings are about render efficiency and a minor layout shift when the prompt library loads asynchronously. Neither affects data correctness or the create flow itself. Only create-conversation-modal.tsx changed; the memoization and async-load presentation concerns are both in that one file.
|
| Filename | Overview |
|---|---|
| apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx | Adds Prompt select UI and wires selectedPrompt into createConversation; implementation is correct but savedPrompts is recomputed on every render without memoization, and the prompt field can flash in after the async load completes. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Modal Opens] --> B[usePromptLibrary fetches library]
B --> C{Data loaded?}
C -- No --> D[savedPrompts empty - Prompt field hidden]
C -- Yes --> E[Filter out prompts with empty text]
E --> F{savedPrompts non-empty?}
F -- No --> G[Prompt field hidden]
F -- Yes --> H[Prompt Select rendered with No prompt default]
H --> I{User selects a prompt?}
I -- No --> J[selectedPromptId = null]
I -- Yes --> K[selectedPromptId = id, preview text shown]
J --> L[handleCreateConversation - initialPrompt = undefined]
K --> M[handleCreateConversation - initialPrompt = prompt text]
L --> N[createConversation called]
M --> N
N --> O{ConversationType}
O -- pty --> P[PTY injects initialPrompt on session start]
O -- acp --> Q[ACP stores in config, consumed in hydrateConversation]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Modal Opens] --> B[usePromptLibrary fetches library]
B --> C{Data loaded?}
C -- No --> D[savedPrompts empty - Prompt field hidden]
C -- Yes --> E[Filter out prompts with empty text]
E --> F{savedPrompts non-empty?}
F -- No --> G[Prompt field hidden]
F -- Yes --> H[Prompt Select rendered with No prompt default]
H --> I{User selects a prompt?}
I -- No --> J[selectedPromptId = null]
I -- Yes --> K[selectedPromptId = id, preview text shown]
J --> L[handleCreateConversation - initialPrompt = undefined]
K --> M[handleCreateConversation - initialPrompt = prompt text]
L --> N[createConversation called]
M --> N
N --> O{ConversationType}
O -- pty --> P[PTY injects initialPrompt on session start]
O -- acp --> Q[ACP stores in config, consumed in hydrateConversation]
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx:53-55
`savedPrompts` is recomputed from a `.filter()` call on every render, which also forces `selectedPrompt` (a `.find()` on the filtered array) to be re-evaluated on every render. Because `selectedPrompt` is in the `useCallback` dependency array, the callback is also recreated on every render while the component is mounted, even when neither the library data nor the selected ID has changed. Wrapping both derived values in `useMemo` keeps them stable between renders and avoids the unnecessary recreations.
```suggestion
const { value: promptLibrary } = usePromptLibrary();
const savedPrompts = useMemo(
() => promptLibrary.filter((p) => p.prompt.trim().length > 0),
[promptLibrary]
);
const selectedPrompt = useMemo(
() => savedPrompts.find((p) => p.id === selectedPromptId) ?? null,
[savedPrompts, selectedPromptId]
);
```
### Issue 2 of 2
apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx:163-190
**Prompt field flashes in after async load**
`usePromptLibrary` starts with `data = undefined`, so `value` falls back to `[]` and `savedPrompts.length > 0` is `false` — the Prompt field is hidden on the initial render. Once the React Query fetch resolves, if the library is non-empty, the field appears and the form visually grows. This is the same pattern used by the Model field (it too is hidden until `useAgents` data arrives), so the precedent is already set. It's worth noting in case you want to add a loading placeholder or a `Skeleton` matching the field height to prevent the layout shift.
Reviews (1): Last reviewed commit: "feat(conversations): allow selecting a s..." | Re-trigger Greptile
| const { value: promptLibrary } = usePromptLibrary(); | ||
| const savedPrompts = promptLibrary.filter((p) => p.prompt.trim().length > 0); | ||
| const selectedPrompt = savedPrompts.find((p) => p.id === selectedPromptId) ?? null; |
There was a problem hiding this comment.
savedPrompts is recomputed from a .filter() call on every render, which also forces selectedPrompt (a .find() on the filtered array) to be re-evaluated on every render. Because selectedPrompt is in the useCallback dependency array, the callback is also recreated on every render while the component is mounted, even when neither the library data nor the selected ID has changed. Wrapping both derived values in useMemo keeps them stable between renders and avoids the unnecessary recreations.
| const { value: promptLibrary } = usePromptLibrary(); | |
| const savedPrompts = promptLibrary.filter((p) => p.prompt.trim().length > 0); | |
| const selectedPrompt = savedPrompts.find((p) => p.id === selectedPromptId) ?? null; | |
| const { value: promptLibrary } = usePromptLibrary(); | |
| const savedPrompts = useMemo( | |
| () => promptLibrary.filter((p) => p.prompt.trim().length > 0), | |
| [promptLibrary] | |
| ); | |
| const selectedPrompt = useMemo( | |
| () => savedPrompts.find((p) => p.id === selectedPromptId) ?? null, | |
| [savedPrompts, selectedPromptId] | |
| ); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx
Line: 53-55
Comment:
`savedPrompts` is recomputed from a `.filter()` call on every render, which also forces `selectedPrompt` (a `.find()` on the filtered array) to be re-evaluated on every render. Because `selectedPrompt` is in the `useCallback` dependency array, the callback is also recreated on every render while the component is mounted, even when neither the library data nor the selected ID has changed. Wrapping both derived values in `useMemo` keeps them stable between renders and avoids the unnecessary recreations.
```suggestion
const { value: promptLibrary } = usePromptLibrary();
const savedPrompts = useMemo(
() => promptLibrary.filter((p) => p.prompt.trim().length > 0),
[promptLibrary]
);
const selectedPrompt = useMemo(
() => savedPrompts.find((p) => p.id === selectedPromptId) ?? null,
[savedPrompts, selectedPromptId]
);
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| {savedPrompts.length > 0 ? ( | ||
| <Field> | ||
| <FieldLabel>Prompt</FieldLabel> | ||
| <Select | ||
| value={selectedPromptId ?? ''} | ||
| onValueChange={(val) => setSelectedPromptId(val || null)} | ||
| > | ||
| <SelectTrigger> | ||
| <SelectValue placeholder="No prompt"> | ||
| {selectedPrompt ? selectedPrompt.title : 'No prompt'} | ||
| </SelectValue> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value="">No prompt</SelectItem> | ||
| {savedPrompts.map((prompt) => ( | ||
| <SelectItem key={prompt.id} value={prompt.id}> | ||
| {prompt.title} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| {selectedPrompt ? ( | ||
| <p className="text-muted-foreground line-clamp-2 text-xs"> | ||
| {selectedPrompt.prompt} | ||
| </p> | ||
| ) : null} | ||
| </Field> | ||
| ) : null} |
There was a problem hiding this comment.
Prompt field flashes in after async load
usePromptLibrary starts with data = undefined, so value falls back to [] and savedPrompts.length > 0 is false — the Prompt field is hidden on the initial render. Once the React Query fetch resolves, if the library is non-empty, the field appears and the form visually grows. This is the same pattern used by the Model field (it too is hidden until useAgents data arrives), so the precedent is already set. It's worth noting in case you want to add a loading placeholder or a Skeleton matching the field height to prevent the layout shift.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx
Line: 163-190
Comment:
**Prompt field flashes in after async load**
`usePromptLibrary` starts with `data = undefined`, so `value` falls back to `[]` and `savedPrompts.length > 0` is `false` — the Prompt field is hidden on the initial render. Once the React Query fetch resolves, if the library is non-empty, the field appears and the form visually grows. This is the same pattern used by the Model field (it too is hidden until `useAgents` data arrives), so the precedent is already set. It's worth noting in case you want to add a loading placeholder or a `Skeleton` matching the field height to prevent the layout shift.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
… create conversation modal Replaces the saved-prompt select and bespoke agent/model/toggle fields with the shared InitialConversationField, so users get a free-text initial message with prompt library insertion via the + popover. Exposes createDisabled from useInitialConversationState to keep the Create button gated on agent availability.
Description
Adds a Prompt select field to the add-conversation modal so users can apply a pre-saved prompt from the prompt library when creating a conversation — previously only agent and model selection were exposed there, while other creation flows (e.g. the create-task modal) already supported saved prompts.
Implementation notes:
usePromptLibrary(), defaults to "No prompt", filters out prompts with empty text, and is hidden entirely when the library is empty.initialPrompttocreateConversation. No backend changes were needed:initialPromptis already plumbed through both runtime paths — PTY injects it on session start, and ACP stores it in the conversation config and consumes it on first spawn inhydrateConversation.Related issues
Fixes ENG-1726 — https://linear.app/general-action/issue/ENG-1726/allow-passing-a-pre-saved-prompt-in-the-add-conversation-modal
Testing
typecheckfor@emdash/emdash-desktop— passeslintfor@emdash/emdash-desktop— passes (remaining warnings are pre-existing in unrelated files)format:checkfor@emdash/emdash-desktop— passessrc/renderer/features/tasks/conversations,src/main/core/conversations): 74 passed.initial-conversation-section.test.tsfails with a pre-existingwindow is not definedimport error that reproduces identically without this change.Screenshot/Recording (if applicable)
N/A
Checklist