Conversation
WalkthroughFrontend adopts Biome, refactors conversation and project UIs, adds transcript copy/download/retranscribe features, and simplifies transcript chunk rendering. Backend adds project cloning, PII redaction through transcription pipeline, Vertex AI integration, config updates, and scheduler guards. New tests added. Various scripts, settings, and configs updated. Changes
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ 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 |
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
echo/frontend/src/routes/project/ProjectsHome.tsx (1)
47-49: Drop the orphaned gridParent ref.
We nuked the grid view but still spin up a seconduseAutoAnimate()ref that never gets used—let’s delete it and keep the hook count tight.- const [gridParent] = useAutoAnimate(); const [listParent] = useAutoAnimate();
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
⛔ Files ignored due to path filters (3)
echo/frontend/pnpm-lock.yamlis excluded by!**/pnpm-lock.yamlecho/server/requirements-dev.lockis excluded by!**/*.lockecho/server/requirements.lockis excluded by!**/*.lock
📒 Files selected for processing (38)
echo/.devcontainer/devcontainer.json(1 hunks)echo/.vscode/sessions.json(1 hunks)echo/.vscode/settings.json(1 hunks)echo/check-later.md(1 hunks)echo/frontend/biome.json(1 hunks)echo/frontend/package.json(1 hunks)echo/frontend/src/components/conversation/ConversationAccordion.tsx(4 hunks)echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx(1 hunks)echo/frontend/src/components/conversation/CopyConversationTranscript.tsx(1 hunks)echo/frontend/src/components/conversation/DownloadConversationTranscript.tsx(1 hunks)echo/frontend/src/components/conversation/RetranscribeConversation.tsx(1 hunks)echo/frontend/src/components/conversation/hooks/index.ts(1 hunks)echo/frontend/src/components/project/ProjectBasicEdit.tsx(2 hunks)echo/frontend/src/components/project/ProjectConversationStatusSection.tsx(1 hunks)echo/frontend/src/components/project/ProjectDangerZone.tsx(1 hunks)echo/frontend/src/components/project/ProjectExportSection.tsx(1 hunks)echo/frontend/src/components/project/ProjectListSkeleton.tsx(1 hunks)echo/frontend/src/components/project/ProjectSettingsSection.tsx(1 hunks)echo/frontend/src/components/project/ProjectUploadSection.tsx(1 hunks)echo/frontend/src/components/project/hooks/index.ts(2 hunks)echo/frontend/src/lib/api.ts(1 hunks)echo/frontend/src/routes/project/ProjectRoutes.tsx(2 hunks)echo/frontend/src/routes/project/ProjectsHome.tsx(2 hunks)echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx(1 hunks)echo/server/dembrane/api/conversation.py(2 hunks)echo/server/dembrane/api/project.py(2 hunks)echo/server/dembrane/config.py(2 hunks)echo/server/dembrane/conversation_utils.py(2 hunks)echo/server/dembrane/scheduler.py(2 hunks)echo/server/dembrane/service/project.py(4 hunks)echo/server/dembrane/tasks.py(13 hunks)echo/server/dembrane/transcribe.py(9 hunks)echo/server/prod-worker.sh(0 hunks)echo/server/prompt_templates/transcript_correction_workflow.en.jinja(1 hunks)echo/server/pyproject.toml(1 hunks)echo/server/run-worker-cpu.sh(1 hunks)echo/server/run-worker.sh(1 hunks)echo/server/tests/service/test_project_service.py(1 hunks)
💤 Files with no reviewable changes (1)
- echo/server/prod-worker.sh
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-10-07T10:59:13.893Z
Learnt from: dtrn2048
PR: Dembrane/echo#322
File: echo/server/run-worker-cpu.sh:3-3
Timestamp: 2025-10-07T10:59:13.893Z
Learning: In the echo repository, run-worker-cpu.sh is the local development worker script and can use fewer processes (e.g., --processes 2), while prod-worker-cpu.sh is the production worker script that should use --processes 8 --threads 1 for LightRAG processing due to its internal locking requirements.
Applied to files:
echo/server/run-worker-cpu.sh
📚 Learning: 2025-08-19T10:22:55.323Z
Learnt from: ussaama
PR: Dembrane/echo#266
File: echo/frontend/src/components/conversation/ConversationAccordion.tsx:675-678
Timestamp: 2025-08-19T10:22:55.323Z
Learning: In echo/frontend/src/components/conversation/hooks/index.ts, the useConversationsCountByProjectId hook uses useSuspenseQuery with Directus aggregate, which always returns string numbers like "0", "1", "2" and suspends during loading instead of returning undefined. Therefore, Number(conversationsCountQuery.data) ?? 0 is safe and the Number() conversion is necessary for type conversion from string to number.
Applied to files:
echo/frontend/src/components/conversation/hooks/index.ts
📚 Learning: 2025-08-19T10:22:55.323Z
Learnt from: ussaama
PR: Dembrane/echo#266
File: echo/frontend/src/components/conversation/ConversationAccordion.tsx:675-678
Timestamp: 2025-08-19T10:22:55.323Z
Learning: In echo/frontend/src/components/conversation/hooks/index.ts, the useConversationsCountByProjectId hook uses regular useQuery (not useSuspenseQuery), which means conversationsCountQuery.data can be undefined during loading states. When using Number(conversationsCountQuery.data) ?? 0, this creates NaN because Number(undefined) = NaN and NaN is not nullish, so the fallback doesn't apply. The correct pattern is Number(conversationsCountQuery.data ?? 0) to ensure the fallback happens before type conversion.
Applied to files:
echo/frontend/src/components/conversation/hooks/index.ts
🧬 Code graph analysis (22)
echo/server/dembrane/api/conversation.py (1)
echo/server/dembrane/tasks.py (1)
task_process_conversation_chunk(512-560)
echo/server/tests/service/test_project_service.py (1)
echo/server/dembrane/service/project.py (5)
create(56-76)create_tags_and_link(85-110)create_shallow_clone(112-180)get_by_id_or_raise(22-54)delete(78-83)
echo/frontend/src/components/project/ProjectConversationStatusSection.tsx (2)
echo/frontend/src/components/project/ProjectSettingsSection.tsx (1)
ProjectSettingsSection(21-55)echo/frontend/src/components/report/ConversationStatusTable.tsx (1)
ConversationStatusTable(15-101)
echo/frontend/src/components/conversation/CopyConversationTranscript.tsx (1)
echo/frontend/src/components/conversation/hooks/index.ts (1)
useGetConversationTranscriptStringMutation(621-626)
echo/frontend/src/components/project/hooks/index.ts (1)
echo/frontend/src/lib/api.ts (1)
cloneProjectById(223-245)
echo/server/dembrane/api/project.py (1)
echo/server/dembrane/service/project.py (1)
create_shallow_clone(112-180)
echo/frontend/src/components/project/ProjectUploadSection.tsx (2)
echo/frontend/src/components/project/ProjectSettingsSection.tsx (1)
ProjectSettingsSection(21-55)echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx (1)
UploadConversationDropzone(248-815)
echo/server/dembrane/conversation_utils.py (1)
echo/server/dembrane/utils.py (1)
get_utc_timestamp(49-50)
echo/frontend/src/components/conversation/DownloadConversationTranscript.tsx (1)
echo/frontend/src/components/conversation/hooks/index.ts (1)
useGetConversationTranscriptStringMutation(621-626)
echo/server/dembrane/transcribe.py (1)
echo/server/dembrane/s3.py (1)
get_signed_url(143-151)
echo/server/dembrane/tasks.py (4)
echo/server/dembrane/transcribe.py (1)
transcribe_conversation_chunk(497-578)echo/server/dembrane/api/stateless.py (2)
InsertRequest(78-81)insert_item(90-128)echo/server/dembrane/audio_lightrag/utils/async_utils.py (1)
run_async_in_new_loop(59-95)echo/server/dembrane/audio_lightrag/services/contextualizer.py (2)
get_contextualizer(97-102)contextualize(18-90)
echo/frontend/src/components/conversation/RetranscribeConversation.tsx (2)
echo/frontend/src/components/conversation/hooks/index.ts (1)
useRetranscribeConversationMutation(590-619)echo/frontend/src/hooks/useI18nNavigate.ts (1)
useI18nNavigate(5-33)
echo/frontend/src/components/project/ProjectExportSection.tsx (1)
echo/frontend/src/components/project/ProjectSettingsSection.tsx (1)
ProjectSettingsSection(21-55)
echo/frontend/src/components/project/ProjectBasicEdit.tsx (1)
echo/frontend/src/components/project/ProjectSettingsSection.tsx (1)
ProjectSettingsSection(21-55)
echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx (2)
echo/frontend/src/components/conversation/hooks/index.ts (1)
useConversationChunkContentUrl(553-572)echo/frontend/src/components/chat/BaseMessage.tsx (1)
BaseMessage(14-55)
echo/frontend/src/routes/project/ProjectsHome.tsx (1)
echo/frontend/src/components/project/ProjectListSkeleton.tsx (1)
ProjectListSkeleton(9-38)
echo/frontend/src/routes/project/ProjectRoutes.tsx (3)
echo/frontend/src/components/project/ProjectUploadSection.tsx (1)
ProjectUploadSection(10-28)echo/frontend/src/components/project/ProjectExportSection.tsx (1)
ProjectExportSection(11-36)echo/frontend/src/lib/api.ts (1)
getProjectTranscriptsLink(220-221)
echo/frontend/src/components/conversation/hooks/index.ts (2)
echo/frontend/src/lib/directus.ts (1)
directus(6-14)echo/frontend/src/lib/api.ts (8)
deleteConversationById(1081-1090)addChatContext(940-952)deleteChatContext(954-966)getConversationChunkContentLink(885-890)apiNoAuth(19-19)getConversationContentLink(877-883)retranscribeConversation(922-934)getConversationTranscriptString(914-920)
echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx (5)
echo/frontend/src/components/conversation/hooks/index.ts (2)
useConversationById(783-836)useInfiniteConversationChunks(34-83)echo/frontend/src/components/conversation/DownloadConversationTranscript.tsx (1)
DownloadConversationTranscriptModalActionIcon(16-37)echo/frontend/src/components/conversation/CopyConversationTranscript.tsx (1)
CopyConversationTranscriptActionIcon(7-47)echo/frontend/src/components/conversation/RetranscribeConversation.tsx (1)
RetranscribeConversationModalActionIcon(25-50)echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx (1)
ConversationChunkAudioTranscript(7-78)
echo/frontend/src/components/conversation/ConversationAccordion.tsx (9)
echo/frontend/src/components/chat/hooks/index.ts (1)
useProjectChatContext(111-117)echo/frontend/src/components/conversation/hooks/index.ts (5)
useAddChatContextMutation(282-413)useDeleteChatContextMutation(415-551)useMoveConversationMutation(251-280)useInfiniteConversationsByProjectId(838-957)useConversationsCountByProjectId(959-984)echo/frontend/src/components/project/hooks/index.ts (2)
useInfiniteProjects(215-248)useProjectById(250-275)echo/frontend/src/components/common/NavigationButton.tsx (1)
NavigationButton(28-118)echo/frontend/src/lib/utils.ts (1)
cn(4-6)echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx (1)
UploadConversationDropzone(248-815)echo/frontend/src/components/conversation/AutoSelectConversations.tsx (1)
AutoSelectConversations(27-193)echo/frontend/src/components/common/i18nLink.tsx (1)
I18nLink(6-26)echo/frontend/src/components/common/BaseSkeleton.tsx (1)
BaseSkeleton(11-25)
echo/frontend/src/components/project/ProjectDangerZone.tsx (3)
echo/frontend/src/components/project/hooks/index.ts (2)
useDeleteProjectByIdMutation(25-38)useCloneProjectByIdMutation(40-71)echo/frontend/src/hooks/useI18nNavigate.ts (1)
useI18nNavigate(5-33)echo/frontend/src/components/project/ProjectSettingsSection.tsx (1)
ProjectSettingsSection(21-55)
echo/frontend/src/lib/api.ts (3)
echo/frontend/src/config.ts (2)
API_BASE_URL(7-7)USE_PARTICIPANT_ROUTER(1-2)echo/frontend/src/lib/directus.ts (2)
directusContent(19-21)directus(6-14)echo/frontend/src/lib/typesDirectusContent.ts (1)
EchoPortalTutorial(753-764)
🪛 markdownlint-cli2 (0.18.1)
echo/check-later.md
1-1: First line in a file should be a top-level heading
(MD041, first-line-heading, first-line-h1)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Cursor Bugbot
- GitHub Check: ci-check-server
🔇 Additional comments (9)
echo/check-later.md (1)
1-7: LGTM!Internal notes file for tracking TODO items. No functional concerns here.
echo/frontend/package.json (1)
88-88: LGTM!Biome dependency addition aligns with the tooling migration. Clean integration with the rest of the biome config stack.
echo/frontend/biome.json (1)
1-37: LGTM!Solid biome config. The useUniqueElementIds exception is reasonable for dynamic React components.
echo/.vscode/settings.json (1)
24-40: LGTM!Biome integration is clean. Format-on-save and code actions are properly configured.
echo/frontend/src/components/project/hooks/index.ts (1)
40-71: LGTM!Mutation hook is well-structured with proper query invalidation and error handling. The success path correctly invalidates the projects list, source project, and new project queries to ensure fresh data.
echo/server/dembrane/service/project.py (2)
56-76: LGTM!The create method enhancement with **kwargs is clean and flexible. Proper propagation of extra fields to Directus.
112-180: Verify cloned project ownership.The create_shallow_clone method copies directus_user_id from the source project (line 129), meaning the cloned project will have the same owner as the original. This might not be the intended behavior if users are cloning projects they don't own.
Consider whether the clone should:
- Inherit the original owner (current behavior)
- Be owned by the user performing the clone
- Be configurable via overrides
If option 2 is preferred, the API endpoint should pass the authenticated user's ID as an override.
echo/server/pyproject.toml (1)
91-91: No known vulnerabilities in google-cloud-aiplatform 1.120.*; LGTM.echo/frontend/src/components/conversation/ConversationAccordion.tsx (1)
688-691: Fix the conversations count fallback
Number(conversationsCountQuery.data) ?? 0returnsNaNduring the loading path becauseNumber(undefined)isNaN, andNaNisn’t nullish so the??never fires. That breaks the “Conversations” badge and anything relying on this count. Coalesce before casting, e.g.Number(conversationsCountQuery.data ?? 0). Based on learnings.⛔ Skipped due to learnings
Learnt from: ussaama PR: Dembrane/echo#266 File: echo/frontend/src/components/conversation/ConversationAccordion.tsx:675-678 Timestamp: 2025-08-19T10:22:55.323Z Learning: In echo/frontend/src/components/conversation/hooks/index.ts, the useConversationsCountByProjectId hook uses regular useQuery (not useSuspenseQuery), which means conversationsCountQuery.data can be undefined during loading states. When using Number(conversationsCountQuery.data) ?? 0, this creates NaN because Number(undefined) = NaN and NaN is not nullish, so the fallback doesn't apply. The correct pattern is Number(conversationsCountQuery.data ?? 0) to ensure the fallback happens before type conversion.Learnt from: ussaama PR: Dembrane/echo#266 File: echo/frontend/src/components/conversation/ConversationAccordion.tsx:675-678 Timestamp: 2025-08-19T10:22:55.323Z Learning: In echo/frontend/src/components/conversation/hooks/index.ts, the useConversationsCountByProjectId hook uses useSuspenseQuery with Directus aggregate, which always returns string numbers like "0", "1", "2" and suspends during loading instead of returning undefined. Therefore, Number(conversationsCountQuery.data) ?? 0 is safe and the Number() conversion is necessary for type conversion from string to number.
| const preCopy = useCallback(async () => { | ||
| getConversationTranscriptStringMutation.mutate(conversationId, { | ||
| onSuccess: (data) => { | ||
| setTranscript(data); | ||
| }, | ||
| }); | ||
| }, [getConversationTranscriptStringMutation, conversationId]); | ||
|
|
||
| return ( | ||
| <CopyButton value={transcript}> | ||
| {({ copied, copy }) => ( | ||
| <Tooltip label={copied ? t`Copied` : t`Copy to clipboard`}> | ||
| <ActionIcon | ||
| variant="transparent" | ||
| color={copied ? "blue" : "gray"} | ||
| onClick={async () => { | ||
| await preCopy(); | ||
| // hmm this is a hack to wait for the transcript to be set. not rly a best practice | ||
| // i rly wanted to use the CopyButton haha | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| copy(); | ||
| }} | ||
| disabled={getConversationTranscriptStringMutation.isPending} | ||
| > |
There was a problem hiding this comment.
Kill the 500 ms sleep hack
We’re firing mutate and then praying the transcript lands within 500 ms before calling copy(). On a slow round-trip—or on error—we end up copying an empty/stale string. Let’s await the fetch with mutateAsync and pass the resolved payload straight into copy(), no timers, no races.
- const [transcript, setTranscript] = useState<string>("");
-
- const preCopy = useCallback(async () => {
- getConversationTranscriptStringMutation.mutate(conversationId, {
- onSuccess: (data) => {
- setTranscript(data);
- },
- });
- }, [getConversationTranscriptStringMutation, conversationId]);
+ const [transcript, setTranscript] = useState<string>("");
@@
- onClick={async () => {
- await preCopy();
- // hmm this is a hack to wait for the transcript to be set. not rly a best practice
- // i rly wanted to use the CopyButton haha
- await new Promise((resolve) => setTimeout(resolve, 500));
- copy();
- }}
+ onClick={async () => {
+ try {
+ const data =
+ await getConversationTranscriptStringMutation.mutateAsync(
+ conversationId,
+ );
+ setTranscript(data);
+ copy(data);
+ } catch (err) {
+ console.error(err);
+ }
+ }}🤖 Prompt for AI Agents
In echo/frontend/src/components/conversation/CopyConversationTranscript.tsx
around lines 17 to 40, remove the 500ms sleep hack and instead await the server
response: replace the current mutate call with mutateAsync (or call
getConversationTranscriptStringMutation.mutateAsync directly in the onClick),
await the resolved transcript payload, then call copy(resolvedTranscript) (or
setTranscript(resolvedTranscript) and then call copy with that value). Ensure
you remove the setTimeout and the separate preCopy wait, propagate errors (or
handle them) and keep the button disabled while
getConversationTranscriptStringMutation.isPending to avoid races.
| mutationFn: async ({ | ||
| conversationId, | ||
| targetProjectId, | ||
| }: { | ||
| conversationId: string; | ||
| targetProjectId: string; | ||
| }) => { | ||
| try { | ||
| await directus.request( | ||
| updateItem("conversation", conversationId, { | ||
| project_id: targetProjectId, | ||
| }), | ||
| ); | ||
| } catch (error) { | ||
| toast.error("Failed to move conversation."); | ||
| } | ||
| }, | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ["conversations"] }); | ||
| queryClient.invalidateQueries({ queryKey: ["projects"] }); | ||
| toast.success("Conversation moved successfully"); | ||
| }, | ||
| onError: (error: Error) => { | ||
| toast.error("Failed to move conversation: " + error.message); | ||
| }, |
There was a problem hiding this comment.
Don’t swallow the move-conversation failure
mutationFn catches the Directus failure, shows a toast, and then quietly resolves. React Query sees that as “success”, so onSuccess and the success toast still fire. That’s a hard false-positive. Let the error propagate (or rethrow) so onError handles it and onSuccess stays dark.
- mutationFn: async ({
+ mutationFn: async ({
conversationId,
targetProjectId,
}: {
conversationId: string;
targetProjectId: string;
}) => {
- try {
- await directus.request(
- updateItem("conversation", conversationId, {
- project_id: targetProjectId,
- }),
- );
- } catch (error) {
- toast.error("Failed to move conversation.");
- }
+ await directus.request(
+ updateItem("conversation", conversationId, {
+ project_id: targetProjectId,
+ }),
+ );
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["conversations"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
toast.success("Conversation moved successfully");
},
onError: (error: Error) => {
toast.error("Failed to move conversation: " + error.message);
},📝 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.
| mutationFn: async ({ | |
| conversationId, | |
| targetProjectId, | |
| }: { | |
| conversationId: string; | |
| targetProjectId: string; | |
| }) => { | |
| try { | |
| await directus.request( | |
| updateItem("conversation", conversationId, { | |
| project_id: targetProjectId, | |
| }), | |
| ); | |
| } catch (error) { | |
| toast.error("Failed to move conversation."); | |
| } | |
| }, | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ["conversations"] }); | |
| queryClient.invalidateQueries({ queryKey: ["projects"] }); | |
| toast.success("Conversation moved successfully"); | |
| }, | |
| onError: (error: Error) => { | |
| toast.error("Failed to move conversation: " + error.message); | |
| }, | |
| mutationFn: async ({ | |
| conversationId, | |
| targetProjectId, | |
| }: { | |
| conversationId: string; | |
| targetProjectId: string; | |
| }) => { | |
| await directus.request( | |
| updateItem("conversation", conversationId, { | |
| project_id: targetProjectId, | |
| }), | |
| ); | |
| }, | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ["conversations"] }); | |
| queryClient.invalidateQueries({ queryKey: ["projects"] }); | |
| toast.success("Conversation moved successfully"); | |
| }, | |
| onError: (error: Error) => { | |
| toast.error("Failed to move conversation: " + error.message); | |
| }, |
🤖 Prompt for AI Agents
In echo/frontend/src/components/conversation/hooks/index.ts around lines
254-278, the mutationFn currently catches Directus errors, shows a toast, and
returns normally which makes React Query treat the operation as a success;
remove the swallowing behavior so the error propagates: either drop the
try/catch entirely or rethrow the caught error after calling toast.error (and
avoid duplicating success/failure toasts in both places), so that React Query
calls onError instead of onSuccess when the request fails.
| const handleRetranscribe = async () => { | ||
| if (!conversationId || !newConversationName.trim()) return; | ||
| const { new_conversation_id } = await retranscribeMutation.mutateAsync({ | ||
| conversationId, | ||
| newConversationName: newConversationName.trim(), | ||
| usePiiRedaction, | ||
| }); | ||
| if (new_conversation_id) { | ||
| onClose(); | ||
| toast.success( | ||
| t`Retranscription started. New conversation will be available soon.`, | ||
| { | ||
| action: { | ||
| label: t`Go to new conversation`, | ||
| actionButtonStyle: { | ||
| color: "blue", | ||
| }, | ||
| onClick: () => { | ||
| navigate( | ||
| `/projects/${projectId}/conversations/${new_conversation_id}/transcript`, | ||
| ); | ||
| }, | ||
| }, | ||
| }, | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Catch the mutateAsync rejection
mutateAsync still rejects after onError fires, so this async handler bubbles an unhandled rejection straight into React, spamming the console and tripping test suites. Wrap the call in try/catch (or use void retranscribeMutation.mutate) so we keep the toast but don’t crash the click path.
const handleRetranscribe = async () => {
- if (!conversationId || !newConversationName.trim()) return;
- const { new_conversation_id } = await retranscribeMutation.mutateAsync({
- conversationId,
- newConversationName: newConversationName.trim(),
- usePiiRedaction,
- });
+ if (!conversationId || !newConversationName.trim()) return;
+ try {
+ const { new_conversation_id } = await retranscribeMutation.mutateAsync({
+ conversationId,
+ newConversationName: newConversationName.trim(),
+ usePiiRedaction,
+ });
+ if (new_conversation_id) {
+ onClose();
+ toast.success(
+ t`Retranscription started. New conversation will be available soon.`,
+ {
+ action: {
+ label: t`Go to new conversation`,
+ actionButtonStyle: {
+ color: "blue",
+ },
+ onClick: () => {
+ navigate(
+ `/projects/${projectId}/conversations/${new_conversation_id}/transcript`,
+ );
+ },
+ },
+ },
+ );
+ }
+ } catch {
+ // no-op: toast already fired in onError
+ }
- if (new_conversation_id) {
- onClose();
- toast.success(
- t`Retranscription started. New conversation will be available soon.`,
- {
- action: {
- label: t`Go to new conversation`,
- actionButtonStyle: {
- color: "blue",
- },
- onClick: () => {
- navigate(
- `/projects/${projectId}/conversations/${new_conversation_id}/transcript`,
- );
- },
- },
- },
- );
- }
};📝 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 handleRetranscribe = async () => { | |
| if (!conversationId || !newConversationName.trim()) return; | |
| const { new_conversation_id } = await retranscribeMutation.mutateAsync({ | |
| conversationId, | |
| newConversationName: newConversationName.trim(), | |
| usePiiRedaction, | |
| }); | |
| if (new_conversation_id) { | |
| onClose(); | |
| toast.success( | |
| t`Retranscription started. New conversation will be available soon.`, | |
| { | |
| action: { | |
| label: t`Go to new conversation`, | |
| actionButtonStyle: { | |
| color: "blue", | |
| }, | |
| onClick: () => { | |
| navigate( | |
| `/projects/${projectId}/conversations/${new_conversation_id}/transcript`, | |
| ); | |
| }, | |
| }, | |
| }, | |
| ); | |
| } | |
| }; | |
| const handleRetranscribe = async () => { | |
| if (!conversationId || !newConversationName.trim()) return; | |
| try { | |
| const { new_conversation_id } = await retranscribeMutation.mutateAsync({ | |
| conversationId, | |
| newConversationName: newConversationName.trim(), | |
| usePiiRedaction, | |
| }); | |
| if (new_conversation_id) { | |
| onClose(); | |
| toast.success( | |
| t`Retranscription started. New conversation will be available soon.`, | |
| { | |
| action: { | |
| label: t`Go to new conversation`, | |
| actionButtonStyle: { | |
| color: "blue", | |
| }, | |
| onClick: () => { | |
| navigate( | |
| `/projects/${projectId}/conversations/${new_conversation_id}/transcript`, | |
| ); | |
| }, | |
| }, | |
| }, | |
| ); | |
| } | |
| } catch { | |
| // no-op: toast already fired in onError | |
| } | |
| }; |
🤖 Prompt for AI Agents
In echo/frontend/src/components/conversation/RetranscribeConversation.tsx around
lines 75 to 101, the async handler calls retranscribeMutation.mutateAsync
without catching rejections which lets errors bubble into React; wrap the
mutateAsync call in a try/catch (or replace with void
retranscribeMutation.mutate) so rejection is handled locally: call mutateAsync
inside try, proceed with onClose and toast only on successful response, and
handle/log the error in catch (e.g., show toast.error or noop) to prevent
unhandled promise rejections.
| const handleDelete = () => { | ||
| if ( | ||
| window.confirm( | ||
| t`By deleting this project, you will delete all the data associated with it. This action cannot be undone. Are you ABSOLUTELY sure you want to delete this project?`, | ||
| ) | ||
| ) { | ||
| deleteProjectByIdMutation.mutate(project.id); | ||
| navigate(`/projects`); | ||
| } |
There was a problem hiding this comment.
Do not bail to /projects before the delete mutation resolves
Right now we fire mutate and instantly navigate away. If the delete blows up (network, auth, etc.) we still leave the page and the project survives, leaving stale UI + false “success” toast. Wait for the async to finish (use mutateAsync) and only redirect on success; keep the modal open otherwise.
- const handleDelete = () => {
- if (
- window.confirm(
- t`By deleting this project, you will delete all the data associated with it. This action cannot be undone. Are you ABSOLUTELY sure you want to delete this project?`,
- )
- ) {
- deleteProjectByIdMutation.mutate(project.id);
- navigate(`/projects`);
- }
- };
+ const handleDelete = async () => {
+ const confirmed = window.confirm(
+ t`By deleting this project, you will delete all the data associated with it. This action cannot be undone. Are you ABSOLUTELY sure you want to delete this project?`,
+ );
+ if (!confirmed) return;
+
+ try {
+ await deleteProjectByIdMutation.mutateAsync(project.id);
+ closeDeleteModal();
+ navigate(`/projects`);
+ } catch {
+ /* mutation hook already surfaces the toast */
+ }
+ };📝 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 handleDelete = () => { | |
| if ( | |
| window.confirm( | |
| t`By deleting this project, you will delete all the data associated with it. This action cannot be undone. Are you ABSOLUTELY sure you want to delete this project?`, | |
| ) | |
| ) { | |
| deleteProjectByIdMutation.mutate(project.id); | |
| navigate(`/projects`); | |
| } | |
| const handleDelete = async () => { | |
| const confirmed = window.confirm( | |
| t`By deleting this project, you will delete all the data associated with it. This action cannot be undone. Are you ABSOLUTELY sure you want to delete this project?`, | |
| ); | |
| if (!confirmed) return; | |
| try { | |
| await deleteProjectByIdMutation.mutateAsync(project.id); | |
| closeDeleteModal(); | |
| navigate(`/projects`); | |
| } catch { | |
| /* mutation hook already surfaces the toast */ | |
| } | |
| }; |
🤖 Prompt for AI Agents
In echo/frontend/src/components/project/ProjectDangerZone.tsx around lines 48 to
56, the handler calls deleteProjectByIdMutation.mutate and immediately navigates
away; change it to use mutateAsync and await the promise so navigation only
happens on success. Wrap the await call in a try/catch: on success navigate to
/projects, on failure keep the modal open and show or rethrow the mutation error
(or display an error toast). Also set/clear a local isDeleting state (or use
mutation.isLoading) to disable the UI while awaiting so the user can’t
re-trigger the action.
| type ProjectSettingsSectionProps = { | ||
| title: ReactNode; | ||
| description?: ReactNode; | ||
| headerRight?: ReactNode; | ||
| children: ReactNode; | ||
| variant?: "default" | "danger"; | ||
| align?: "start" | "stretch"; | ||
| id?: string; | ||
| }; | ||
|
|
||
| export const ProjectSettingsSection = ({ | ||
| title, | ||
| description, | ||
| headerRight, | ||
| children, | ||
| align = "stretch", | ||
| id, | ||
| }: ProjectSettingsSectionProps) => { | ||
| return ( | ||
| <Paper | ||
| id={id} | ||
| radius="md" | ||
| withBorder = {false} | ||
| p={{ base: "1.25rem", md: "1.75rem" }} | ||
| > | ||
| <Stack gap="1.5rem"> | ||
| <Group justify="space-between" align="flex-start"> | ||
| <Stack gap="0.4rem"> | ||
| <Title order={2}>{title}</Title> | ||
| {description && ( | ||
| <Text size="sm" c="dimmed"> | ||
| {description} | ||
| </Text> | ||
| )} | ||
| </Stack> | ||
| {headerRight} | ||
| </Group> | ||
|
|
||
| <Stack gap="1.25rem" align={align === "start" ? "flex-start" : "stretch"}> | ||
| {children} | ||
| </Stack> | ||
| </Stack> | ||
| </Paper> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Wire up the variant prop or remove it before shipping.
Right now we advertise variant?: "default" | "danger" but never branch on it, so passing variant="danger" buys literally nothing. That's a broken promise for anyone expecting destructive sections to stand out. Either implement the styling or retract the prop until it’s real—shipping a dead-on-arrival API is not how we roll.
Here’s the minimal diff to retract it until the styling lands:
-import {
- Group,
- Paper,
- Stack,
- Text,
- Title,
- useMantineTheme,
-} from "@mantine/core";
+import { Group, Paper, Stack, Text, Title } from "@mantine/core";
@@
- variant?: "default" | "danger";
align?: "start" | "stretch";🤖 Prompt for AI Agents
In echo/frontend/src/components/project/ProjectSettingsSection.tsx around lines
11 to 55, the declared prop variant?: "default" | "danger" is never used; remove
it from the public API by deleting it from ProjectSettingsSectionProps, removing
variant from the component destructuring, and deleting any internal references
(none currently) so the prop is no longer accepted; then run TypeScript/grep to
find and fix any call sites passing variant (either remove the prop there or
replace it with an equivalent styling change), and update any related tests or
stories that referenced variant.
| @ProjectRouter.post("/{project_id}/clone") | ||
| async def clone_project(project_id: str, body: CloneProjectRequestBodySchema) -> str: | ||
| logger.info(f"Cloning project {project_id}") | ||
|
|
||
| overrides = {} | ||
| if body.name: | ||
| overrides["name"] = body.name | ||
| if body.language: | ||
| overrides["language"] = body.language | ||
|
|
||
| new_project_id = project_service.create_shallow_clone( | ||
| project_id, | ||
| with_tags=True, | ||
| **overrides, | ||
| ) | ||
|
|
||
| return new_project_id |
There was a problem hiding this comment.
Add error handling for service exceptions.
The service call to create_shallow_clone can raise ProjectNotFoundException or ValueError (when id is in overrides), but these aren't caught. This will result in 500 errors instead of proper HTTP responses.
Apply this diff to add error handling:
+from dembrane.service.project import ProjectNotFoundException
+
@ProjectRouter.post("/{project_id}/clone")
async def clone_project(project_id: str, body: CloneProjectRequestBodySchema) -> str:
logger.info(f"Cloning project {project_id}")
overrides = {}
if body.name:
overrides["name"] = body.name
if body.language:
overrides["language"] = body.language
- new_project_id = project_service.create_shallow_clone(
- project_id,
- with_tags=True,
- **overrides,
- )
+ try:
+ new_project_id = project_service.create_shallow_clone(
+ project_id,
+ with_tags=True,
+ **overrides,
+ )
+ except ProjectNotFoundException as e:
+ raise HTTPException(status_code=404, detail="Project not found") from e
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
return new_project_id🤖 Prompt for AI Agents
In echo/server/dembrane/api/project.py around lines 357-373, the call to
project_service.create_shallow_clone can raise ProjectNotFoundException or
ValueError but those exceptions are not handled; wrap the service call in a
try/except that catches ProjectNotFoundException and raises
fastapi.HTTPException(status_code=404, detail="Project not found") and catches
ValueError to raise HTTPException(status_code=400, detail=str(error)) (also log
the errors when caught); ensure you import HTTPException and the
ProjectNotFoundException type at the top of the file and preserve the returned
new_project_id as the endpoint response.
Critical: Missing authorization check.
The clone_project endpoint lacks authentication/authorization checks. Any user can clone any project, which is a significant security issue. Endpoints like create_project (line 50) use DependencyDirectusSession for auth, but this endpoint doesn't.
Apply this diff to add authentication and authorization:
@ProjectRouter.post("/{project_id}/clone")
-async def clone_project(project_id: str, body: CloneProjectRequestBodySchema) -> str:
+async def clone_project(
+ project_id: str,
+ body: CloneProjectRequestBodySchema,
+ auth: DependencyDirectusSession,
+ db: DependencyInjectDatabase,
+) -> str:
logger.info(f"Cloning project {project_id}")
+
+ # Verify project exists and user has access
+ project = db.get(ProjectModel, project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ if not auth.is_admin and project.directus_user_id != auth.user_id:
+ raise HTTPException(status_code=403, detail="User does not have access to this project")
overrides = {}📝 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.
| @ProjectRouter.post("/{project_id}/clone") | |
| async def clone_project(project_id: str, body: CloneProjectRequestBodySchema) -> str: | |
| logger.info(f"Cloning project {project_id}") | |
| overrides = {} | |
| if body.name: | |
| overrides["name"] = body.name | |
| if body.language: | |
| overrides["language"] = body.language | |
| new_project_id = project_service.create_shallow_clone( | |
| project_id, | |
| with_tags=True, | |
| **overrides, | |
| ) | |
| return new_project_id | |
| @ProjectRouter.post("/{project_id}/clone") | |
| async def clone_project( | |
| project_id: str, | |
| body: CloneProjectRequestBodySchema, | |
| auth: DependencyDirectusSession, | |
| db: DependencyInjectDatabase, | |
| ) -> str: | |
| logger.info(f"Cloning project {project_id}") | |
| # Verify project exists and user has access | |
| project = db.get(ProjectModel, project_id) | |
| if not project: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| if not auth.is_admin and project.directus_user_id != auth.user_id: | |
| raise HTTPException(status_code=403, detail="User does not have access to this project") | |
| overrides = {} | |
| if body.name: | |
| overrides["name"] = body.name | |
| if body.language: | |
| overrides["language"] = body.language | |
| new_project_id = project_service.create_shallow_clone( | |
| project_id, | |
| with_tags=True, | |
| **overrides, | |
| ) | |
| return new_project_id |
🤖 Prompt for AI Agents
In echo/server/dembrane/api/project.py around lines 357 to 373, add
authentication and authorization by injecting the DependencyDirectusSession
dependency into the clone_project handler (e.g. a parameter session:
DirectusSession = Depends(DependencyDirectusSession)) so the endpoint requires a
logged-in user; then call the project service authorization check before cloning
(e.g. project_service.authorize_clone(project_id, session) or
project_service.user_can_clone(project_id, session.user) and if it returns false
raise an HTTPException(403)); only proceed to call create_shallow_clone when the
authorization check succeeds and return the new_project_id as before.
| GCP_SA_JSON_RAW = os.environ.get("GCP_SA_JSON") | ||
| GCP_SA_JSON = None | ||
| try: | ||
| if GCP_SA_JSON_RAW: | ||
| GCP_SA_JSON = json.loads(GCP_SA_JSON_RAW) | ||
| logger.debug("GCP_SA_JSON: set") | ||
| else: | ||
| logger.debug("GCP_SA_JSON: not set") | ||
| except Exception as e: | ||
| logger.debug(f"GCP_SA_JSON: not set (invalid json): {e}") | ||
|
|
There was a problem hiding this comment.
Fail fast when GCP credentials are bogus.
If GCP_SA_JSON is set but invalid (or missing while we’re on Dembrane-25-09), we now just log at debug and limp forward until Vertex calls explode later. Let’s validate up front and crash loudly, consistent with the rest of this config module.
-GCP_SA_JSON_RAW = os.environ.get("GCP_SA_JSON")
-GCP_SA_JSON = None
-try:
- if GCP_SA_JSON_RAW:
- GCP_SA_JSON = json.loads(GCP_SA_JSON_RAW)
- logger.debug("GCP_SA_JSON: set")
- else:
- logger.debug("GCP_SA_JSON: not set")
-except Exception as e:
- logger.debug(f"GCP_SA_JSON: not set (invalid json): {e}")
+GCP_SA_JSON_RAW = os.environ.get("GCP_SA_JSON")
+GCP_SA_JSON = None
+if GCP_SA_JSON_RAW:
+ try:
+ GCP_SA_JSON = json.loads(GCP_SA_JSON_RAW)
+ logger.debug("GCP_SA_JSON: set")
+ except json.JSONDecodeError as e:
+ raise ValueError("GCP_SA_JSON must be valid JSON") from e
+elif TRANSCRIPTION_PROVIDER == "Dembrane-25-09":
+ raise AssertionError("GCP_SA_JSON environment variable is required for Dembrane-25-09")
+else:
+ logger.debug("GCP_SA_JSON: not set")📝 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.
| GCP_SA_JSON_RAW = os.environ.get("GCP_SA_JSON") | |
| GCP_SA_JSON = None | |
| try: | |
| if GCP_SA_JSON_RAW: | |
| GCP_SA_JSON = json.loads(GCP_SA_JSON_RAW) | |
| logger.debug("GCP_SA_JSON: set") | |
| else: | |
| logger.debug("GCP_SA_JSON: not set") | |
| except Exception as e: | |
| logger.debug(f"GCP_SA_JSON: not set (invalid json): {e}") | |
| GCP_SA_JSON_RAW = os.environ.get("GCP_SA_JSON") | |
| GCP_SA_JSON = None | |
| if GCP_SA_JSON_RAW: | |
| try: | |
| GCP_SA_JSON = json.loads(GCP_SA_JSON_RAW) | |
| logger.debug("GCP_SA_JSON: set") | |
| except json.JSONDecodeError as e: | |
| raise ValueError("GCP_SA_JSON must be valid JSON") from e | |
| elif TRANSCRIPTION_PROVIDER == "Dembrane-25-09": | |
| raise AssertionError("GCP_SA_JSON environment variable is required for Dembrane-25-09") | |
| else: | |
| logger.debug("GCP_SA_JSON: not set") |
🤖 Prompt for AI Agents
In echo/server/dembrane/config.py around lines 225-235, the code currently
swallows invalid or missing GCP service account JSON by logging at debug; change
it to validate and fail fast: if GCP_SA_JSON_RAW is present but json.loads
raises, log an error and raise a RuntimeError (or call sys.exit) with the parse
error so the process stops immediately; additionally, if the release/feature
flag for Dembrane-25-09 is active (check the existing config/flag used elsewhere
in this module) and GCP_SA_JSON_RAW is missing, log an error and raise similarly
so startup fails loudly rather than continuing until Vertex calls fail later.
| "created_at": { | ||
| "_gte": (get_utc_timestamp() - timedelta(minutes=5)).isoformat() | ||
| }, |
There was a problem hiding this comment.
Fix created_at filter direction.
The comment describes excluding conversations created in the last 5 minutes, but _gte on created_at keeps only those fresh rows. That reversal means we now nag brand-new conversations and still skip the stale ones we actually want. Flip it to _lte (or _lt) with the same timestamp.
- "created_at": {
- "_gte": (get_utc_timestamp() - timedelta(minutes=5)).isoformat()
- },
+ "created_at": {
+ "_lte": (get_utc_timestamp() - timedelta(minutes=5)).isoformat()
+ },📝 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.
| "created_at": { | |
| "_gte": (get_utc_timestamp() - timedelta(minutes=5)).isoformat() | |
| }, | |
| "created_at": { | |
| "_lte": (get_utc_timestamp() - timedelta(minutes=5)).isoformat() | |
| }, |
🤖 Prompt for AI Agents
In echo/server/dembrane/conversation_utils.py around lines 34 to 36, the
created_at filter uses "_gte" which keeps only conversations from the last 5
minutes contrary to the intended behavior of excluding recent conversations;
change the comparator to "_lte" (or "_lt") using the same (get_utc_timestamp() -
timedelta(minutes=5)).isoformat() value so the query selects conversations
created at or before that cutoff (i.e., older than 5 minutes).
| new_project_data = { | ||
| "name": current_project["name"], | ||
| "language": current_project["language"], | ||
| "is_conversation_allowed": current_project["is_conversation_allowed"], | ||
| "directus_user_id": current_project["directus_user_id"], | ||
| "context": current_project["context"], | ||
| "default_conversation_title": current_project["default_conversation_title"], | ||
| "default_conversation_description": current_project["default_conversation_description"], | ||
| "default_conversation_finish_text": current_project["default_conversation_finish_text"], | ||
| "default_conversation_ask_for_participant_name": current_project[ | ||
| "default_conversation_ask_for_participant_name" | ||
| ], | ||
| "default_conversation_tutorial_slug": current_project[ | ||
| "default_conversation_tutorial_slug" | ||
| ], | ||
| "default_conversation_transcript_prompt": current_project[ | ||
| "default_conversation_transcript_prompt" | ||
| ], | ||
| "conversation_ask_for_participant_name_label": current_project[ | ||
| "conversation_ask_for_participant_name_label" | ||
| ], | ||
| "image_generation_model": current_project["image_generation_model"], | ||
| "is_enhanced_audio_processing_enabled": current_project[ | ||
| "is_enhanced_audio_processing_enabled" | ||
| ], | ||
| "is_get_reply_enabled": current_project["is_get_reply_enabled"], | ||
| "is_project_notification_subscription_allowed": current_project[ | ||
| "is_project_notification_subscription_allowed" | ||
| ], | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider extracting field list to a constant.
The 17-field list of project attributes to clone is embedded in the method. If this list needs to be maintained or reused elsewhere, consider extracting it to a module-level constant or a helper method.
This would make it easier to keep the cloned fields in sync with the project schema and improve maintainability.
🤖 Prompt for AI Agents
In echo/server/dembrane/service/project.py around lines 125 to 154, the
hard-coded 17-field mapping for cloning project attributes is embedded in the
method; extract that list of field names into a module-level constant (e.g.,
CLONABLE_PROJECT_FIELDS) or a small helper function that returns the dict by
iterating over that constant, then replace the inline mapping with a
comprehension or helper call that builds new_project_data from current_project
using the constant to improve reuse and maintainability.
| #!/usr/bin/env bash | ||
|
|
||
| dramatiq --queues cpu --processes 2 --threads 1 dembrane.tasks | ||
| dramatiq --watch ./dembrane --queues cpu --processes 1 --threads 2 dembrane.tasks |
There was a problem hiding this comment.
Don't break LightRAG's single-thread contract.
Threads=2 means two Dramatiq workers share a process and fight LightRAG’s global locks—we already learned these tasks must stay single-threaded. Keep the watch flag, but revert to threads=1 (bump processes instead if you need more throughput).
Apply this diff:
-dramatiq --watch ./dembrane --queues cpu --processes 1 --threads 2 dembrane.tasks
+dramatiq --watch ./dembrane --queues cpu --processes 1 --threads 1 dembrane.tasksBased on learnings
📝 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.
| dramatiq --watch ./dembrane --queues cpu --processes 1 --threads 2 dembrane.tasks | |
| dramatiq --watch ./dembrane --queues cpu --processes 1 --threads 1 dembrane.tasks |
🤖 Prompt for AI Agents
In echo/server/run-worker-cpu.sh around line 3 the Dramatiq invocation uses
--threads 2 which violates LightRAG's single-thread contract and causes global
lock contention; change the flag to --threads 1 (keep the --watch flag) and, if
more throughput is required, increase the --processes count instead of using
multiple threads.
ECHO-xxx
- (feature) new "Dembrane 25-09" transcription provider used for all new
transcription (AssemblyAI + Gemini)
- (feature) updated retranscribe flow (pii redaction, toast that takes
you to new conversation)
- (feature) add clone project functionality (only metadata) + tests
- (bug) fix premature calling of "finish conversation" (duration bug)
- dev stuff:
- use vertex ai instead of gemini api
- remove grid view
- add biome for frontend linting and formatting (eslint + prettier was
breaking my pc lol)
- refactor conversation transcript into maintainable components
- side effects:
- remember to add GCP_SA_JSON env var. ask @spashii for it.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- New Features
- Copy and download conversation transcripts (Markdown).
- Retranscribe conversations with optional PII redaction.
- Clone projects and export all transcripts as a ZIP.
- View conversation status via a modal in Project settings.
- Improvements
- Streamlined transcript view with simpler controls and infinite scroll.
- Unified Project settings sections (Upload, Export, Actions).
- Projects page now uses a consistent list view.
- Documentation
- Added operational notes and guidance.
- Chores
- Switched editor tooling to Biome; updated workspace sessions.
- Tests
- Added test for project shallow clone with tags.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Summary by CodeRabbit