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
10 changes: 10 additions & 0 deletions echo/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ Production uses webhook mode (`ASSEMBLYAI_WEBHOOK_URL`); polling is only a fallb

The `agent/` directory contains the agentic chat service (LangGraph-based). It runs as a separate FastAPI service on port 8001. Agentic chat streams via `POST /api/agentic/runs/{run_id}/stream` — no Dramatiq dispatch. See `agent/README.md`.

## Directus File Cleanup

When removing a file reference from a user record (e.g. avatar, whitelabel logo), always delete the orphaned Directus file after clearing the reference. Pattern:

1. Fetch the current file ID from the user record
2. Update the user record to set the field to `None`
3. Delete the file via `directus.delete_file(file_id)`

Wrap all blocking Directus SDK calls in `run_in_thread_pool` (from `dembrane.async_helpers`) when used in async endpoints. See `server/dembrane/api/user_settings.py` for reference implementations in `remove_avatar` and `remove_whitelabel_logo`.

## Tech Debt / Known Issues
- Some mypy errors in `llm_router.py` and `settings.py` (pre-existing, non-blocking)

Expand Down
5 changes: 5 additions & 0 deletions echo/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
- **React Query hook hubs**: Each feature owns a `hooks/index.ts` exposing `useQuery`/`useMutation` wrappers with shared `useQueryClient` invalidation logic (`src/components/{conversation,project,chat,participant,...}/hooks/index.ts`).
- **Lingui macros for copy**: Most routed screens import `t` from `@lingui/core/macro` and `Trans` from `@lingui/react/macro` to localize UI strings (e.g. `src/routes/auth/Login.tsx`, `src/routes/project/conversation/ProjectConversationOverview.tsx`).
- **Mantine + Tailwind blend**: Screens compose Mantine primitives (`Stack`, `Group`, `ActionIcon`, etc.) while layering Tailwind utility classes via `className`, alongside toast feedback via `@/components/common/Toaster` (e.g. `src/components/conversation/ConversationDangerZone.tsx`, `src/components/dropzone/UploadConversationDropzone.tsx`).
- **ConfirmModal for destructive/irreversible actions**: Never use `window.confirm()`. Always use `ConfirmModal` from `@/components/common/ConfirmModal`. Pass `confirmColor="red"` for destructive actions and always include a `data-testid` prop. Manage open/close state with `useDisclosure` from `@mantine/hooks`. Used in 12+ components (delete chat, delete conversation, delete project, delete template, delete report, delete tag, remove avatar, remove logo, regenerate summary, generate library, disable anonymization, change language during chat).
- **InputModal for text input prompts**: Never use `window.prompt()`. Always use `InputModal` from `@/components/common/InputModal`. It auto-focuses, validates empty input, and supports form submit via Enter. Used for chat rename and similar single-field prompts.
- **Toast for status messages**: Never use `window.alert()` or `alert()`. Always use `toast.error()` / `toast.success()` from `@/components/common/Toaster` for transient status feedback (e.g. permission denied, invalid token, clipboard failure).
- **Confirm dialog button layout**: Right-aligned `Group` with `variant="subtle"` cancel button on the left, primary action button on the right. This is handled automatically by `ConfirmModal` and `InputModal`.
- **`data-testid` convention for modals**: Use kebab-case like `"chat-delete-modal"`, `"tag-delete-modal"`. `ConfirmModal` and `InputModal` automatically append `-cancel` and `-confirm` suffixes on their buttons.

## Change Hotspots (git history)
- Translation bundles dominate churn: `src/locales/{en-US,de-DE,es-ES,fr-FR,nl-NL}.{po,ts}` appear in 50–60 commits each (`git log` frequency).
Expand Down
135 changes: 82 additions & 53 deletions echo/frontend/src/components/chat/ChatAccordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconDotsVertical,
IconMessageCircle,
Expand All @@ -26,6 +27,8 @@ import { useInView } from "react-intersection-observer";
import { useParams } from "react-router";
import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { testId } from "@/lib/testUtils";
import { ConfirmModal } from "../common/ConfirmModal";
import { InputModal } from "../common/InputModal";
import { NavigationButton } from "../common/NavigationButton";
import { MODE_COLORS } from "./ChatModeSelector";
import { ChatSkeleton } from "./ChatSkeleton";
Expand Down Expand Up @@ -104,62 +107,88 @@ export const ChatAccordionItemMenu = ({
const deleteChatMutation = useDeleteChatMutation();
const updateChatMutation = useUpdateChatMutation();
const navigate = useI18nNavigate();
const [
deleteConfirmOpened,
{ open: openDeleteConfirm, close: closeDeleteConfirm },
] = useDisclosure(false);
const [renameOpened, { open: openRename, close: closeRename }] =
useDisclosure(false);

return (
<Menu shadow="md" position="right" {...testId("chat-item-menu")}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
size={size}
className="flex items-center justify-center"
{...testId("chat-item-menu-button")}
>
<IconDotsVertical />
</ActionIcon>
</Menu.Target>

<Menu.Dropdown>
<Stack gap="xs">
<Menu.Item
leftSection={<IconPencil />}
disabled={deleteChatMutation.isPending}
onClick={() => {
const newName = prompt(
t`Enter new name for the chat:`,
chat.name ?? "",
);
if (newName) {
updateChatMutation.mutate({
chatId: chat.id ?? "",
payload: { name: newName },
projectId: (chat.project_id as string) ?? "",
});
}
}}
{...testId("chat-item-menu-rename")}
>
<Trans id="project.sidebar.chat.rename">Rename</Trans>
</Menu.Item>
<Menu.Item
leftSection={<IconTrash />}
disabled={deleteChatMutation.isPending}
onClick={() => {
if (confirm("Are you sure you want to delete this chat?")) {
deleteChatMutation.mutate({
chatId: chat.id ?? "",
projectId: (chat.project_id as string) ?? "",
});
navigate(`/projects/${chat.project_id}/overview`);
}
}}
{...testId("chat-item-menu-delete")}
<>
<Menu shadow="md" position="right" {...testId("chat-item-menu")}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
size={size}
className="flex items-center justify-center"
{...testId("chat-item-menu-button")}
>
<Trans id="project.sidebar.chat.delete">Delete</Trans>
</Menu.Item>
</Stack>
</Menu.Dropdown>
</Menu>
<IconDotsVertical />
</ActionIcon>
</Menu.Target>

<Menu.Dropdown>
<Stack gap="xs">
<Menu.Item
leftSection={<IconPencil />}
disabled={deleteChatMutation.isPending}
onClick={openRename}
{...testId("chat-item-menu-rename")}
>
<Trans id="project.sidebar.chat.rename">Rename</Trans>
</Menu.Item>
<Menu.Item
leftSection={<IconTrash />}
disabled={deleteChatMutation.isPending}
onClick={openDeleteConfirm}
{...testId("chat-item-menu-delete")}
>
<Trans id="project.sidebar.chat.delete">Delete</Trans>
</Menu.Item>
</Stack>
</Menu.Dropdown>
</Menu>

<InputModal
opened={renameOpened}
onClose={closeRename}
title={t`Rename chat`}
label={<Trans>Chat name</Trans>}
initialValue={chat.name ?? ""}
confirmLabel={<Trans>Save</Trans>}
loading={updateChatMutation.isPending}
onConfirm={(newName) => {
updateChatMutation.mutate({
chatId: chat.id ?? "",
payload: { name: newName },
projectId: (chat.project_id as string) ?? "",
});
closeRename();
}}
data-testid="chat-rename-modal"
/>

<ConfirmModal
opened={deleteConfirmOpened}
onClose={closeDeleteConfirm}
title={t`Delete chat`}
message={t`Are you sure you want to delete this chat? This action cannot be undone.`}
confirmLabel={<Trans>Delete</Trans>}
confirmColor="red"
loading={deleteChatMutation.isPending}
onConfirm={() => {
deleteChatMutation.mutate({
chatId: chat.id ?? "",
projectId: (chat.project_id as string) ?? "",
});
navigate(`/projects/${chat.project_id}/overview`);
closeDeleteConfirm();
}}
data-testid="chat-delete-modal"
/>
</>
);
};

Expand Down
118 changes: 52 additions & 66 deletions echo/frontend/src/components/chat/TemplatesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,25 +37,25 @@ import {
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
ArrowLeft,
Copy,
DotsSixVertical,
Globe,
MagnifyingGlass,
PencilSimple,
Plus,
Trash,
X,
ArrowLeftIcon,
CopyIcon,
DotsSixVerticalIcon,
MagnifyingGlassIcon,
PencilSimpleIcon,
PlusIcon,
TrashIcon,
XIcon,
} from "@phosphor-icons/react";
import { IconPin, IconPinFilled } from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { ConfirmModal } from "@/components/common/ConfirmModal";
import {
type QuickAccessItem,
encodeTemplateKey,
keyToQuickAccess,
type QuickAccessItem,
quickAccessToKey,
} from "./templateKey";
import { type Template, Templates } from "./templates";
import { Templates } from "./templates";

// ── Types ──

Expand Down Expand Up @@ -402,40 +402,23 @@ export const TemplatesModal = ({
// ── Render: Create / Edit view ──

const deleteConfirmationModal = (
<Modal
<ConfirmModal
opened={!!deletingTemplateId}
onClose={() => setDeletingTemplateId(null)}
title={t`Delete template`}
size="sm"
centered
>
<Stack gap="md">
<Text size="sm">
<Trans>
Are you sure you want to delete this template? This cannot be
undone.
</Trans>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeletingTemplateId(null)}>
<Trans>Cancel</Trans>
</Button>
<Button
color="red"
loading={isDeleting}
onClick={() => {
if (deletingTemplateId) {
onDeleteUserTemplate?.(deletingTemplateId);
setDeletingTemplateId(null);
setView("browse");
}
}}
>
<Trans>Delete</Trans>
</Button>
</Group>
</Stack>
</Modal>
data-testid="template-delete-modal"
message={t`Are you sure you want to delete this template? This cannot be undone.`}
confirmLabel={<Trans>Delete</Trans>}
loading={isDeleting}
confirmColor="red"
onConfirm={() => {
if (deletingTemplateId) {
onDeleteUserTemplate?.(deletingTemplateId);
setDeletingTemplateId(null);
setView("browse");
}
}}
/>
);

if (view === "create" || view === "edit") {
Expand All @@ -445,7 +428,7 @@ export const TemplatesModal = ({
<div className="flex h-full flex-col">
<UnstyledButton onClick={handleBack} className="mb-4">
<Group gap={4}>
<ArrowLeft size={16} />
<ArrowLeftIcon size={16} />
<Text size="sm" c="dimmed">
<Trans>Back to templates</Trans>
</Text>
Expand Down Expand Up @@ -476,23 +459,26 @@ export const TemplatesModal = ({
</Trans>
</Text>
)}
<Button
onClick={view === "create" ? handleSaveCreate : handleSaveEdit}
loading={view === "create" ? isCreating : isUpdating}
disabled={!formTitle.trim() || !formContent.trim()}
fullWidth
>
<Trans>Save template</Trans>
</Button>
{view === "edit" && editingId && (
<UnstyledButton
onClick={() => setDeletingTemplateId(editingId)}
<Group justify="flex-end" gap="sm">
{view === "edit" && editingId && (
<Button
variant="subtle"
color="red"
onClick={() => setDeletingTemplateId(editingId)}
>
<Trans>Delete</Trans>
</Button>
)}
<Button
onClick={
view === "create" ? handleSaveCreate : handleSaveEdit
}
loading={view === "create" ? isCreating : isUpdating}
disabled={!formTitle.trim() || !formContent.trim()}
>
<Text size="sm" c="red" ta="center">
<Trans>Delete template</Trans>
</Text>
</UnstyledButton>
)}
<Trans>Save template</Trans>
</Button>
</Group>
</Stack>
</div>
</Modal>
Expand Down Expand Up @@ -538,7 +524,7 @@ export const TemplatesModal = ({
className="flex cursor-grab items-center text-gray-400 hover:text-gray-600 active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<DotsSixVertical size={14} weight="bold" />
<DotsSixVerticalIcon size={14} weight="bold" />
</div>
</Tooltip>
)}
Expand All @@ -564,7 +550,7 @@ export const TemplatesModal = ({
handleDuplicate(tmpl.title, tmpl.content);
}}
>
<Copy size={12} />
<CopyIcon size={12} />
</ActionIcon>
</Tooltip>
)}
Expand All @@ -580,7 +566,7 @@ export const TemplatesModal = ({
if (ut) handleStartEdit(ut);
}}
>
<PencilSimple size={12} />
<PencilSimpleIcon size={12} />
</ActionIcon>
</Tooltip>
<Tooltip label={t`Delete`}>
Expand All @@ -594,7 +580,7 @@ export const TemplatesModal = ({
setDeletingTemplateId(tmpl.id);
}}
>
<Trash size={12} />
<TrashIcon size={12} />
</ActionIcon>
</Tooltip>
</>
Expand Down Expand Up @@ -687,7 +673,7 @@ export const TemplatesModal = ({
{/* Search */}
<TextInput
placeholder={t`Search templates...`}
leftSection={<MagnifyingGlass size={16} />}
leftSection={<MagnifyingGlassIcon size={16} />}
className="flex-1"
size="sm"
rightSection={
Expand All @@ -698,7 +684,7 @@ export const TemplatesModal = ({
aria-label="Clear search"
onClick={() => setSearchQuery("")}
>
<X size={16} />
<XIcon size={16} />
</ActionIcon>
) : null
}
Expand All @@ -710,7 +696,7 @@ export const TemplatesModal = ({
{/* Create template — primary CTA */}
<Button
variant="filled"
leftSection={<Plus size={16} />}
rightSection={<PlusIcon size={16} />}
onClick={handleStartCreate}
>
<Trans>Create template</Trans>
Expand Down
Loading
Loading