Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions echo/frontend/src/components/chat/ChatHistoryMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,14 @@ export const ChatHistoryMessage = ({
);
}
}

if (message?._original?.added_conversations?.length > 0) {
const conversations = message?._original?.added_conversations
.map((ac) => (ac as unknown as ConversationLink).source_conversation_id)
.map((conv: string | ProjectChatMessageConversation1) =>
typeof conv === "object" && conv !== null
? (conv as ProjectChatMessageConversation1).conversation_id
: null,
)
.filter((conv) => conv != null);
Comment on lines +159 to 164
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for added_conversations usage and context
rg -n "added_conversations" --type=ts --type=tsx -B 3 -A 3

Repository: Dembrane/echo

Length of output: 84


🏁 Script executed:

#!/bin/bash
# Search for added_conversations usage - try without file type restriction
rg -n "added_conversations" --type=typescript -B 3 -A 3

Repository: Dembrane/echo

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Search for added_conversations without file type restriction
rg -n "added_conversations" -B 3 -A 3

Repository: Dembrane/echo

Length of output: 5944


Strings are valid conversation IDs and should not be dropped—the current mapping logic has a bug.

The type definition confirms that added_conversations can contain either string[] (raw conversation IDs) or ProjectChatMessageConversation1[] (expanded objects). The current code at lines 159-164 maps strings to null and filters them out, which silently discards valid conversation IDs that should be included in the output.

The fix should preserve strings as-is:

.map((conv: string | ProjectChatMessageConversation1) =>
	typeof conv === "object" && conv !== null
		? (conv as ProjectChatMessageConversation1).conversation_id
		: conv,
)
.filter((conv) => conv != null);

This way, string IDs pass through unchanged while objects have their conversation_id extracted.

🤖 Prompt for AI Agents
In echo/frontend/src/components/chat/ChatHistoryMessage.tsx around lines 159 to
164, the mapping converts string conversation IDs to null and then filters them
out; preserve string IDs by returning the string unchanged and only extract
conversation_id from object entries, then filter out null/undefined; update the
map callback to return conv when it's a string and the object's conversation_id
when it's an object, keeping the subsequent .filter((conv) => conv != null) to
remove only truly null/undefined values.


return conversations.length > 0 ? (
// biome-ignore lint/a11y/useValidAriaRole: role is a component prop for styling, not an ARIA attribute
<ChatMessage key={message.id} role="dembrane" section={section}>
Expand Down
44 changes: 44 additions & 0 deletions echo/frontend/src/components/conversation/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,3 +1020,47 @@ export const useConversationsCountByProjectId = (
queryKey: ["projects", projectId, "conversations", "count", query],
});
};

export const useConversationHasTranscript = (
conversationId: string,
refetchInterval = 10000,
enabled = true,
) => {
return useQuery({
enabled: enabled,
queryFn: async () => {
const response = await directus.request(
aggregate("conversation_chunk", {
aggregate: {
count: "*",
},
query: {
filter: {
_and: [
{
conversation_id: {
_eq: conversationId,
},
},
{
transcript: {
_nnull: true,
},
},
{
transcript: {
_nempty: true,
},
},
],
},
},
}),
);
const count = response[0]?.count;
return typeof count === "number" ? count : Number(count) || 0;
},
queryKey: ["conversations", conversationId, "chunks", "transcript-count"],
refetchInterval,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useParams, useSearchParams } from "react-router";
import { Logo } from "@/components/common/Logo";
import { toast } from "@/components/common/Toaster";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { useLanguage } from "@/hooks/useLanguage";
import { useParticipantProjectById } from "../hooks";
import { startCooldown } from "../refine/hooks/useRefineSelectionCooldown";
import {
Expand All @@ -15,14 +16,22 @@ import {
} from "./hooks";
import { VerifyInstructions } from "./VerifyInstructions";

const LANGUAGE_TO_LOCALE: Record<string, string> = {
type LanguageCode = "de" | "en" | "es" | "fr" | "nl";

const LANGUAGE_TO_LOCALE: Record<LanguageCode, string> = {
de: "de-DE",
en: "en-US",
es: "es-ES",
fr: "fr-FR",
nl: "nl-NL",
};

const localeFromLanguage = (language?: string) => {
if (!language) return undefined;
const iso = language.includes("-") ? language.split("-")[0] : language;
return LANGUAGE_TO_LOCALE[iso as LanguageCode];
};
Comment on lines +19 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Centralize LanguageCode / LANGUAGE_TO_LOCALE / localeFromLanguage to stay DRY and future‑proof

Nice, clean helper and mapping; runtime behavior is solid. The only concern is duplication + drift: per the PR summary, similar language→locale logic now lives in other components as well. Once we add or tweak a language, we’ll have to remember to touch multiple maps and union types.

I’d pull this into a shared i18n util (e.g. @/lib/i18n/locale) and import it here, so we have a single source of truth for both LanguageCode and the mapping:

- type LanguageCode = "de" | "en" | "es" | "fr" | "nl";
-
- const LANGUAGE_TO_LOCALE: Record<LanguageCode, string> = {
-  de: "de-DE",
-  en: "en-US",
-  es: "es-ES",
-  fr: "fr-FR",
-  nl: "nl-NL",
- };
-
- const localeFromLanguage = (language?: string) => {
-  if (!language) return undefined;
-  const iso = language.includes("-") ? language.split("-")[0] : language;
-  return LANGUAGE_TO_LOCALE[iso as LanguageCode];
- };
+ import { LANGUAGE_TO_LOCALE, localeFromLanguage } from "@/lib/i18n/locale";

That also lets you derive the union type from whatever source backs SUPPORTED_LANGUAGES instead of hand‑maintaining string literals here.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In echo/frontend/src/components/participant/verify/VerifySelection.tsx around
lines 19-33, the LanguageCode type, LANGUAGE_TO_LOCALE map, and
localeFromLanguage helper are duplicated elsewhere; extract them into a shared
i18n module (e.g. src/lib/i18n/locale.ts or @/lib/i18n/locale) that exports a
single SUPPORTED_LANGUAGES array, a derived LanguageCode union type, the
LANGUAGE_TO_LOCALE mapping and localeFromLanguage function, update this file to
import those exports, and replace the local definitions so all consumers use the
centralized source of truth.


export const TOPIC_ICON_MAP: Record<string, string> = {
actions: "↗️",
agreements: "✅",
Expand Down Expand Up @@ -50,8 +59,11 @@ export const VerifySelection = () => {
const topicsQuery = useVerificationTopics(projectId);

const projectLanguage = projectQuery.data?.language ?? "en";
const { iso639_1: uiLanguageIso } = useLanguage();
const languageLocale =
LANGUAGE_TO_LOCALE[projectLanguage] ?? LANGUAGE_TO_LOCALE.en;
localeFromLanguage(uiLanguageIso) ??
localeFromLanguage(projectLanguage) ??
LANGUAGE_TO_LOCALE.en;

const selectedTopics = topicsQuery.data?.selected_topics ?? [];
const availableTopics = topicsQuery.data?.available_topics ?? [];
Expand Down
19 changes: 14 additions & 5 deletions echo/frontend/src/components/project/ProjectPortalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { useAutoSave } from "@/hooks/useAutoSave";
import { useLanguage } from "@/hooks/useLanguage";
import type { VerificationTopicsResponse } from "@/lib/api";
import { Logo } from "../common/Logo";
import { toast } from "../common/Toaster";
Expand Down Expand Up @@ -55,14 +56,19 @@ const FormSchema = z.object({

type ProjectPortalFormValues = z.infer<typeof FormSchema>;

const LANGUAGE_TO_LOCALE: Record<string, string> = {
type LanguageCode = "de" | "en" | "es" | "fr" | "nl";

const LANGUAGE_TO_LOCALE: Record<LanguageCode, string> = {
de: "de-DE",
en: "en-US",
es: "es-ES",
fr: "fr-FR",
nl: "nl-NL",
};

const localeFromIso = (iso?: string) =>
iso ? LANGUAGE_TO_LOCALE[iso as LanguageCode] : undefined;

const normalizeTopicList = (topics: string[]): string[] =>
Array.from(
new Set(topics.map((topic) => topic.trim()).filter(Boolean)),
Expand Down Expand Up @@ -193,8 +199,11 @@ const ProjectPortalEditorComponent: React.FC<ProjectPortalEditorProps> = ({
| "de"
| "fr"
| "es";
const languageLocale =
LANGUAGE_TO_LOCALE[projectLanguageCode] ?? LANGUAGE_TO_LOCALE.en;
const { iso639_1: uiLanguageIso } = useLanguage();
const translationLocale =
localeFromIso(uiLanguageIso) ??
localeFromIso(projectLanguageCode) ??
LANGUAGE_TO_LOCALE.en;

const availableVerifyTopics = useMemo(
() =>
Expand All @@ -204,11 +213,11 @@ const ProjectPortalEditorComponent: React.FC<ProjectPortalEditorProps> = ({
(topic.icon && !topic.icon.startsWith(":") ? topic.icon : undefined),
key: topic.key,
label:
topic.translations?.[languageLocale]?.label ??
topic.translations?.[translationLocale]?.label ??
topic.translations?.["en-US"]?.label ??
topic.key,
})),
[verificationTopics, languageLocale],
[verificationTopics, translationLocale],
);

const selectedTopicDefaults = useMemo(
Expand Down
2 changes: 1 addition & 1 deletion echo/frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const PLAUSIBLE_API_HOST =

export const DEBUG_MODE = import.meta.env.VITE_DEBUG_MODE === "1";

export const ENABLE_CHAT_AUTO_SELECT = true;
export const ENABLE_CHAT_AUTO_SELECT = false;
export const ENABLE_CONVERSATION_HEALTH = true;
export const ENABLE_ANNOUNCEMENTS = true;
export const ENABLE_DISPLAY_CONVERSATION_LINKS = true;
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { IconRefresh } from "@tabler/icons-react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useMutationState } from "@tanstack/react-query";
import { useParams } from "react-router";
import { CopyIconButton } from "@/components/common/CopyIconButton";
import { Markdown } from "@/components/common/Markdown";
Expand All @@ -23,6 +23,7 @@ import { ConversationLink } from "@/components/conversation/ConversationLink";
import {
useConversationById,
useConversationChunks,
useConversationHasTranscript,
} from "@/components/conversation/hooks";
import { VerifiedArtefactsSection } from "@/components/conversation/VerifiedArtefactsSection";
import { useProjectById } from "@/components/project/hooks";
Expand Down Expand Up @@ -58,44 +59,70 @@ export const ProjectConversationOverviewRoute = () => {
},
});

// Check if conversation has any transcript text using aggregate
const chunksWithTranscriptQuery = useConversationHasTranscript(
conversationId ?? "",
10000,
!!conversationId && !conversationQuery.data?.summary,
);
const hasTranscript = (chunksWithTranscriptQuery.data ?? 0) > 0;

const useHandleGenerateSummaryManually = useMutation({
mutationFn: async () => {
const response = await generateConversationSummary(conversationId ?? "");
toast.info(
t`The summary is being regenerated. Please wait for the new summary to be available.`,
);
return response;
},
onError: () => {
toast.error(t`Failed to regenerate the summary. Please try again later.`);
mutationFn: async (isRegeneration: boolean) => {
const promise = generateConversationSummary(conversationId ?? "");

toast.promise(promise, {
error: isRegeneration
? t`Failed to regenerate the summary. Please try again later.`
: t`Failed to generate the summary. Please try again later.`,
loading: isRegeneration
? t`Regenerating the summary. Please wait...`
: t`Generating the summary. Please wait...`,
success: (response) => {
// Show different message based on whether summary was generated or is being processed
if (
response.status === "success" &&
"summary" in response &&
response.summary
) {
return isRegeneration
? t`Summary regenerated successfully.`
: t`Summary generated successfully.`;
}
return isRegeneration
? t`The summary is being regenerated. Please wait for it to be available.`
: t`The summary is being generated. Please wait for it to be available.`;
},
});

return promise;
},
mutationKey: ["generateSummary", conversationId],
onSuccess: () => {
conversationQuery.refetch();
},
});

const clipboard = useClipboard();
// Check if there's a pending mutation for this conversation across the app
const pendingMutations = useMutationState({
filters: {
mutationKey: ["generateSummary", conversationId],
status: "pending",
},
});
const isMutationPending = pendingMutations.length > 0;

// Determine if summary section should be shown at all
const showSummarySection =
conversationQuery.data?.summary ||
(conversationQuery.data?.source &&
!conversationQuery.data.source.toLowerCase().includes("upload"));
const clipboard = useClipboard();

return (
<Stack gap="3rem" className="relative" px="2rem" pt="2rem" pb="2rem">
<LoadingOverlay visible={conversationQuery.isLoading} />
{conversationChunksQuery.data &&
conversationChunksQuery.data?.length > 0 &&
showSummarySection && (
conversationChunksQuery.data?.length > 0 && (
<Stack gap="1.5rem">
<Group>
<Title order={2}>
{(conversationQuery.data?.summary ||
(conversationQuery.data?.source &&
!conversationQuery.data.source
.toLowerCase()
.includes("upload"))) && <Trans>Summary</Trans>}
<Trans>Summary</Trans>
</Title>
<Group gap="sm">
{conversationQuery.data?.summary && (
Expand All @@ -112,10 +139,11 @@ export const ProjectConversationOverviewRoute = () => {
<Tooltip label={t`Regenerate Summary`}>
<ActionIcon
variant="transparent"
loading={isMutationPending}
onClick={() =>
window.confirm(
t`Are you sure you want to regenerate the summary? You will lose the current summary.`,
) && useHandleGenerateSummaryManually.mutate()
) && useHandleGenerateSummaryManually.mutate(true)
}
>
<IconRefresh size={23} color="gray" />
Expand All @@ -136,22 +164,30 @@ export const ProjectConversationOverviewRoute = () => {
/>

{!conversationQuery.isFetching &&
!conversationQuery.data?.summary &&
conversationQuery.data?.source &&
!conversationQuery.data.source
.toLowerCase()
.includes("upload") && (
!conversationQuery.data?.summary && (
<div>
<Button
variant="outline"
className="-mt-[2rem]"
loading={useHandleGenerateSummaryManually.isPending}
onClick={() => {
useHandleGenerateSummaryManually.mutate();
}}
<Tooltip
color="gray.7"
position="bottom-start"
label={
!hasTranscript
? t`Summary will be available once the conversation is transcribed`
: undefined
}
disabled={hasTranscript}
>
{t`Generate Summary`}
</Button>
<Button
variant="outline"
className="-mt-[2rem]"
loading={isMutationPending}
disabled={!hasTranscript}
onClick={() => {
useHandleGenerateSummaryManually.mutate(false);
}}
>
{t`Generate Summary`}
</Button>
</Tooltip>
</div>
)}

Expand Down
Loading
Loading