Files
fusion/packages/dashboard/app/components/ConversationHistory.tsx
gsxdsm d4d7623ee0 FN-6749: rebaseline i18n lint guardrail
Rebaseline the i18n hardcoded-string lint guardrail for shipping dashboard surfaces.

- Add scoped i18n lint ignores for non-shipping tests, stories, and deferred source-copy files with follow-up rationale.
- Localize remaining plugin/view copy and replace technical glyph literals that should not be linted as prose.
- Refresh synced locale catalogs and generated resource types, and document the lint baseline policy.

Files changed:
 .changeset/fn-6749-i18n-lint-rebaseline.md         |    5 +
 docs/i18n-contributing.md                          |   14 +
 i18next.config.ts                                  |   96 ++
 .../dashboard/app/components/AgentDetailView.tsx   |    4 +-
 .../app/components/ConversationHistory.tsx         |    4 +-
 .../app/components/DockerNodeOnboardingModal.tsx   |    8 +-
 .../components/MilestoneSliceInterviewModal.tsx    |    4 +-
 .../app/components/MissionInterviewModal.tsx       |    4 +-
 .../app/components/ModelSelectionModal.tsx         |    4 +-
 .../dashboard/app/components/NodeDetailModal.tsx   |   15 +-
 .../dashboard/app/components/PlanningModeModal.tsx |    4 +-
 .../dashboard/app/components/SessionTerminal.tsx   |   15 +-
 .../app/components/SubtaskBreakdownModal.tsx       |    4 +-
 packages/dashboard/app/components/TaskForm.tsx     |    4 +-
 .../dashboard/app/components/TerminalModal.tsx     |   18 +-
 .../dashboard/app/plugins/pluginViewRegistry.tsx   |    7 +-
 packages/i18n/locales/en/app.json                  |  890 +++++++++---
 packages/i18n/locales/en/common.json               |   82 +-
 packages/i18n/locales/es/app.json                  |  986 ++++++++++---
 packages/i18n/locales/es/common.json               |   82 +-
 packages/i18n/locales/fr/app.json                  |  986 ++++++++++---
 packages/i18n/locales/fr/common.json               |   82 +-
 packages/i18n/locales/ko/app.json                  | 1482 ++++++++++++++------
 packages/i18n/locales/ko/cli.json                  |    8 +-
 packages/i18n/locales/ko/common.json               |   82 +-
 packages/i18n/locales/zh-CN/app.json               | 1482 ++++++++++++++------
 packages/i18n/locales/zh-CN/cli.json               |    8 +-
 packages/i18n/locales/zh-CN/common.json            |   82 +-
 packages/i18n/locales/zh-TW/app.json               | 1482 ++++++++++++++------
 packages/i18n/locales/zh-TW/cli.json               |    8 +-
 packages/i18n/locales/zh-TW/common.json            |   82 +-
 packages/i18n/src/resources.d.ts                   |  459 +++---
 32 files changed, 6055 insertions(+), 2438 deletions(-)

Fusion-Task-Id: FN-6749

Fusion-Task-Lineage: 789a7cbb-fdf8-4190-8cd2-6bf7d5e2d552
2026-06-19 22:14:57 -07:00

165 lines
6.0 KiB
TypeScript

import "./ConversationHistory.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { PlanningQuestion } from "@fusion/core";
import { useState } from "react";
import type { ConversationHistoryEntry } from "../api";
const COMMENT_ICON = "💬";
interface ConversationHistoryProps {
entries: ConversationHistoryEntry[];
defaultShowThinking?: boolean;
}
interface NumberedEntry extends ConversationHistoryEntry {
questionNumber: number | null;
}
function getResponseValue(entry: ConversationHistoryEntry): unknown {
const { question, response } = entry;
if (!question) return response;
if (response && typeof response === "object" && !Array.isArray(response)) {
const record = response as Record<string, unknown>;
if (question.id in record) {
return record[question.id];
}
}
return response;
}
function formatResponse(question: PlanningQuestion, responseValue: unknown, t: TFunction<"app">): string {
switch (question.type) {
case "text": {
if (typeof responseValue === "string") return responseValue;
return responseValue == null ? "" : String(responseValue);
}
case "single_select": {
if (typeof responseValue === "string") {
const selected = question.options?.find((option) => option.id === responseValue);
return selected?.label ?? responseValue;
}
return responseValue == null ? "" : String(responseValue);
}
case "multi_select": {
if (Array.isArray(responseValue)) {
return responseValue
.map((value) => {
if (typeof value !== "string") {
return String(value);
}
const selected = question.options?.find((option) => option.id === value);
return selected?.label ?? value;
})
.join(", ");
}
return responseValue == null ? "" : String(responseValue);
}
case "confirm": {
if (responseValue === true) return t("conversation.confirm.yes", "Yes");
if (responseValue === false) return t("conversation.confirm.no", "No");
return responseValue == null ? "" : String(responseValue);
}
default:
return responseValue == null ? "" : JSON.stringify(responseValue);
}
}
function normalizeEntries(entries: ConversationHistoryEntry[]): NumberedEntry[] {
let questionCounter = 0;
const normalized: NumberedEntry[] = [];
for (const entry of entries) {
if (entry.question) {
questionCounter += 1;
normalized.push({ ...entry, questionNumber: questionCounter });
continue;
}
if (entry.thinkingOutput) {
normalized.push({ ...entry, questionNumber: null });
}
}
return normalized;
}
export function ConversationHistory({ entries, defaultShowThinking = false }: ConversationHistoryProps) {
const { t } = useTranslation("app");
const [expandedThinking, setExpandedThinking] = useState<Record<number, boolean>>({});
const normalizedEntries = normalizeEntries(entries);
if (normalizedEntries.length === 0) {
return null;
}
return (
<div className="conversation-history" data-testid="conversation-history">
{normalizedEntries.map((entry, index) => {
const hasQuestion = Boolean(entry.question);
const hasThinking = Boolean(entry.thinkingOutput);
const isExpanded = expandedThinking[index] ?? defaultShowThinking;
const responseValue = hasQuestion ? getResponseValue(entry) : undefined;
const formattedResponse =
entry.question && responseValue !== undefined
? formatResponse(entry.question, responseValue, t)
: "";
const responseRecord =
entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
? (entry.response as Record<string, unknown>)
: undefined;
const comment =
typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
return (
<div key={`${entry.question?.id ?? "thinking"}-${index}`} className="conversation-entry">
{hasQuestion ? (
<div className="conversation-entry-question">
<span className="conversation-entry-question-label">Q{entry.questionNumber}</span>
<p>{entry.question?.question}</p>
</div>
) : (
<div className="conversation-entry-question">
<span className="conversation-entry-question-label">{t("conversation.aiReasoning", "AI Reasoning")}</span>
</div>
)}
{hasQuestion && (
<div className="conversation-entry-response">
<strong>{t("conversation.yourResponse", "Your response")}</strong>
<p>{formattedResponse || "—"}</p>
{comment && <p className="conversation-comment">{COMMENT_ICON} {comment}</p>}
</div>
)}
{hasThinking && (
<div className="conversation-entry-thinking">
<button
type="button"
className="conversation-thinking-toggle"
onClick={() => {
setExpandedThinking((current) => ({
...current,
[index]: !isExpanded,
}));
}}
aria-expanded={isExpanded}
>
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
{isExpanded
? t("conversation.hide", `Hide {{type}}`, { type: hasQuestion ? t("conversation.aiThinking", "AI thinking") : t("conversation.aiReasoning", "AI reasoning") })
: t("conversation.show", `Show {{type}}`, { type: hasQuestion ? t("conversation.aiThinking", "AI thinking") : t("conversation.aiReasoning", "AI reasoning") })}
</button>
{isExpanded && <pre>{entry.thinkingOutput}</pre>}
</div>
)}
</div>
);
})}
</div>
);
}