Projects Prototype (Frontend-only, localStorage)#421
Projects Prototype (Frontend-only, localStorage)#421
Conversation
Build the complete Projects UI/UX flow as a frontend-only prototype using localStorage for data persistence. This allows evaluating the design and interactions before committing to a full backend implementation. - Add ProjectsContext/useProjects for state management (localStorage) - Add project CRUD: create, rename, delete with confirmation dialogs - Add project detail page with chat input, custom instructions, and files - Add collapsible Projects and Recents sections in sidebar - Add unified ChatContextMenu shared across all chat context menus - Add "Move to project" drill-down submenu with animated slide transition - Add chat rename/delete support from both sidebar and project page - Auto-assign chats to projects via URL param after conversation creation - Filter project chats from Recents list, track deleted project chats
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR introduces a comprehensive project management system to the frontend application. It adds a new ProjectsContext for managing project data in localStorage, creates UI components for project creation/deletion/management, integrates project functionality into chat workflows with event-driven assignment, and establishes a new route for viewing and managing projects with their associated chats. Changes
Sequence DiagramssequenceDiagram
participant User
participant ChatContextMenu
participant Sidebar
participant ProjectsContext
participant CreateProjectDialog
participant ProjectDetailPage
User->>ChatContextMenu: Click "New project" in context menu
ChatContextMenu->>Sidebar: Emit "createprojectforchat" event
Sidebar->>CreateProjectDialog: Open dialog with createProjectForChatId
User->>CreateProjectDialog: Enter project name & submit
CreateProjectDialog->>ProjectsContext: createProject(name)
ProjectsContext->>ProjectsContext: Save to localStorage
ProjectsContext->>User: Return projectId
Sidebar->>ProjectsContext: assignChatToProject(chatId, projectId)
Sidebar->>ProjectDetailPage: Navigate to project detail page
ProjectDetailPage->>User: Display project with assigned chat
sequenceDiagram
participant User
participant ProjectsList
participant ChatContextMenu
participant ProjectsContext
participant OpenAIAPI
participant EventSystem
User->>ProjectsList: Click chat "Move to project"
ProjectsList->>ChatContextMenu: Render context menu
User->>ChatContextMenu: Select target project
ChatContextMenu->>ProjectsContext: moveChatToProject(chatId, targetProjectId)
ProjectsContext->>ProjectsContext: Remove from old project
ProjectsContext->>ProjectsContext: Add to new project
ProjectsContext->>EventSystem: Dispatch state change event
ProjectsList->>User: Update chat list
User->>ProjectsList: Click chat "Rename"
ProjectsList->>ChatContextMenu: Render context menu
User->>ChatContextMenu: Rename chat
ChatContextMenu->>OpenAIAPI: Update chat metadata
OpenAIAPI->>EventSystem: Emit update event
ProjectsList->>User: Reflect renamed chat
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile OverviewGreptile SummaryAdded a complete Projects feature prototype using localStorage for organizing chats into projects with custom instructions and file attachments. Key changes:
Critical issues found:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant ProjectDetailPage
participant Browser
participant UnifiedChat
participant ProjectsContext
participant localStorage
User->>ProjectDetailPage: Click "New chat in Project"
ProjectDetailPage->>Browser: navigate({ to: "/" })
ProjectDetailPage->>Browser: setTimeout(0ms)
Browser->>Browser: history.replaceState("/?project_id=...")
Browser->>Browser: dispatchEvent("newchat")
Browser->>UnifiedChat: Event received
ProjectDetailPage->>Browser: setTimeout(100ms)
Browser->>Browser: Find textarea, set value
ProjectDetailPage->>Browser: setTimeout(50ms)
Browser->>Browser: Dispatch form submit
UnifiedChat->>UnifiedChat: Create conversation
UnifiedChat->>Browser: dispatchEvent("conversationcreated")
UnifiedChat->>Browser: Check project_id param
UnifiedChat->>Browser: dispatchEvent("assignchattoproject")
Browser->>ProjectsContext: Event received
ProjectsContext->>ProjectsContext: Assign chat to project
ProjectsContext->>localStorage: Save updated projects
|
| const handleStartChat = useCallback(() => { | ||
| const message = newChatInput.trim(); | ||
| if (!message) return; | ||
| setNewChatInput(""); | ||
| // Navigate to home with project_id param so UnifiedChat can auto-assign | ||
| navigate({ to: "/" }); | ||
| setTimeout(() => { | ||
| window.history.replaceState(null, "", `/?project_id=${projectId}`); | ||
| window.dispatchEvent(new Event("newchat")); | ||
| // Set the message to be sent | ||
| setTimeout(() => { | ||
| const textarea = document.getElementById("message") as HTMLTextAreaElement; | ||
| if (textarea) { | ||
| const nativeInputValueSetter = Object.getOwnPropertyDescriptor( | ||
| window.HTMLTextAreaElement.prototype, | ||
| "value" | ||
| )?.set; | ||
| nativeInputValueSetter?.call(textarea, message); | ||
| textarea.dispatchEvent(new Event("input", { bubbles: true })); | ||
| textarea.focus(); | ||
| // Auto-submit after a brief delay | ||
| setTimeout(() => { | ||
| const form = textarea.closest("form"); | ||
| if (form) { | ||
| form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); | ||
| } | ||
| }, 50); | ||
| } | ||
| }, 100); | ||
| }, 0); | ||
| }, [newChatInput, projectId, navigate]); |
There was a problem hiding this comment.
Chain of nested setTimeout calls creates fragile timing dependencies that may fail across different devices/browsers. The navigation and message submission rely on arbitrary delays (0ms, 100ms, 50ms) to coordinate state.
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/components/projects/ProjectDetailPage.tsx
Line: 148:178
Comment:
Chain of nested `setTimeout` calls creates fragile timing dependencies that may fail across different devices/browsers. The navigation and message submission rely on arbitrary delays (0ms, 100ms, 50ms) to coordinate state.
How can I resolve this? If you propose a fix, please make it concise.| name: string; | ||
| createdAt: number; | ||
| updatedAt: number; | ||
| customInstructions: string; |
There was a problem hiding this comment.
Custom instructions are stored but never actually sent with chat messages - they're not integrated with UnifiedChat.tsx to be included in the system prompt or API calls. Users can set them but they won't affect AI responses.
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/state/ProjectsContext.tsx
Line: 18:18
Comment:
Custom instructions are stored but never actually sent with chat messages - they're not integrated with `UnifiedChat.tsx` to be included in the system prompt or API calls. Users can set them but they won't affect AI responses.
How can I resolve this? If you propose a fix, please make it concise.| import { useState, useEffect } from "react"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| DialogDescription, | ||
| DialogFooter | ||
| } from "@/components/ui/dialog"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { Textarea } from "@/components/ui/textarea"; | ||
|
|
||
| interface CustomInstructionsDialogProps { | ||
| open: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| currentInstructions: string; | ||
| onSave: (instructions: string) => void; | ||
| } | ||
|
|
||
| export function CustomInstructionsDialog({ | ||
| open, | ||
| onOpenChange, | ||
| currentInstructions, | ||
| onSave | ||
| }: CustomInstructionsDialogProps) { | ||
| const [instructions, setInstructions] = useState(""); | ||
|
|
||
| useEffect(() => { | ||
| if (open) { | ||
| setInstructions(currentInstructions); | ||
| } | ||
| }, [open, currentInstructions]); | ||
|
|
||
| const handleSave = () => { | ||
| onSave(instructions); | ||
| onOpenChange(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogContent className="sm:max-w-[525px]"> | ||
| <DialogHeader> | ||
| <DialogTitle>Custom instructions</DialogTitle> | ||
| <DialogDescription> | ||
| Set context and customize how Maple responds in this project. | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
| <div className="py-4"> | ||
| <Textarea | ||
| value={instructions} | ||
| onChange={(e) => setInstructions(e.target.value)} | ||
| placeholder="e.g., 'Use concise bullet points. Focus on practical examples.'" | ||
| className="min-h-[200px] resize-y" | ||
| autoFocus | ||
| /> | ||
| </div> | ||
| <DialogFooter> | ||
| <Button variant="outline" onClick={() => onOpenChange(false)}> | ||
| Cancel | ||
| </Button> | ||
| <Button onClick={handleSave}>Save</Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Custom instructions should only be editable by paid users per requirements, but there's no billing/subscription check in this dialog.
Context Used: Context from dashboard - System prompts should only be editable by paid users and should not be accessible to free users. Ens... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/components/projects/CustomInstructionsDialog.tsx
Line: 1:66
Comment:
Custom instructions should only be editable by paid users per requirements, but there's no billing/subscription check in this dialog.
**Context Used:** Context from `dashboard` - System prompts should only be editable by paid users and should not be accessible to free users. Ens... ([source](https://app.greptile.com/review/custom-context?memory=85e73147-9381-44e9-86f6-50b68c27d659))
How can I resolve this? If you propose a fix, please make it concise.| export interface ProjectFile { | ||
| id: string; | ||
| name: string; | ||
| type: string; // e.g. "pdf", "txt", "md" | ||
| size: number; | ||
| addedAt: number; | ||
| } |
There was a problem hiding this comment.
File metadata is stored but file contents are never persisted or uploaded - only name, type, and size are saved. The actual file data isn't available for chat context.
Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/state/ProjectsContext.tsx
Line: 5:11
Comment:
File metadata is stored but file contents are never persisted or uploaded - only name, type, and size are saved. The actual file data isn't available for chat context.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/src/app.tsx (1)
100-115:⚠️ Potential issue | 🟡 MinorFix indentation — pipeline failure.
The
<ProjectsProvider>wrapper doesn't indent its children, which is what's triggering the Prettier failure. The<OpenAIProvider>through</OpenAIProvider>block should be indented one level inside<ProjectsProvider>.🔧 Proposed fix
<LocalStateProvider> - <ProjectsProvider> - <OpenAIProvider> - <QueryClientProvider client={queryClient}> - <TooltipProvider> - <TTSProvider> - <BillingServiceProvider> - <ProxyEventListener /> - <UpdateEventListener /> - <DeepLinkHandler /> - <InnerApp /> - </BillingServiceProvider> - </TTSProvider> - </TooltipProvider> - </QueryClientProvider> - </OpenAIProvider> - </ProjectsProvider> + <ProjectsProvider> + <OpenAIProvider> + <QueryClientProvider client={queryClient}> + <TooltipProvider> + <TTSProvider> + <BillingServiceProvider> + <ProxyEventListener /> + <UpdateEventListener /> + <DeepLinkHandler /> + <InnerApp /> + </BillingServiceProvider> + </TTSProvider> + </TooltipProvider> + </QueryClientProvider> + </OpenAIProvider> + </ProjectsProvider> </LocalStateProvider>
🤖 Fix all issues with AI agents
In `@frontend/src/components/ChatContextMenu.tsx`:
- Around line 53-63: The button in ChatContextMenu wrapping the MoreHorizontal
icon lacks an accessible label, so update the <button> element inside the
ChatContextMenu component to include an appropriate aria-label (e.g., "Open
message menu" or similar) so screen readers can announce its purpose; locate the
button using the MoreHorizontal icon and isMobile usage and add the aria-label
attribute and ensure it remains descriptive and concise.
In `@frontend/src/components/projects/ProjectDetailPage.tsx`:
- Around line 148-178: handleStartChat is using fragile nested setTimeouts,
direct history.replaceState, and a prototype value-setter hack to push a message
into UnifiedChat; replace this with a declarative handoff: stop manipulating the
DOM (remove document.getElementById("message"), HTMLTextAreaElement.prototype
access, manual form submit and history.replaceState), and instead navigate to
"/" with URL search params (e.g. project_id and initial_message) or write the
projectId + message into a shared context/store that UnifiedChat reads on mount;
update handleStartChat (referencing newChatInput, projectId, navigate) to
setNewChatInput("") then navigate({ to: "/", search:
`?project_id=${projectId}&initial_message=${encodeURIComponent(message)}` }) or
dispatch to the shared state, and change UnifiedChat to consume those
params/state and perform the focus/send action declaratively on mount.
- Around line 206-217: handleFileUpload currently only saves file metadata
(name, type, size) and discards the File blob; either persist the file content
or make the metadata-only limitation explicit. Update handleFileUpload to read
the file content (e.g., await file.text() or use FileReader) and pass that
content into addFile (extend addFile payload with a content or base64 field) if
you intend to store content, or alternatively annotate the saved metadata with a
metadataOnly: true flag and add a visible label "(metadata only — content not
yet stored)" in the UI where addFile-driven attachments are rendered so
users/developers know the limitation; adjust the addFile signature and any
consumers (and ProjectDetailPage’s render) accordingly.
- Around line 124-144: The useEffect that loads conversations depends on
project?.chatIds.length which misses cases where the array contents change but
length stays the same; update the dependency to track the actual IDs (e.g.,
depend on project?.chatIds or a stable serialization like
JSON.stringify(project?.chatIds) or [...(project?.chatIds || [])]) so
fetchConversations runs whenever chat IDs change; ensure you still include
opensecret in the dependency array and keep the same logic inside
fetchConversations/setConversationMap to populate the conversationMap.
In `@frontend/src/components/projects/ProjectsList.tsx`:
- Around line 152-166: handleRenameChat dispatches a
CustomEvent("conversationrenamed") after updating the server but no component
listens for it, so the local conversations state in ChatHistoryList never
updates; either add an event listener in ChatHistoryList that listens for
"conversationrenamed" and updates its conversations state (or triggers a
refresh) when detail.conversationId/ title are received, or instead of
dispatching the CustomEvent from handleRenameChat call the existing cache/query
invalidation routine (e.g., the same function ChatHistoryList uses to refresh
conversations) so the sidebar refreshes immediately after
openai.conversations.update completes.
- Around line 25-33: The local Conversation interface in ProjectsList.tsx is
duplicated; remove this local definition and import the shared Conversation type
from ChatHistoryList.tsx (which exports Conversation) instead; update the top of
ProjectsList.tsx to add an import for Conversation from ChatHistoryList, delete
the interface block, and ensure all usages in ProjectsList (and any references
to ConversationData elsewhere) use the imported Conversation type so the file
relies on the single source of truth exported by ChatHistoryList.
In `@frontend/src/components/UnifiedChat.tsx`:
- Around line 2349-2359: The block that auto-assigns a chat to a project (uses
projectParam, conversationId, CustomEvent with "assignchattoproject", and
window.history.replaceState) has lines exceeding the 100-char Prettier limit;
refactor the long lines by breaking the CustomEvent creation and the
replaceState call into multiple shorter statements (e.g., create a const event =
new CustomEvent(...) on its own line and build the cleaned URL string on a
separate line) to satisfy formatting rules, then run just format, just lint, and
just build to verify and commit the reformatted changes.
In `@frontend/src/state/ProjectsContext.tsx`:
- Around line 130-145: The setProjects updater in deleteProject currently
performs a localStorage write (using DELETED_CHATS_KEY) inside the state
updater, which is a side effect and unsafe in StrictMode; refactor deleteProject
to first find the target project from the outer projects state (e.g. const
project = projects.find(p => p.id === projectId)), perform the localStorage
read/merge/write for project.chatIds outside the setProjects callback (wrapped
in try/catch), then call setProjects(prev => prev.filter(p => p.id !==
projectId)) to update state; also add projects to the deleteProject dependency
array so the finder uses current state.
🧹 Nitpick comments (10)
frontend/src/components/Sidebar.tsx (2)
46-48:excludeChatIdsis a newSeton every render, defeating downstream memoization.
getAllAssignedChatIds(),getDeletedChatIds(), and the spread into a newSetall run every render, producing a fresh object reference each time. This causesfilteredConversationsinChatHistoryList(which depends onexcludeChatIds) to recompute on every parent re-render, even when nothing changed.Consider memoizing this:
♻️ Suggested fix
- const assignedIds = getAllAssignedChatIds(); - const deletedIds = getDeletedChatIds(); - const excludeChatIds = new Set([...assignedIds, ...deletedIds]); + const assignedIds = getAllAssignedChatIds(); + const deletedIds = getDeletedChatIds(); + const excludeChatIds = useMemo( + () => new Set([...assignedIds, ...deletedIds]), + [assignedIds, deletedIds] + );Note: this alone isn't sufficient since
getAllAssignedChatIdsandgetDeletedChatIdsalso return newSetinstances on every call (seeProjectsContext). You'd need stable references there too (e.g., memoize the returned sets in the context) for this to be truly effective.
54-61: Custom DOM events for cross-component communication are fragile.The
createprojectforchatevent uses an untypedCustomEventwith adetail.chatIdproperty, relying on string-based event names. This pattern is used in multiple places across the PR (alsoassignchattoproject,conversationselected, etc.) and is hard to refactor, type-check, or trace.For a prototype this is acceptable, but before graduating this feature consider replacing these with a more traceable pattern (e.g., a shared event bus with typed events, or lifting callbacks through context).
frontend/src/components/projects/ProjectDetailPage.tsx (2)
40-46:ConversationDataduplicates theConversationtype fromChatHistoryList.This interface is essentially the same shape as
Conversationexported fromChatHistoryList.tsx(and also duplicated inProjectsList.tsx). Consider importing the shared type to avoid drift.
226-235:handleSelectConversationis duplicated across three components.This exact pattern (build URL params →
pushState→ dispatchconversationselectedevent) appears inProjectDetailPage,ProjectsList, andChatHistoryList. Extract it into a shared utility or hook to reduce duplication and ensure consistent behavior.frontend/src/components/ChatContextMenu.tsx (1)
66-116: Sliding panel approach may cause height jumps between main menu and submenu.When the main menu is active, the submenu is
position: absolute(out of flow), and vice versa. If the two panels have different heights, the dropdown will snap between heights on transition. This can cause a jarring visual effect, especially when the project list is long.Consider setting a
min-heighton the container based on the taller panel, or measuring both panels and animating height.Also applies to: 119-161
frontend/src/components/ChatHistoryList.tsx (2)
869-890: Duplicated visibility condition for Recents section and loading indicator.The expression
(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0))appears at Lines 891 and 967. Extract it to aconstfor readability and to avoid divergence:♻️ Suggested improvement
+ const showRecents = isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0); + {/* Recents header */} {filteredConversations.length > 0 && ( <button onClick={toggleRecentsExpanded} ... > <span className="font-medium">Recents</span> <span className={ - !(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0)) || isMobile + !showRecents || isMobile ? "" : "opacity-0 group-hover/header:opacity-100 transition-opacity" } > - {isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0) ? ( + {showRecents ? ( <ChevronDown className="h-3.5 w-3.5" /> ...And similarly at line 967.
Also applies to: 891-891, 967-967
516-521:onConversationsLoadedeffect may cause unnecessary updates.This effect fires every time
conversationsoronConversationsLoadedchanges. IfonConversationsLoadedwere ever an unstable reference (e.g., an inline arrow function instead ofsetConversations), it would trigger an infinite loop. Currently safe becauseSidebarpassessetConversationsdirectly, but this coupling is implicit.Consider guarding with a ref or adding a comment clarifying the stability requirement.
frontend/src/state/ProjectsContext.tsx (3)
225-249:getAllAssignedChatIdsandgetDeletedChatIdscreate new objects every call, preventing downstream memoization.Both functions return a new
Seteach time they are invoked. SinceSidebarcalls them on every render to buildexcludeChatIds, no amount of downstreamuseMemowill help — the inputs are always "new."Memoize the results inside the provider:
♻️ Suggested fix
- const getAllAssignedChatIds = useCallback((): Set<string> => { - const ids = new Set<string>(); - for (const p of projects) { - for (const chatId of p.chatIds) { - ids.add(chatId); - } - } - return ids; - }, [projects]); + const allAssignedChatIds = useMemo(() => { + const ids = new Set<string>(); + for (const p of projects) { + for (const chatId of p.chatIds) { + ids.add(chatId); + } + } + return ids; + }, [projects]); + + const getAllAssignedChatIds = useCallback( + () => allAssignedChatIds, + [allAssignedChatIds] + );Similarly for
getDeletedChatIds— though that one readslocalStoragedirectly, so consider tracking deleted IDs in React state instead.
211-216:moveChatToProjectis an unnecessary wrapper aroundassignChatToProject.It delegates entirely to
assignChatToProjectwith the same signature. Consider removing it and usingassignChatToProjectdirectly.
61-68: No schema validation onlocalStorage.getItemparse — corrupt data will silently produce invalid state.
loadProjectsparses localStorage JSON but doesn't validate the shape. If the stored data is corrupt or from an incompatible version (e.g., missingchatIdsfield), downstream code will throw when accessingproject.chatIds.length,project.files.map(), etc.Consider adding minimal validation (e.g.,
Array.isArray(parsed) && parsed.every(p => p.id && Array.isArray(p.chatIds))) or wrapping access with defaults.
| <button | ||
| className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${ | ||
| isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100" | ||
| }`} | ||
| onClick={(e) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| }} | ||
| > | ||
| <MoreHorizontal size={16} /> | ||
| </button> |
There was a problem hiding this comment.
Trigger button lacks an accessible label.
The <button> wrapping <MoreHorizontal /> has no aria-label, so screen readers will announce it as an unlabeled button. Add an aria-label for accessibility.
♻️ Suggested fix
<button
className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
+ aria-label="Chat actions"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${ | |
| isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100" | |
| }`} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }} | |
| > | |
| <MoreHorizontal size={16} /> | |
| </button> | |
| <button | |
| className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${ | |
| isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100" | |
| }`} | |
| aria-label="Chat actions" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }} | |
| > | |
| <MoreHorizontal size={16} /> | |
| </button> |
🤖 Prompt for AI Agents
In `@frontend/src/components/ChatContextMenu.tsx` around lines 53 - 63, The button
in ChatContextMenu wrapping the MoreHorizontal icon lacks an accessible label,
so update the <button> element inside the ChatContextMenu component to include
an appropriate aria-label (e.g., "Open message menu" or similar) so screen
readers can announce its purpose; locate the button using the MoreHorizontal
icon and isMobile usage and add the aria-label attribute and ensure it remains
descriptive and concise.
| useEffect(() => { | ||
| if (!opensecret || !project?.chatIds.length) return; | ||
| let cancelled = false; | ||
|
|
||
| async function fetchConversations() { | ||
| try { | ||
| const response = await opensecret.listConversations({ limit: 100 }); | ||
| if (cancelled) return; | ||
| const map = new Map<string, ConversationData>(); | ||
| for (const conv of response.data || []) { | ||
| map.set(conv.id, conv); | ||
| } | ||
| setConversationMap(map); | ||
| } catch { | ||
| // Silently fail — names just won't show | ||
| } | ||
| } | ||
|
|
||
| fetchConversations(); | ||
| return () => { cancelled = true; }; | ||
| }, [opensecret, project?.chatIds.length]); |
There was a problem hiding this comment.
useEffect dependency on project?.chatIds.length misses same-length mutations.
If a chat is removed and another added in the same operation (length unchanged), the effect won't re-run and the conversationMap will be stale. Depend on the chat IDs themselves instead:
♻️ Suggested fix
- }, [opensecret, project?.chatIds.length]);
+ }, [opensecret, project?.chatIds.join(",")]);Alternatively, use JSON.stringify(project?.chatIds) or spread the array into the dependency.
🤖 Prompt for AI Agents
In `@frontend/src/components/projects/ProjectDetailPage.tsx` around lines 124 -
144, The useEffect that loads conversations depends on project?.chatIds.length
which misses cases where the array contents change but length stays the same;
update the dependency to track the actual IDs (e.g., depend on project?.chatIds
or a stable serialization like JSON.stringify(project?.chatIds) or
[...(project?.chatIds || [])]) so fetchConversations runs whenever chat IDs
change; ensure you still include opensecret in the dependency array and keep the
same logic inside fetchConversations/setConversationMap to populate the
conversationMap.
| const handleStartChat = useCallback(() => { | ||
| const message = newChatInput.trim(); | ||
| if (!message) return; | ||
| setNewChatInput(""); | ||
| // Navigate to home with project_id param so UnifiedChat can auto-assign | ||
| navigate({ to: "/" }); | ||
| setTimeout(() => { | ||
| window.history.replaceState(null, "", `/?project_id=${projectId}`); | ||
| window.dispatchEvent(new Event("newchat")); | ||
| // Set the message to be sent | ||
| setTimeout(() => { | ||
| const textarea = document.getElementById("message") as HTMLTextAreaElement; | ||
| if (textarea) { | ||
| const nativeInputValueSetter = Object.getOwnPropertyDescriptor( | ||
| window.HTMLTextAreaElement.prototype, | ||
| "value" | ||
| )?.set; | ||
| nativeInputValueSetter?.call(textarea, message); | ||
| textarea.dispatchEvent(new Event("input", { bubbles: true })); | ||
| textarea.focus(); | ||
| // Auto-submit after a brief delay | ||
| setTimeout(() => { | ||
| const form = textarea.closest("form"); | ||
| if (form) { | ||
| form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); | ||
| } | ||
| }, 50); | ||
| } | ||
| }, 100); | ||
| }, 0); | ||
| }, [newChatInput, projectId, navigate]); |
There was a problem hiding this comment.
handleStartChat relies on fragile nested timeouts and React-internal hacks — high breakage risk.
This function:
- Navigates to
"/", then usessetTimeout(…, 0)to wait for render. - Manually sets
window.historyto injectproject_id. - Accesses
HTMLTextAreaElement.prototype.valuesetter viaObject.getOwnPropertyDescriptor— a React internals hack to bypass controlled-input behavior. - Chains another
setTimeout(…, 100)for DOM lookup, thensetTimeout(…, 50)for synthetic form submission.
This is inherently timing-dependent and will silently break if UnifiedChat's DOM changes (e.g., textarea ID, form structure) or if navigation timing shifts. The nativeInputValueSetter hack is especially fragile and has been known to break across React versions.
A more robust approach would be to use shared state (context or URL search params) to pass both project_id and the initial message, letting UnifiedChat consume them declaratively on mount.
🤖 Prompt for AI Agents
In `@frontend/src/components/projects/ProjectDetailPage.tsx` around lines 148 -
178, handleStartChat is using fragile nested setTimeouts, direct
history.replaceState, and a prototype value-setter hack to push a message into
UnifiedChat; replace this with a declarative handoff: stop manipulating the DOM
(remove document.getElementById("message"), HTMLTextAreaElement.prototype
access, manual form submit and history.replaceState), and instead navigate to
"/" with URL search params (e.g. project_id and initial_message) or write the
projectId + message into a shared context/store that UnifiedChat reads on mount;
update handleStartChat (referencing newChatInput, projectId, navigate) to
setNewChatInput("") then navigate({ to: "/", search:
`?project_id=${projectId}&initial_message=${encodeURIComponent(message)}` }) or
dispatch to the shared state, and change UnifiedChat to consume those
params/state and perform the focus/send action declaratively on mount.
| const handleFileUpload = useCallback( | ||
| (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const files = e.target.files; | ||
| if (!files) return; | ||
| for (const file of Array.from(files)) { | ||
| const ext = file.name.split(".").pop()?.toLowerCase() || "file"; | ||
| addFile(projectId, { name: file.name, type: ext, size: file.size }); | ||
| } | ||
| e.target.value = ""; | ||
| }, | ||
| [projectId, addFile] | ||
| ); |
There was a problem hiding this comment.
File upload only stores metadata — file content is discarded.
handleFileUpload records name, type, and size but never reads the File blob. The UI presents these as attached files, but they won't be available for chat context since the content isn't persisted (not even to localStorage or IndexedDB).
If this is intentional scaffolding for the prototype, add a visible indicator (e.g., "(metadata only — content not yet stored)") or a comment, so users and future developers understand the limitation.
🤖 Prompt for AI Agents
In `@frontend/src/components/projects/ProjectDetailPage.tsx` around lines 206 -
217, handleFileUpload currently only saves file metadata (name, type, size) and
discards the File blob; either persist the file content or make the
metadata-only limitation explicit. Update handleFileUpload to read the file
content (e.g., await file.text() or use FileReader) and pass that content into
addFile (extend addFile payload with a content or base64 field) if you intend to
store content, or alternatively annotate the saved metadata with a metadataOnly:
true flag and add a visible label "(metadata only — content not yet stored)" in
the UI where addFile-driven attachments are rendered so users/developers know
the limitation; adjust the addFile signature and any consumers (and
ProjectDetailPage’s render) accordingly.
| interface Conversation { | ||
| id: string; | ||
| object: "conversation"; | ||
| created_at: number; | ||
| metadata?: { | ||
| title?: string; | ||
| [key: string]: unknown; | ||
| }; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Conversation interface is duplicated in three files.
This exact interface exists in ChatHistoryList.tsx (which exports it at line 1077), ProjectDetailPage.tsx (as ConversationData), and here. Import from ChatHistoryList to have a single source of truth.
♻️ Suggested fix
-interface Conversation {
- id: string;
- object: "conversation";
- created_at: number;
- metadata?: {
- title?: string;
- [key: string]: unknown;
- };
-}
+import type { Conversation } from "@/components/ChatHistoryList";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| interface Conversation { | |
| id: string; | |
| object: "conversation"; | |
| created_at: number; | |
| metadata?: { | |
| title?: string; | |
| [key: string]: unknown; | |
| }; | |
| } | |
| import type { Conversation } from "@/components/ChatHistoryList"; |
🤖 Prompt for AI Agents
In `@frontend/src/components/projects/ProjectsList.tsx` around lines 25 - 33, The
local Conversation interface in ProjectsList.tsx is duplicated; remove this
local definition and import the shared Conversation type from
ChatHistoryList.tsx (which exports Conversation) instead; update the top of
ProjectsList.tsx to add an import for Conversation from ChatHistoryList, delete
the interface block, and ensure all usages in ProjectsList (and any references
to ConversationData elsewhere) use the imported Conversation type so the file
relies on the single source of truth exported by ChatHistoryList.
| const handleRenameChat = useCallback( | ||
| async (chatId: string, newTitle: string) => { | ||
| if (!openai) return; | ||
| await openai.conversations.update(chatId, { | ||
| metadata: { title: newTitle } | ||
| }); | ||
| // Dispatch event so ChatHistoryList and other consumers refresh | ||
| window.dispatchEvent( | ||
| new CustomEvent("conversationrenamed", { | ||
| detail: { conversationId: chatId, title: newTitle } | ||
| }) | ||
| ); | ||
| }, | ||
| [openai] | ||
| ); |
There was a problem hiding this comment.
conversationrenamed event appears to have no listener.
handleRenameChat dispatches a CustomEvent("conversationrenamed", …) after the API call succeeds, but none of the provided files listen for this event. The rename will persist server-side but the sidebar's local conversations state won't update — the old title will show until the next poll (up to 60s).
Either add a listener in ChatHistoryList to handle this event, or trigger a query invalidation instead.
🤖 Prompt for AI Agents
In `@frontend/src/components/projects/ProjectsList.tsx` around lines 152 - 166,
handleRenameChat dispatches a CustomEvent("conversationrenamed") after updating
the server but no component listens for it, so the local conversations state in
ChatHistoryList never updates; either add an event listener in ChatHistoryList
that listens for "conversationrenamed" and updates its conversations state (or
triggers a refresh) when detail.conversationId/ title are received, or instead
of dispatching the CustomEvent from handleRenameChat call the existing
cache/query invalidation routine (e.g., the same function ChatHistoryList uses
to refresh conversations) so the sidebar refreshes immediately after
openai.conversations.update completes.
|
|
||
| // Auto-assign to project if project_id param is present | ||
| const projectParam = new URLSearchParams(window.location.search).get("project_id"); | ||
| if (projectParam) { | ||
| window.dispatchEvent(new CustomEvent("assignchattoproject", { | ||
| detail: { chatId: conversationId, projectId: projectParam } | ||
| })); | ||
| const cleaned = new URLSearchParams(window.location.search); | ||
| cleaned.delete("project_id"); | ||
| window.history.replaceState(null, "", `${window.location.pathname}?${cleaned.toString()}`); | ||
| } |
There was a problem hiding this comment.
Auto-assign logic is sound; fix formatting for Prettier.
The event-based project assignment and URL cleanup work correctly. window.location.search at this point already has conversation_id from the replaceState on Line 2341, so the cleaned params won't be empty.
However, this block likely contributes to the Prettier pipeline failure — the new CustomEvent(...) and replaceState lines appear to exceed the 100-character line limit. Run just format to resolve. As per coding guidelines, "Run just format, just lint, and just build after making TypeScript/React changes to ensure code quality and compilation."
🤖 Prompt for AI Agents
In `@frontend/src/components/UnifiedChat.tsx` around lines 2349 - 2359, The block
that auto-assigns a chat to a project (uses projectParam, conversationId,
CustomEvent with "assignchattoproject", and window.history.replaceState) has
lines exceeding the 100-char Prettier limit; refactor the long lines by breaking
the CustomEvent creation and the replaceState call into multiple shorter
statements (e.g., create a const event = new CustomEvent(...) on its own line
and build the cleaned URL string on a separate line) to satisfy formatting
rules, then run just format, just lint, and just build to verify and commit the
reformatted changes.
| const deleteProject = useCallback((projectId: string) => { | ||
| setProjects((prev) => { | ||
| const project = prev.find((p) => p.id === projectId); | ||
| if (project && project.chatIds.length > 0) { | ||
| // Mark project's chats as deleted in localStorage | ||
| try { | ||
| const existing = JSON.parse(localStorage.getItem(DELETED_CHATS_KEY) || "[]"); | ||
| const updated = [...new Set([...existing, ...project.chatIds])]; | ||
| localStorage.setItem(DELETED_CHATS_KEY, JSON.stringify(updated)); | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
| return prev.filter((p) => p.id !== projectId); | ||
| }); | ||
| }, []); |
There was a problem hiding this comment.
Side effect (localStorage write) inside setProjects updater — unsafe in StrictMode.
The deleteProject callback performs a localStorage.setItem inside the function passed to setProjects. React state updater functions should be pure — in StrictMode, React may invoke them twice, causing the deleted chat IDs to be written to localStorage redundantly (in this case idempotent due to Set dedup, but still an anti-pattern).
Move the side effect outside the updater:
♻️ Suggested fix
const deleteProject = useCallback((projectId: string) => {
+ const project = projects.find((p) => p.id === projectId);
+ if (project && project.chatIds.length > 0) {
+ try {
+ const existing = JSON.parse(localStorage.getItem(DELETED_CHATS_KEY) || "[]");
+ const updated = [...new Set([...existing, ...project.chatIds])];
+ localStorage.setItem(DELETED_CHATS_KEY, JSON.stringify(updated));
+ } catch {
+ // ignore
+ }
+ }
setProjects((prev) => {
- const project = prev.find((p) => p.id === projectId);
- if (project && project.chatIds.length > 0) {
- try {
- const existing = JSON.parse(localStorage.getItem(DELETED_CHATS_KEY) || "[]");
- const updated = [...new Set([...existing, ...project.chatIds])];
- localStorage.setItem(DELETED_CHATS_KEY, JSON.stringify(updated));
- } catch {
- // ignore
- }
- }
return prev.filter((p) => p.id !== projectId);
});
- }, []);
+ }, [projects]);Note: this requires adding projects to the dependency array since we now read projects outside the updater.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const deleteProject = useCallback((projectId: string) => { | |
| setProjects((prev) => { | |
| const project = prev.find((p) => p.id === projectId); | |
| if (project && project.chatIds.length > 0) { | |
| // Mark project's chats as deleted in localStorage | |
| try { | |
| const existing = JSON.parse(localStorage.getItem(DELETED_CHATS_KEY) || "[]"); | |
| const updated = [...new Set([...existing, ...project.chatIds])]; | |
| localStorage.setItem(DELETED_CHATS_KEY, JSON.stringify(updated)); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| return prev.filter((p) => p.id !== projectId); | |
| }); | |
| }, []); | |
| const deleteProject = useCallback((projectId: string) => { | |
| const project = projects.find((p) => p.id === projectId); | |
| if (project && project.chatIds.length > 0) { | |
| try { | |
| const existing = JSON.parse(localStorage.getItem(DELETED_CHATS_KEY) || "[]"); | |
| const updated = [...new Set([...existing, ...project.chatIds])]; | |
| localStorage.setItem(DELETED_CHATS_KEY, JSON.stringify(updated)); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| setProjects((prev) => { | |
| return prev.filter((p) => p.id !== projectId); | |
| }); | |
| }, [projects]); |
🤖 Prompt for AI Agents
In `@frontend/src/state/ProjectsContext.tsx` around lines 130 - 145, The
setProjects updater in deleteProject currently performs a localStorage write
(using DELETED_CHATS_KEY) inside the state updater, which is a side effect and
unsafe in StrictMode; refactor deleteProject to first find the target project
from the outer projects state (e.g. const project = projects.find(p => p.id ===
projectId)), perform the localStorage read/merge/write for project.chatIds
outside the setProjects callback (wrapped in try/catch), then call
setProjects(prev => prev.filter(p => p.id !== projectId)) to update state; also
add projects to the deleteProject dependency array so the finder uses current
state.
|
not bad, appreciate you doing that. i have a few thoughts:
|
…roject in submenu
Deploying maple with
|
| Latest commit: |
b7d4bc2
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e2cd86cb.maple-ca8.pages.dev |
| Branch Preview URL: | https://feature-projects-prototype.maple-ca8.pages.dev |
For the UX perspective, it would be awesome to add multiple files into the context. From the technical perspective, I can understand the limitation right now. The feature is still very useful without the file attachment feature. Would we be able to do a single file or should we just leave it to the user to attach the file to a single chat like they already do?
Yeah I can see that. Both ChatGPT and Claude have it above like this, so I was following their lead. That said, putting the chat box below could be fine.
Yes, I tested it on mobile and just pushed a fix for a small layout issue. Everything else works great on mobile. |
|
I'm not a fan of attempting to extract just the chatbox out of the main display. This isn't an extractable component and it's designed this way for a reason. I think I would prefer there to be something like a folder icon on the chatbox that you can auto assign a new chat to a project. And maybe you can have a "new chat" button on the project page that jumps to the main page but preloads the correct project. |
I'm good with that solution. I'll adjust the prototype with that behavior. |
|
Made improvements to the UX:
|
|
hmm, looks like cloudflare didn't publish it? can you do an empty commit and push when you have a chance |
Move project selection from a duplicated chat input on the Project Page into a folder icon dropdown in the main chat toolbar. Simplify the Project Page to show a "New chat" button instead. Collapse project chats in the sidebar by default, expanding only when active. Add date subtitles and separators to the project chat list. Remove Files section.
6ffb5a5 to
b7d4bc2
Compare
yep, done |
Projects Prototype (Frontend-only, localStorage)
Video Walkthrough:
maple-projects-poc-walkthrough.mov
Adds a complete Projects feature prototype to evaluate the UX before building
the backend. All data is persisted in localStorage — no API changes.
What it does
the sidebar or project detail page
within a project (chats don't share history with each other, but they all
use the project's custom instructions and files as context)
are available across all its chats
remove from a project, consistent everywhere (sidebar + project page)
independently collapsible with persisted state
the project
How it works
Projects are stored in localStorage (
maple_projects). When a chat isstarted from a project page, a
project_idURL param triggersauto-assignment after conversation creation via a custom event. Chats
assigned to projects are filtered out of the Recents list. Deleting a project
marks its chats as deleted.
New files (10)
state/ProjectsContext.tsx+state/useProjects.ts— state managementcomponents/ChatContextMenu.tsx— shared context menu used across sidebarand project page
components/projects/— ProjectsList, ProjectDetailPage, and 4 dialogcomponents
routes/_auth.project.$projectId.tsx— project detail routeModified files (5)
app.tsx— added ProjectsProviderSidebar.tsx— projects section, create-project-for-chat event handlingChatHistoryList.tsx— collapsible Recents, exclude project chats, sharedcontext menu
UnifiedChat.tsx— auto-assign chat to project via URL paramSummary by CodeRabbit
Release Notes