diff --git a/echo/frontend/src/components/chat/AgenticChatPanel.tsx b/echo/frontend/src/components/chat/AgenticChatPanel.tsx
index f7542d85..8086f447 100644
--- a/echo/frontend/src/components/chat/AgenticChatPanel.tsx
+++ b/echo/frontend/src/components/chat/AgenticChatPanel.tsx
@@ -380,7 +380,7 @@ export const AgenticChatPanel = ({
const computedChatForCopy = useMemo(() => {
const messagesList = historyMessages.map((message) =>
- formatMessage(message, "User", "Dembrane"),
+ formatMessage(message, "User", "dembrane"),
);
return messagesList.join("\n\n\n\n");
}, [historyMessages]);
@@ -802,14 +802,7 @@ export const AgenticChatPanel = ({
-
-
- {runStatus && (
-
- Run status: {runStatus}
-
- )}
-
+
{isRunInFlight && (
@@ -925,12 +918,12 @@ export const AgenticChatPanel = ({
className="shrink-0 self-start"
>
{statusMeta.label}
-
+
{formatDate(new Date(item.timestamp), "h:mm a")}
{hasRawData && (
@@ -968,13 +961,13 @@ export const AgenticChatPanel = ({
>
{item.rawInput && (
-
+
Input
{item.rawInput}
@@ -982,13 +975,13 @@ export const AgenticChatPanel = ({
)}
{item.rawOutput && (
-
+
Output
{item.rawOutput}
@@ -997,7 +990,7 @@ export const AgenticChatPanel = ({
{item.rawError && (
{item.rawError}
@@ -1034,7 +1027,7 @@ export const AgenticChatPanel = ({
-
+
{liveRunStatusText}
@@ -1093,7 +1086,7 @@ export const AgenticChatPanel = ({
maxRows={10}
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
- placeholder={t`Ask the agent...`}
+ placeholder={t`Ask about your conversations...`}
disabled={atTurnLimit}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
diff --git a/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx b/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx
index a6ff376b..dbf47bac 100644
--- a/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx
+++ b/echo/frontend/src/components/chat/ChatTemplatesMenu.tsx
@@ -114,7 +114,7 @@ const TemplatePill = ({
{label}
diff --git a/echo/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx b/echo/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
index 34a7ac97..d8659853 100644
--- a/echo/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
+++ b/echo/frontend/src/components/chat/ProjectUpdateSuggestionCard.tsx
@@ -8,11 +8,17 @@ import {
Group,
Paper,
Stack,
+ Switch,
Text,
+ Textarea,
} from "@mantine/core";
+import { IconArrowRight, IconCheck } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { toast } from "@/components/common/Toaster";
-import { useUpdateProjectByIdMutation } from "@/components/project/hooks";
+import {
+ useProjectById,
+ useUpdateProjectByIdMutation,
+} from "@/components/project/hooks";
import { testId } from "@/lib/testUtils";
export type ProjectUpdateSuggestionChange = {
@@ -28,24 +34,93 @@ export type ProjectUpdateSuggestion = {
changes: ProjectUpdateSuggestionChange[];
};
-const formatValue = (value: unknown): string => {
- if (value === null || value === undefined || value === "") return "—";
- if (typeof value === "boolean") return value ? "on" : "off";
- if (typeof value === "object") return JSON.stringify(value);
- return String(value);
+// Hosts see the portal editor's language, not backend field names.
+const FIELD_LABELS: Record string> = {
+ anonymize_transcripts: () => t`Anonymise transcripts`,
+ context: () => t`Project context`,
+ conversation_title_prompt: () => t`Title guidance`,
+ default_conversation_ask_for_participant_email: () =>
+ t`Ask participants for their email`,
+ default_conversation_ask_for_participant_name: () =>
+ t`Ask participants for their name`,
+ default_conversation_description: () => t`Portal description`,
+ default_conversation_finish_text: () => t`Portal finish message`,
+ default_conversation_title: () => t`Portal title`,
+ default_conversation_transcript_prompt: () => t`Key terms`,
+ default_conversation_tutorial_slug: () => t`Portal tutorial`,
+ enable_ai_title_and_tags: () => t`Automatic titles and tags`,
+ get_reply_mode: () => t`Reply mode`,
+ get_reply_prompt: () => t`Reply guidance`,
+ host_guide: () => t`Host guide`,
+ image_generation_model: () => t`Image style`,
+ is_conversation_allowed: () => t`Portal open for new conversations`,
+ is_get_reply_enabled: () => t`Replies to participants`,
+ is_project_notification_subscription_allowed: () =>
+ t`Participant updates subscription`,
+ is_verify_enabled: () => t`Participant verification`,
+ is_verify_on_finish_enabled: () => t`Verification on the finish screen`,
+ language: () => t`Portal language`,
+ name: () => t`Project name`,
+ selected_verification_key_list: () => t`Verification topics`,
+ tutorial_slug: () => t`Tutorial`,
};
-const humanizeField = (field: string) =>
- field
- .replace(/^default_conversation_/, "portal ")
- .replace(/^is_/, "")
- .replace(/_/g, " ");
+const fieldLabel = (field: string) =>
+ FIELD_LABELS[field]?.() ??
+ field.replace(/^default_conversation_/, "").replace(/_/g, " ");
+
+const isEmptyValue = (value: unknown) =>
+ value === null || value === undefined || value === "";
+
+const ValueText = ({
+ value,
+ kind,
+}: {
+ value: unknown;
+ kind: "old" | "new";
+}) => {
+ if (isEmptyValue(value)) {
+ return (
+
+ {kind === "old" ? empty : cleared}
+
+ );
+ }
+ const display =
+ typeof value === "boolean" ? (
+ value ? (
+ on
+ ) : (
+ off
+ )
+ ) : typeof value === "object" ? (
+ JSON.stringify(value)
+ ) : (
+ String(value)
+ );
+ return (
+
+ {display}
+
+ );
+};
/**
- * Renders an agent-proposed settings diff. The agent never writes; the
- * user reviews each field (old value, proposed value, reason) and applies
- * the selected changes through the normal project PATCH under their own
- * session, so the v2 access ladder still gates the write.
+ * Renders an agent-proposed settings change for the host to review.
+ * The agent never writes; the host can fine-tune each proposed value, pick
+ * which changes to keep, and apply them through the normal project PATCH
+ * under their own session (the access ladder gates the write).
+ *
+ * Applied state is stateless: the card compares the live project values to
+ * the proposal, so a reload still shows "Applied" truthfully.
*/
export const ProjectUpdateSuggestionCard = ({
suggestion,
@@ -53,12 +128,38 @@ export const ProjectUpdateSuggestionCard = ({
suggestion: ProjectUpdateSuggestion;
}) => {
const updateProjectMutation = useUpdateProjectByIdMutation();
+ const changedFields = useMemo(
+ () => suggestion.changes.map((c) => c.field),
+ [suggestion.changes],
+ );
+ const projectQuery = useProjectById({
+ projectId: suggestion.projectId,
+ query: { fields: ["id", ...(changedFields as (keyof Project)[])] },
+ });
+
const [selected, setSelected] = useState>(() =>
Object.fromEntries(suggestion.changes.map((c) => [c.field, true])),
);
- const [applied, setApplied] = useState(false);
+ // Hosts can fine-tune proposed values before applying.
+ const [edited, setEdited] = useState>({});
const [dismissed, setDismissed] = useState(false);
+ const effectiveValue = (change: ProjectUpdateSuggestionChange) =>
+ change.field in edited ? edited[change.field] : change.proposed;
+
+ // Stateless applied detection: if the live project already matches every
+ // proposed value, this suggestion has been applied (even after a reload).
+ // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveValue is stable over `edited`, which is listed
+ const applied = useMemo(() => {
+ const project = projectQuery.data as Record | undefined;
+ if (!project) return false;
+ return suggestion.changes.every((change) => {
+ const live = project[change.field];
+ const target = effectiveValue(change);
+ return String(live ?? "") === String(target ?? "");
+ });
+ }, [projectQuery.data, suggestion.changes, edited]);
+
const selectedChanges = useMemo(
() => suggestion.changes.filter((c) => selected[c.field]),
[suggestion.changes, selected],
@@ -67,87 +168,149 @@ export const ProjectUpdateSuggestionCard = ({
const handleApply = async () => {
if (selectedChanges.length === 0) return;
const payload = Object.fromEntries(
- selectedChanges.map((c) => [c.field, c.proposed]),
+ selectedChanges.map((c) => [c.field, effectiveValue(c)]),
);
try {
await updateProjectMutation.mutateAsync({
id: suggestion.projectId,
payload,
});
- setApplied(true);
+ await projectQuery.refetch();
+ toast.success(
+ t`Changes applied. You can fine-tune them anytime in project settings.`,
+ );
} catch {
- toast.error(t`Could not apply the suggested changes`);
+ toast.error(t`Could not apply the changes. Nothing was saved.`);
}
};
+ if (applied) {
+ return (
+
+
+
+
+
+ These changes are applied to your project.
+
+
+
+
+ );
+ }
+
return (
-
- Suggested project changes
+
+ Suggested changes for your project
- {applied ? (
-
- Applied
-
- ) : dismissed ? (
+ {dismissed && (
Dismissed
- ) : null}
+ )}
- {suggestion.summary && {suggestion.summary}}
-
-
- {suggestion.changes.map((change) => (
-
- {!applied && !dismissed && (
-
- setSelected((prev) => ({
- ...prev,
- [change.field]: event.currentTarget.checked,
- }))
- }
- {...testId(`suggestion-field-checkbox-${change.field}`)}
- />
- )}
-
-
- {humanizeField(change.field)}
-
-
-
- {formatValue(change.current)}
- {" "}
-
- {formatValue(change.proposed)}
-
-
- {change.reason && (
-
- {change.reason}
-
+ {suggestion.summary && {suggestion.summary}}
+ {!dismissed && (
+
+
+ Review each change below. You can edit the new text first.
+ Nothing changes until you apply.
+
+
+ )}
+
+
+ {suggestion.changes.map((change) => {
+ const value = effectiveValue(change);
+ const isBoolean = typeof change.proposed === "boolean";
+ const isEditable =
+ !isBoolean && typeof change.proposed !== "object";
+ return (
+
+ {!dismissed && (
+
+ setSelected((prev) => ({
+ ...prev,
+ [change.field]: event.currentTarget.checked,
+ }))
+ }
+ {...testId(`suggestion-field-checkbox-${change.field}`)}
+ />
)}
-
-
- ))}
+
+
+ {fieldLabel(change.field)}
+
+
+
+
+ {isBoolean ? (
+
+ setEdited((prev) => ({
+ ...prev,
+ [change.field]: event.currentTarget.checked,
+ }))
+ }
+ />
+ ) : !isEditable || dismissed ? (
+
+ ) : null}
+
+ {isEditable && !dismissed && (
+
+
+ );
+ })}
- {!applied && !dismissed && (
+ {!dismissed && (