feat(i18n): full-sweep string migration — 5,930 keys across 5 locales (#1352)
Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
carry ~5,930 keys each across common/app/errors/cli namespaces;
CLI bundles regenerated (6 locales incl. ko)
Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
literal {{placeholders}}); dashboard vitest.setup boots a minimal en
i18next instance for the same reason
Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render } from "ink-testing-library";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { DashboardApp } from "../app.js";
|
||||
import { initCliI18n } from "../../../i18n/index.js";
|
||||
import { DashboardTUI } from "../controller.js";
|
||||
import { createInitialState } from "../state.js";
|
||||
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
|
||||
@@ -10,8 +12,17 @@ function newController(): DashboardTUI {
|
||||
return new DashboardTUI();
|
||||
}
|
||||
|
||||
// Initialize the real CLI i18n instance so t() interpolation runs in tests —
|
||||
// without a provider, react-i18next's fallback returns defaults with literal
|
||||
// {{placeholders}}. Mirrors the production wrap in controller.render().
|
||||
const testI18n = initCliI18n("en");
|
||||
|
||||
function renderDashboardAppNode(controller: DashboardTUI) {
|
||||
return React.createElement(DashboardApp, { controller });
|
||||
return React.createElement(
|
||||
I18nextProvider,
|
||||
{ i18n: testI18n },
|
||||
React.createElement(DashboardApp, { controller }),
|
||||
);
|
||||
}
|
||||
|
||||
function makeSystemInfo() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
computeCapacityRisk,
|
||||
DEFAULT_CAPACITY_RISK_TODO_THRESHOLD,
|
||||
@@ -236,6 +237,7 @@ export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectC
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const { t } = useTranslation("app");
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
|
||||
const shellHost = useShellHostContext();
|
||||
@@ -1336,7 +1338,7 @@ function AppInner() {
|
||||
if (showBackendConnectionErrorPage) {
|
||||
return (
|
||||
<BackendConnectionErrorPage
|
||||
errorMessage={projectsError ?? "Failed to fetch projects"}
|
||||
errorMessage={projectsError ?? t("app.backendError.failedFetch", "Failed to fetch projects")}
|
||||
isRetrying={retryingProjects}
|
||||
onRetry={handleRetryProjects}
|
||||
onManageConnection={shellApi ? () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, FileText } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Agent } from "../api";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
@@ -17,6 +18,7 @@ interface LiveAgentCardProps {
|
||||
const TASK_STATUS_POLL_MS = 5000;
|
||||
|
||||
function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgentCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId);
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
|
||||
@@ -64,8 +66,8 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
const nextMs = new Date(agent.lastHeartbeatAt).getTime() + intervalMs;
|
||||
const deltaSec = Math.round((nextMs - Date.now()) / 1000);
|
||||
if (!Number.isFinite(deltaSec)) return null;
|
||||
if (deltaSec <= 0) return `Heartbeat overdue ${formatElapsed(-deltaSec)}`;
|
||||
return `Next heartbeat in ${formatElapsed(deltaSec)}`;
|
||||
if (deltaSec <= 0) return t("agents.heartbeatOverdue", "Heartbeat overdue {{elapsed}}", { elapsed: formatElapsed(-deltaSec) });
|
||||
return t("agents.nextHeartbeat", "Next heartbeat in {{elapsed}}", { elapsed: formatElapsed(deltaSec) });
|
||||
})();
|
||||
|
||||
const currentStep = task?.steps?.[task.currentStep ?? 0];
|
||||
@@ -100,7 +102,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select agent ${agent.name}`}
|
||||
aria-label={t("agents.selectAgent", "Select agent {{name}}", { name: agent.name })}
|
||||
>
|
||||
<div className="live-agent-card-header">
|
||||
<div className="live-agent-card-name">
|
||||
@@ -122,12 +124,11 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
// SSE stream to attach to; useLiveTranscript bails out with
|
||||
// isConnected=false. Showing "Connecting..." here is misleading
|
||||
// — the agent is just idle.
|
||||
<span>{agent.state === "running" ? "Starting..." : "Idle — no task assigned"}</span>
|
||||
<span>{agent.state === "running" ? t("agents.starting", "Starting...") : t("agents.idleNoTask", "Idle — no task assigned")}</span>
|
||||
) : currentStep ? (
|
||||
<>
|
||||
<div className="live-agent-card-status">
|
||||
Step {stepNumber}
|
||||
{totalSteps ? `/${totalSteps}` : ""}: {currentStep.name}
|
||||
{t("agents.step", "Step {{number}}{{total}}: {{name}}", { number: stepNumber, total: totalSteps ? `/${totalSteps}` : "", name: currentStep.name })}
|
||||
</div>
|
||||
{executorModel && (
|
||||
<div className="live-agent-card-status-sub">
|
||||
@@ -135,11 +136,11 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
</div>
|
||||
)}
|
||||
<div className="live-agent-card-status-sub">
|
||||
{isConnected ? "Waiting for output..." : "Connecting to log stream..."}
|
||||
{isConnected ? t("agents.waitingOutput", "Waiting for output...") : t("agents.connectingStream", "Connecting to log stream...")}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span>{isConnected ? "Waiting for output..." : "Connecting..."}</span>
|
||||
<span>{isConnected ? t("agents.waitingOutput", "Waiting for output...") : t("agents.connecting", "Connecting...")}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -167,11 +168,11 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
type="button"
|
||||
className="live-agent-card-logs-btn"
|
||||
onClick={handleViewLogs}
|
||||
title="View live run logs"
|
||||
aria-label={`View live logs for ${agent.taskId}`}
|
||||
title={t("agents.viewLiveLogs", "View live run logs")}
|
||||
aria-label={t("agents.viewLogsFor", "View live logs for {{taskId}}", { taskId: agent.taskId })}
|
||||
>
|
||||
<FileText size={12} />
|
||||
<span>Live logs</span>
|
||||
<span>{t("agents.liveLogs", "Live logs")}</span>
|
||||
</button>
|
||||
)}
|
||||
{isConnected && <Activity size={12} className="live-agent-streaming-dot" />}
|
||||
@@ -196,6 +197,7 @@ interface ActiveAgentsPanelProps {
|
||||
}
|
||||
|
||||
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs, className = "" }: ActiveAgentsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Dedupe by id defensively. The store should return unique agents but a race
|
||||
// between the initial fetch and an SSE refresh can briefly surface the same
|
||||
// agent twice — without this guard React floods the console with duplicate
|
||||
@@ -208,7 +210,7 @@ export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTask
|
||||
<div className={`active-agents-panel ${className}`.trim()}>
|
||||
<div className="active-agents-panel-header">
|
||||
<Activity size={16} />
|
||||
<span>Active Agents ({uniqueAgents.length})</span>
|
||||
<span>{t("agents.activeAgents", "Active Agents ({{count}})", { count: uniqueAgents.length })}</span>
|
||||
</div>
|
||||
<div className="active-agents-grid">
|
||||
{uniqueAgents.map(agent => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// in ScriptsModal.css. Until extracted, import that file so this eager modal is styled.
|
||||
import "./ScriptsModal.css";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
@@ -91,7 +92,7 @@ function formatTimestamp(timestamp: string): string {
|
||||
* - Event type filter
|
||||
* - Real-time updates via useActivityLog hook
|
||||
*/
|
||||
export function ActivityLogModal({
|
||||
export function ActivityLogModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tasks: _tasks,
|
||||
@@ -101,6 +102,7 @@ export function ActivityLogModal({
|
||||
onProjectFilterChange,
|
||||
currentProject,
|
||||
}: ActivityLogModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
|
||||
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
|
||||
const [showConfirmClear, setShowConfirmClear] = useState(false);
|
||||
@@ -209,7 +211,7 @@ export function ActivityLogModal({
|
||||
<div className="modal-header activity-log-header">
|
||||
<div className="activity-log-title">
|
||||
<History size={18} />
|
||||
<span>Activity Log</span>
|
||||
<span>{t("activityLog.title", "Activity Log")}</span>
|
||||
</div>
|
||||
<div className="activity-log-actions">
|
||||
{/* Project filter dropdown (when projects provided) */}
|
||||
@@ -222,7 +224,7 @@ export function ActivityLogModal({
|
||||
className="activity-log-filter-select"
|
||||
data-testid="activity-project-filter"
|
||||
>
|
||||
<option value="all">All Projects</option>
|
||||
<option value="all">{t("activityLog.allProjects", "All Projects")}</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
@@ -241,7 +243,7 @@ export function ActivityLogModal({
|
||||
className="activity-log-filter-select"
|
||||
data-testid="activity-filter"
|
||||
>
|
||||
<option value="all">All Events</option>
|
||||
<option value="all">{t("activityLog.allEvents", "All Events")}</option>
|
||||
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
|
||||
<option key={type} value={type}>
|
||||
{label}
|
||||
@@ -254,7 +256,7 @@ export function ActivityLogModal({
|
||||
<button
|
||||
className="activity-log-refresh"
|
||||
onClick={() => refresh()}
|
||||
title="Refresh"
|
||||
title={t("activityLog.refresh", "Refresh")}
|
||||
data-testid="activity-refresh"
|
||||
>
|
||||
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
@@ -265,7 +267,7 @@ export function ActivityLogModal({
|
||||
<button
|
||||
className="activity-log-clear"
|
||||
onClick={() => setShowConfirmClear(true)}
|
||||
title="Clear Log"
|
||||
title={t("activityLog.clearLog", "Clear Log")}
|
||||
data-testid="activity-clear"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -276,8 +278,8 @@ export function ActivityLogModal({
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
aria-label={t("actions.close", "Close")}
|
||||
title={t("actions.close", "Close")}
|
||||
data-testid="activity-close"
|
||||
>
|
||||
×
|
||||
@@ -287,7 +289,7 @@ export function ActivityLogModal({
|
||||
{/* Active filters display */}
|
||||
{isFilterActive && (
|
||||
<div className="activity-log-active-filters">
|
||||
<span className="activity-log-filter-label">Active filters:</span>
|
||||
<span className="activity-log-filter-label">{t("activityLog.activeFilters", "Active filters:")}</span>
|
||||
{filteredProjectId !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Project: {projects.find(p => p.id === filteredProjectId)?.name || filteredProjectId}
|
||||
@@ -306,7 +308,7 @@ export function ActivityLogModal({
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
{t("activityLog.clearFilters", "Clear all")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -324,9 +326,9 @@ export function ActivityLogModal({
|
||||
<div className="activity-log-empty" data-testid="activity-empty">
|
||||
<History size={48} className="activity-log-empty-icon" />
|
||||
<p>
|
||||
{isFilterActive
|
||||
? "No activity matches the current filters"
|
||||
: "No activity recorded yet"}
|
||||
{isFilterActive
|
||||
? t("activityLog.noMatchingActivity", "No activity matches the current filters")
|
||||
: t("activityLog.noActivityRecorded", "No activity recorded yet")}
|
||||
</p>
|
||||
{isFilterActive && (
|
||||
<button
|
||||
@@ -337,7 +339,7 @@ export function ActivityLogModal({
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
{t("activityLog.clearFiltersBtnLabel", "Clear Filters")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -402,7 +404,7 @@ export function ActivityLogModal({
|
||||
onClick={refresh}
|
||||
data-testid="activity-load-more"
|
||||
>
|
||||
Load More
|
||||
{t("activityLog.loadMore", "Load More")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -417,20 +419,20 @@ export function ActivityLogModal({
|
||||
{showConfirmClear && (
|
||||
<div className="activity-log-confirm-overlay">
|
||||
<div className="activity-log-confirm-dialog">
|
||||
<h3>Clear Activity Log?</h3>
|
||||
<p>This will permanently delete all activity log entries. This action cannot be undone.</p>
|
||||
<h3>{t("activityLog.confirmClear", "Clear Activity Log?")}</h3>
|
||||
<p>{t("activityLog.confirmClearMessage", "This will permanently delete all activity log entries. This action cannot be undone.")}</p>
|
||||
<div className="activity-log-confirm-actions">
|
||||
<button
|
||||
className="activity-log-confirm-cancel"
|
||||
onClick={() => setShowConfirmClear(false)}
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="activity-log-confirm-clear"
|
||||
onClick={handleClearLog}
|
||||
>
|
||||
Clear Log
|
||||
{t("activityLog.confirmClearButton", "Clear Log")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NodeProjectMappingInput, ProjectInfo, RemoteNodeDiscoveredProject, RemoteNodeProjectDiscoveryResult } from "../api";
|
||||
import { validateProjectPath } from "../utils/projectDetection";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -47,25 +49,25 @@ interface FormErrors {
|
||||
const MAX_CONCURRENT_MIN = 1;
|
||||
const MAX_CONCURRENT_MAX = 10;
|
||||
|
||||
function validateInput(input: AddNodeInput): FormErrors {
|
||||
function validateInput(input: AddNodeInput, t: TFunction<"app">): FormErrors {
|
||||
const errors: FormErrors = { projectMappings: {} };
|
||||
|
||||
if (!input.name.trim()) {
|
||||
errors.name = "Name is required";
|
||||
errors.name = t("nodes.nameRequired", "Name is required");
|
||||
}
|
||||
|
||||
if (input.type === "remote" && !input.url?.trim()) {
|
||||
errors.url = "URL is required for remote nodes";
|
||||
errors.url = t("nodes.urlRequired", "URL is required for remote nodes");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) {
|
||||
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
|
||||
errors.maxConcurrent = t("nodes.concurrencyRange", "Concurrency must be between {{min}} and {{max}}", { min: MAX_CONCURRENT_MIN, max: MAX_CONCURRENT_MAX });
|
||||
}
|
||||
|
||||
for (const mapping of input.projectMappings) {
|
||||
const validation = validateProjectPath(mapping.path);
|
||||
if (!validation.valid) {
|
||||
errors.projectMappings[mapping.projectId] = validation.error ?? "Path is invalid";
|
||||
errors.projectMappings[mapping.projectId] = validation.error ?? t("nodes.pathInvalid", "Path is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +77,7 @@ function validateInput(input: AddNodeInput): FormErrors {
|
||||
type DiscoveryState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjects, addToast, projects }: AddNodeModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
useMobileScrollLock(isOpen);
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<"local" | "remote">("local");
|
||||
@@ -143,7 +146,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
if (!trimmedUrl) {
|
||||
setErrors((current) => ({ ...current, url: "URL is required for remote nodes" }));
|
||||
setErrors((current) => ({ ...current, url: t("nodes.urlRequired", "URL is required for remote nodes") }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,9 +178,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
} catch (error) {
|
||||
setDiscoveryState("error");
|
||||
setDiscoveredProjects([]);
|
||||
setDiscoveryError(error instanceof Error ? error.message : "Failed to discover remote projects");
|
||||
setDiscoveryError(error instanceof Error ? error.message : t("nodes.discoveryFailed", "Failed to discover remote projects"));
|
||||
}
|
||||
}, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, url]);
|
||||
}, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, t, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "remote") {
|
||||
@@ -195,7 +198,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const validationErrors = validateInput(input);
|
||||
const validationErrors = validateInput(input, t);
|
||||
setErrors(validationErrors);
|
||||
|
||||
if (
|
||||
@@ -208,7 +211,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
}
|
||||
|
||||
if (input.type === "remote" && discoveryState !== "success") {
|
||||
setDiscoveryError("Discover remote projects before adding this node.");
|
||||
setDiscoveryError(t("nodes.discoverBeforeAdding", "Discover remote projects before adding this node."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -216,15 +219,15 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
|
||||
try {
|
||||
await onSubmit(input);
|
||||
addToast(`Node "${input.name}" registered`, "success");
|
||||
addToast(t("nodes.registered", "Node \"{{name}}\" registered", { name: input.name }), "success");
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to register node";
|
||||
const message = error instanceof Error ? error.message : t("nodes.registerFailed", "Failed to register node");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit]);
|
||||
}, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit, t]);
|
||||
|
||||
const toggleProjectSelection = (project: ProjectInfo) => {
|
||||
setSelectedProjectPaths((current) => {
|
||||
@@ -253,25 +256,25 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={closeModal}>
|
||||
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add Node">
|
||||
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.addNode", "Add Node")}>
|
||||
<div className="modal-header">
|
||||
<h3>Add Node</h3>
|
||||
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label="Close add node modal">
|
||||
<h3>{t("nodes.addNode", "Add Node")}</h3>
|
||||
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label={t("nodes.closeNodeModal", "Close add node modal")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body add-node-modal__body">
|
||||
<p className="add-node-modal__description">Register an existing Fusion node by providing its connection details and concurrency settings.</p>
|
||||
<p className="add-node-modal__description">{t("nodes.description", "Register an existing Fusion node by providing its connection details and concurrency settings.")}</p>
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>Name</span>
|
||||
<span>{t("nodes.name", "Name")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Build Machine"
|
||||
placeholder={t("nodes.namePlaceholder", "Build Machine")}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
@@ -288,7 +291,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
disabled={isSubmitting}
|
||||
aria-pressed={type === "local"}
|
||||
>
|
||||
Local
|
||||
{t("nodes.local", "Local")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -298,14 +301,14 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
disabled={isSubmitting}
|
||||
aria-pressed={type === "remote"}
|
||||
>
|
||||
Remote
|
||||
{t("nodes.remote", "Remote")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{type === "remote" && (
|
||||
<div className="add-node-modal__remote-fields" data-testid="remote-fields-container" data-visible>
|
||||
<label className="add-node-modal__field">
|
||||
<span>Reachable URL / Hostname</span>
|
||||
<span>{t("nodes.reachableUrl", "Reachable URL / Hostname")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
@@ -319,27 +322,27 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
</label>
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>API Key Mode</span>
|
||||
<span>{t("nodes.apiKeyMode", "API Key Mode")}</span>
|
||||
<select
|
||||
className="select"
|
||||
value={apiKeyMode}
|
||||
onChange={(event) => setApiKeyMode(event.target.value as "auto-generate" | "provide")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="auto-generate">Auto-generate</option>
|
||||
<option value="provide">Provide key manually</option>
|
||||
<option value="auto-generate">{t("nodes.autoGenerate", "Auto-generate")}</option>
|
||||
<option value="provide">{t("nodes.provideManually", "Provide key manually")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{apiKeyMode === "provide" && (
|
||||
<label className="add-node-modal__field">
|
||||
<span>API Key</span>
|
||||
<span>{t("nodes.apiKey", "API Key")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="Enter node API key"
|
||||
placeholder={t("nodes.apiKeyPlaceholder", "Enter node API key")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
@@ -352,18 +355,18 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
onClick={() => void handleDiscoverProjects()}
|
||||
disabled={isSubmitting || discoveryState === "loading"}
|
||||
>
|
||||
{discoveryState === "loading" ? "Discovering..." : "Discover Remote Projects"}
|
||||
{discoveryState === "loading" ? t("nodes.discovering", "Discovering...") : t("nodes.discoverRemoteProjects", "Discover Remote Projects")}
|
||||
</button>
|
||||
{discoveryState === "success" && (
|
||||
<span className="add-node-modal__discovery-state" data-state="success">
|
||||
{discoveredProjects.length > 0 ? `Discovered ${discoveredProjects.length} remote project${discoveredProjects.length === 1 ? "" : "s"}.` : "No projects discovered on remote node."}
|
||||
{discoveredProjects.length > 0 ? t("nodes.discoveredCount", "Discovered {{count}} remote project{{plural}}", { count: discoveredProjects.length, plural: discoveredProjects.length === 1 ? "" : "s" }) : t("nodes.noProjectsDiscovered", "No projects discovered on remote node.")}
|
||||
</span>
|
||||
)}
|
||||
{discoveryState === "error" && discoveryError && (
|
||||
<span className="form-error add-node-modal__error">{discoveryError}</span>
|
||||
)}
|
||||
{discoveryState === "idle" && (
|
||||
<span className="add-node-modal__hint">Discover remote projects before adding this node.</span>
|
||||
<span className="add-node-modal__hint">{t("nodes.discoverBeforeAdding", "Discover remote projects before adding this node.")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -384,7 +387,7 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
)}
|
||||
|
||||
<label className="add-node-modal__field">
|
||||
<span>Max Concurrent</span>
|
||||
<span>{t("nodes.maxConcurrent", "Max Concurrent")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -395,15 +398,15 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.maxConcurrent)}
|
||||
/>
|
||||
<span className="add-node-modal__hint">Max simultaneous task agents (1–10)</span>
|
||||
<span className="add-node-modal__hint">{t("nodes.maxConcurrentHint", "Max simultaneous task agents (1–10)")}</span>
|
||||
{errors.maxConcurrent && <span className="form-error add-node-modal__error">{errors.maxConcurrent}</span>}
|
||||
</label>
|
||||
|
||||
<section className="add-node-modal__projects" aria-label="Project path mappings">
|
||||
<h4 className="add-node-modal__projects-title">Attach Existing Projects</h4>
|
||||
<p className="add-node-modal__hint">Select existing projects to run on this node and provide the node-specific absolute path for each one.</p>
|
||||
<h4 className="add-node-modal__projects-title">{t("nodes.attachProjects", "Attach Existing Projects")}</h4>
|
||||
<p className="add-node-modal__hint">{t("nodes.attachProjectsHint", "Select existing projects to run on this node and provide the node-specific absolute path for each one.")}</p>
|
||||
{projects.length === 0 ? (
|
||||
<p className="add-node-modal__hint">No projects are currently registered.</p>
|
||||
<p className="add-node-modal__hint">{t("nodes.noProjects", "No projects are currently registered.")}</p>
|
||||
) : (
|
||||
<div className="add-node-modal__project-list">
|
||||
{projects.map((project) => {
|
||||
@@ -422,18 +425,18 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
</label>
|
||||
{selected && (
|
||||
<label className="add-node-modal__field">
|
||||
<span>Path on this node</span>
|
||||
<span>{t("nodes.pathOnNode", "Path on this node")}</span>
|
||||
{type === "remote" && discoveryState === "success" && (
|
||||
<span className="add-node-modal__hint">
|
||||
{(() => {
|
||||
const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name);
|
||||
if (matches.length === 1) {
|
||||
return `Remote-authoritative path discovered: ${matches[0].path}`;
|
||||
return t("nodes.pathDiscovered", "Remote-authoritative path discovered: {{path}}", { path: matches[0].path });
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return "Multiple remote projects matched this name. Enter the correct path manually.";
|
||||
return t("nodes.multipleMatches", "Multiple remote projects matched this name. Enter the correct path manually.");
|
||||
}
|
||||
return "No exact remote name match. Enter this path manually.";
|
||||
return t("nodes.noMatch", "No exact remote name match. Enter this path manually.");
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
@@ -459,9 +462,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={closeModal} disabled={isSubmitting}>Cancel</button>
|
||||
<button className="btn btn-sm" onClick={closeModal} disabled={isSubmitting}>{t("common.cancel", "Cancel")}</button>
|
||||
<button className="btn btn-primary btn-sm" data-testid="add-node-submit" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add Node"}
|
||||
{isSubmitting ? t("nodes.adding", "Adding...") : t("nodes.addNode", "Add Node")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import "./AgentErrorDetailsModal.css";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
||||
|
||||
@@ -50,6 +51,7 @@ export function buildAgentErrorIssueUrl(errorText: string, context: AgentErrorIs
|
||||
|
||||
export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext }: AgentErrorDetailsModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { t } = useTranslation("app");
|
||||
const issueUrl = useMemo(() => buildAgentErrorIssueUrl(errorText, issueContext), [errorText, issueContext]);
|
||||
|
||||
if (!open) {
|
||||
@@ -62,7 +64,7 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
<AlertCircle size={16} />
|
||||
Agent Error Details
|
||||
{t("agentError.title", "Agent Error Details")}
|
||||
</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
@@ -79,10 +81,10 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}}
|
||||
aria-label={copied ? "Copied error to clipboard" : "Copy error to clipboard"}
|
||||
aria-label={copied ? t("agentError.copiedLabel", "Copied error to clipboard") : t("agentError.copyLabel", "Copy error to clipboard")}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied ? t("agentError.copied", "Copied") : t("agentError.copy", "Copy")}
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-sm btn-warning"
|
||||
@@ -95,7 +97,7 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Report on GitHub
|
||||
{t("agentError.reportOnGithub", "Report on GitHub")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +113,11 @@ interface AgentErrorIndicatorProps {
|
||||
|
||||
export function AgentErrorIndicator({ errorText, issueContext, summaryPrefix = "Error" }: AgentErrorIndicatorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="agent-error-indicator" onClick={() => setOpen(true)} aria-label="Open error details">
|
||||
<button type="button" className="agent-error-indicator" onClick={() => setOpen(true)} aria-label={t("agentError.openDetails", "Open error details")}>
|
||||
<AlertCircle size={14} />
|
||||
<span className="agent-error-indicator__label">{summaryPrefix}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentGenerationSpec } from "../api";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ export function AgentGenerationModal({
|
||||
onGenerated,
|
||||
projectId,
|
||||
}: AgentGenerationModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
useMobileScrollLock(isOpen);
|
||||
const [roleDescription, setRoleDescription] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "input" });
|
||||
@@ -112,7 +114,7 @@ export function AgentGenerationModal({
|
||||
err instanceof Error ? err.message : "Failed to generate agent specification";
|
||||
// Handle rate limit errors with user-friendly message
|
||||
if (message.includes("429") || message.toLowerCase().includes("rate limit")) {
|
||||
setError("Too many requests. Please wait a moment and try again.");
|
||||
setError(t("agents.generation.rateLimited", "Too many requests. Please wait a moment and try again."));
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
@@ -172,12 +174,12 @@ export function AgentGenerationModal({
|
||||
<div className="agent-dialog-header">
|
||||
<span className="agent-dialog-header-title">
|
||||
<span className="agent-dialog-header-sparkle">✨</span>
|
||||
Generate Agent
|
||||
{t("agents.generation.title", "Generate Agent")}
|
||||
</span>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={handleCancel}
|
||||
aria-label="Close"
|
||||
aria-label={t("actions.close", "Close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -190,18 +192,16 @@ export function AgentGenerationModal({
|
||||
{view.type === "input" && (
|
||||
<div>
|
||||
<p className="agent-dialog-info">
|
||||
Describe your agent's role and the AI will generate a complete
|
||||
specification including system prompt, suggested configuration, and
|
||||
more.
|
||||
{t("agents.generation.info", "Describe your agent's role and the AI will generate a complete specification including system prompt, suggested configuration, and more.")}
|
||||
</p>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-role-description">Role Description</label>
|
||||
<label htmlFor="agent-role-description">{t("agents.generation.roleLabel", "Role Description")}</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="agent-role-description"
|
||||
className="input agent-dialog-textarea"
|
||||
rows={4}
|
||||
placeholder='e.g. "Senior frontend code reviewer who specializes in React accessibility"'
|
||||
placeholder={t("agents.generation.rolePlaceholder", 'e.g. "Senior frontend code reviewer who specializes in React accessibility"')}
|
||||
value={roleDescription}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -217,7 +217,7 @@ export function AgentGenerationModal({
|
||||
id="role-description-hint"
|
||||
className="agent-dialog-hint"
|
||||
>
|
||||
<span>Describe what your agent should do</span>
|
||||
<span>{t("agents.generation.roleHint", "Describe what your agent should do")}</span>
|
||||
<span>
|
||||
{roleDescription.length}/{MAX_ROLE_LENGTH}
|
||||
</span>
|
||||
@@ -230,7 +230,7 @@ export function AgentGenerationModal({
|
||||
<div className="agent-dialog-loading-center">
|
||||
<div className="agent-dialog-spinner spin" />
|
||||
<p className="agent-dialog-loading-text">
|
||||
Generating agent specification...
|
||||
{t("agents.generation.loading", "Generating agent specification...")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -240,7 +240,7 @@ export function AgentGenerationModal({
|
||||
<div className="agent-dialog-summary agent-dialog-summary--spaced">
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
|
||||
Title
|
||||
{t("agents.generation.previewTitle", "Title")}
|
||||
</span>
|
||||
<span className="agent-dialog-summary-row-value">
|
||||
{view.spec.icon} {view.spec.title}
|
||||
@@ -248,19 +248,19 @@ export function AgentGenerationModal({
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
|
||||
Role
|
||||
{t("agents.generation.previewRole", "Role")}
|
||||
</span>
|
||||
<span>{view.spec.role}</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
|
||||
Description
|
||||
{t("agents.generation.previewDescription", "Description")}
|
||||
</span>
|
||||
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--body">{view.spec.description}</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
|
||||
Thinking
|
||||
{t("agents.generation.previewThinking", "Thinking")}
|
||||
</span>
|
||||
<span className="agent-dialog-summary-row-value agent-dialog-summary-row-value--capitalize">
|
||||
{view.spec.thinkingLevel}
|
||||
@@ -268,7 +268,7 @@ export function AgentGenerationModal({
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label agent-dialog-summary-row-label--fixed">
|
||||
Max Turns
|
||||
{t("agents.generation.previewMaxTurns", "Max Turns")}
|
||||
</span>
|
||||
<span>{view.spec.maxTurns}</span>
|
||||
</div>
|
||||
@@ -277,13 +277,13 @@ export function AgentGenerationModal({
|
||||
{/* System prompt preview */}
|
||||
<div className="agent-dialog-field">
|
||||
<label>
|
||||
System Prompt
|
||||
{t("agents.generation.systemPrompt", "System Prompt")}
|
||||
<button
|
||||
type="button"
|
||||
className="agent-dialog-expand-btn"
|
||||
onClick={() => setSystemPromptExpanded(!systemPromptExpanded)}
|
||||
>
|
||||
{systemPromptExpanded ? "Collapse" : "Expand"}
|
||||
{systemPromptExpanded ? t("agents.generation.collapse", "Collapse") : t("agents.generation.expand", "Expand")}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
@@ -303,7 +303,7 @@ export function AgentGenerationModal({
|
||||
{/* Footer */}
|
||||
<div className="agent-dialog-footer">
|
||||
<button className="btn" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
{view.type === "input" && (
|
||||
<button
|
||||
@@ -311,7 +311,7 @@ export function AgentGenerationModal({
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={!canGenerate}
|
||||
>
|
||||
Generate
|
||||
{t("agents.generation.generate", "Generate")}
|
||||
</button>
|
||||
)}
|
||||
{view.type === "preview" && (
|
||||
@@ -320,10 +320,10 @@ export function AgentGenerationModal({
|
||||
className="btn"
|
||||
onClick={() => void handleRegenerate()}
|
||||
>
|
||||
Regenerate
|
||||
{t("agents.generation.regenerate", "Regenerate")}
|
||||
</button>
|
||||
<button className="btn btn-task-create" onClick={handleUseSpec}>
|
||||
Use This
|
||||
{t("agents.generation.useThis", "Use This")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./AgentImportModal.css";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, Globe, Search, RefreshCw } from "lucide-react";
|
||||
import { fetchCompanies, type CompanyEntry } from "../api";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
@@ -128,6 +129,7 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
|
||||
* Flow: Input → Preview parsed agents → Import → Show results
|
||||
*/
|
||||
export function AgentImportModal({ isOpen, onClose, onImported, projectId, initialInputMethod = "paste" }: AgentImportModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
useMobileScrollLock(isOpen);
|
||||
const [step, setStep] = useState<ModalStep>("input");
|
||||
const [inputMethod, setInputMethod] = useState<InputMethod>(initialInputMethod);
|
||||
@@ -169,17 +171,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
} else if (data.companies.length > 0) {
|
||||
setCompanies(data.companies);
|
||||
} else {
|
||||
setCompaniesError("No companies available");
|
||||
setCompaniesError(t("agents.noCompaniesAvailable", "No companies available"));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
|
||||
setCompaniesError(err instanceof Error ? err.message : t("agents.failedToLoadCompanies", "Failed to load companies"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCompanies(false);
|
||||
});
|
||||
}
|
||||
}, [inputMethod, isLoadingCompanies]);
|
||||
}, [inputMethod, isLoadingCompanies, t]);
|
||||
|
||||
/** Retry fetching companies after an error - calls fetch directly to bypass useEffect */
|
||||
const handleRetryFetchCompanies = useCallback(() => {
|
||||
@@ -195,16 +197,16 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
} else if (data.companies.length > 0) {
|
||||
setCompanies(data.companies);
|
||||
} else {
|
||||
setCompaniesError("No companies available");
|
||||
setCompaniesError(t("agents.noCompaniesAvailable", "No companies available"));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setCompaniesError(err instanceof Error ? err.message : "Failed to load companies");
|
||||
setCompaniesError(err instanceof Error ? err.message : t("agents.failedToLoadCompanies", "Failed to load companies"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCompanies(false);
|
||||
});
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep("input");
|
||||
@@ -247,13 +249,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
setParseError(null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setParseError("Failed to read file");
|
||||
setParseError(t("agents.failedToReadFile", "Failed to read file"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset file input so the same file can be re-selected
|
||||
e.target.value = "";
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleDirectoryChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
@@ -269,7 +271,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
});
|
||||
|
||||
if (agentFiles.length === 0) {
|
||||
setParseError("Selected directory has no AGENTS.md files");
|
||||
setParseError(t("agents.noAgentsMdFiles", "Selected directory has no AGENTS.md files"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,11 +286,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
setManifestContent("");
|
||||
setParseError(null);
|
||||
} catch {
|
||||
setParseError("Failed to parse AGENTS.md files from selected directory");
|
||||
setParseError(t("agents.failedToParseDirectory", "Failed to parse AGENTS.md files from selected directory"));
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
/** Build the API URL with optional projectId */
|
||||
function buildUrl(path: string): string {
|
||||
@@ -300,15 +302,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
/** Parse the manifest content by calling the API with dryRun=true */
|
||||
const handleParse = useCallback(async () => {
|
||||
if (inputMethod === "directory" && directoryAgents.length === 0) {
|
||||
setParseError("Please select a directory containing AGENTS.md files");
|
||||
setParseError(t("agents.selectDirectory", "Please select a directory containing AGENTS.md files"));
|
||||
return;
|
||||
}
|
||||
if (inputMethod === "browse" && !selectedCompany) {
|
||||
setParseError("Please select a company from the catalog");
|
||||
setParseError(t("agents.selectCompany", "Please select a company from the catalog"));
|
||||
return;
|
||||
}
|
||||
if (inputMethod !== "directory" && inputMethod !== "browse" && !manifestContent.trim()) {
|
||||
setParseError("Please provide manifest content");
|
||||
setParseError(t("agents.provideManifest", "Please provide manifest content"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -358,11 +360,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
setSelectedSkillNames(previewSkills.map((skill) => skill.name));
|
||||
setStep("preview");
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : "Failed to parse manifest");
|
||||
setParseError(err instanceof Error ? err.message : t("agents.failedToParseManifest", "Failed to parse manifest"));
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId]);
|
||||
}, [inputMethod, directoryAgents, manifestContent, selectedCompany, projectId, t]);
|
||||
|
||||
/** Execute the actual import */
|
||||
const handleImport = useCallback(async () => {
|
||||
@@ -412,7 +414,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
setStep("result");
|
||||
onImported();
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : "Failed to import agents");
|
||||
setImportError(err instanceof Error ? err.message : t("agents.failedToImport", "Failed to import agents"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
@@ -425,6 +427,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
selectedSkillNames,
|
||||
projectId,
|
||||
onImported,
|
||||
t,
|
||||
]);
|
||||
|
||||
const selectedAgentCount = selectedAgentNames.length;
|
||||
@@ -462,11 +465,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
|
||||
return (
|
||||
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
|
||||
<div className="agent-dialog agent-import-dialog" role="dialog" aria-modal="true" aria-label="Import agents">
|
||||
<div className="agent-dialog agent-import-dialog" role="dialog" aria-modal="true" aria-label={t("agents.importAgents", "Import agents")}>
|
||||
{/* Header */}
|
||||
<div className="agent-dialog-header">
|
||||
<span className="agent-dialog-header-title">Import Agents</span>
|
||||
<button className="modal-close" onClick={handleClose} aria-label="Close">
|
||||
<span className="agent-dialog-header-title">{t("agents.importAgents", "Import Agents")}</span>
|
||||
<button className="modal-close" onClick={handleClose} aria-label={t("agents.close", "Close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -477,7 +480,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{step === "input" && (
|
||||
<div className="agent-import-input">
|
||||
<p className="agent-import-description">
|
||||
Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.
|
||||
{t("agents.importDescription", "Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.")}
|
||||
</p>
|
||||
|
||||
{/* File upload */}
|
||||
@@ -506,7 +509,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Choose File
|
||||
{t("agents.chooseFile", "Choose File")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,7 +517,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
onClick={() => directoryInputRef.current?.click()}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
Select Directory
|
||||
{t("agents.selectDirectory", "Select Directory")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -528,9 +531,9 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
}}
|
||||
>
|
||||
<Globe size={16} />
|
||||
Browse Catalog
|
||||
{t("agents.browseCatalog", "Browse Catalog")}
|
||||
</button>
|
||||
<span className="agent-import-file-hint">.md and .txt files supported</span>
|
||||
<span className="agent-import-file-hint">{t("agents.fileHint", ".md and .txt files supported")}</span>
|
||||
</div>
|
||||
|
||||
{/* Browse Catalog Mode */}
|
||||
@@ -542,22 +545,22 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
<input
|
||||
type="text"
|
||||
className="agent-import-browse-search-input"
|
||||
placeholder="Search companies..."
|
||||
placeholder={t("agents.searchCompanies", "Search companies...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label="Search companies"
|
||||
aria-label={t("agents.searchCompanies", "Search companies")}
|
||||
/>
|
||||
</div>
|
||||
{selectedCompany && (
|
||||
<div className="agent-import-browse-selected">
|
||||
<span className="agent-import-browse-selected-label">Selected:</span>
|
||||
<span className="agent-import-browse-selected-label">{t("agents.selected", "Selected:")} </span>
|
||||
<span className="agent-import-browse-selected-name">{selectedCompany.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setSelectedCompany(null)}
|
||||
>
|
||||
Change
|
||||
{t("agents.change", "Change")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -566,7 +569,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{isLoadingCompanies && (
|
||||
<div className="agent-import-browse-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>Loading companies...</span>
|
||||
<span>{t("agents.loadingCompanies", "Loading companies...")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -580,7 +583,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
onClick={handleRetryFetchCompanies}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
{t("agents.retry", "Retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -609,7 +612,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
<div className="agent-import-browse-item-header">
|
||||
<span className="agent-import-browse-item-name">{company.name}</span>
|
||||
{company.installs !== undefined && (
|
||||
<span className="agent-import-browse-item-installs">{company.installs.toLocaleString()} installs</span>
|
||||
<span className="agent-import-browse-item-installs">{company.installs.toLocaleString()} {t("agents.installs", "installs")}</span>
|
||||
)}
|
||||
</div>
|
||||
{company.tagline && (
|
||||
@@ -626,7 +629,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
(company.tagline?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false)
|
||||
).length === 0 && (
|
||||
<p className="agent-import-browse-empty">
|
||||
{searchQuery ? "No companies match your search" : "No companies available"}
|
||||
{searchQuery ? t("agents.noCompaniesMatch", "No companies match your search") : t("agents.noCompaniesAvailable", "No companies available")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -638,7 +641,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{inputMethod !== "browse" && (
|
||||
<>
|
||||
<div className="agent-import-divider">
|
||||
<span>or paste manifest content</span>
|
||||
<span>{t("agents.orPasteManifest", "or paste manifest content")}</span>
|
||||
</div>
|
||||
|
||||
{/* Text area for paste */}
|
||||
@@ -653,12 +656,12 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
setParseError(null);
|
||||
}}
|
||||
rows={8}
|
||||
aria-label="Manifest content"
|
||||
aria-label={t("agents.manifestContent", "Manifest content")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="agent-import-file-hint">Current input: {inputMethod}</p>
|
||||
<p className="agent-import-file-hint">{t("agents.currentInput", "Current input: {{method}}", { method: inputMethod })}</p>
|
||||
|
||||
{parseError && (
|
||||
<p className="agent-dialog-error">
|
||||
@@ -673,13 +676,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{step === "preview" && (
|
||||
<div className="agent-import-preview">
|
||||
<div className="agent-import-company">
|
||||
<span className="agent-import-company-label">Company</span>
|
||||
<span className="agent-import-company-label">{t("agents.company", "Company")}</span>
|
||||
<span className="agent-import-company-name">{companyName}</span>
|
||||
</div>
|
||||
|
||||
<div className="agent-import-count">
|
||||
<FileText size={14} />
|
||||
<span>{agents.length} agent{agents.length !== 1 ? "s" : ""} found</span>
|
||||
<span>{t("agents.agentsFound", "{{count}} agent{{plural}} found", { count: agents.length, plural: agents.length !== 1 ? "s" : "" })}</span>
|
||||
</div>
|
||||
|
||||
{agents.length > 0 && (
|
||||
@@ -689,14 +692,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
className="btn btn-sm"
|
||||
onClick={() => setSelectedAgentNames(agents.map((agent) => agent.name))}
|
||||
>
|
||||
Select all agents
|
||||
{t("agents.selectAllAgents", "Select all agents")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setSelectedAgentNames([])}
|
||||
>
|
||||
Clear agents
|
||||
{t("agents.clearAgents", "Clear agents")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -708,7 +711,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select agent ${agent.name}`}
|
||||
aria-label={t("agents.selectAgent", "Select agent {{name}}", { name: agent.name })}
|
||||
checked={selectedAgentNames.includes(agent.name)}
|
||||
onChange={() => toggleAgentSelection(agent.name)}
|
||||
/>
|
||||
@@ -736,14 +739,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="agent-import-empty">No agents found in the manifest.</p>
|
||||
<p className="agent-import-empty">{t("agents.noAgentsFound", "No agents found in the manifest.")}</p>
|
||||
)}
|
||||
|
||||
{skills.length > 0 && (
|
||||
<div className="agent-import-skills-section">
|
||||
<div className="agent-import-count">
|
||||
<FileText size={14} />
|
||||
<span>{skills.length} skill{skills.length !== 1 ? "s" : ""} found</span>
|
||||
<span>{t("agents.skillsFound", "{{count}} skill{{plural}} found", { count: skills.length, plural: skills.length !== 1 ? "s" : "" })}</span>
|
||||
</div>
|
||||
<div className="agent-import-selection-controls">
|
||||
<button
|
||||
@@ -751,14 +754,14 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
className="btn btn-sm"
|
||||
onClick={() => setSelectedSkillNames(skills.map((skill) => skill.name))}
|
||||
>
|
||||
Select all skills
|
||||
{t("agents.selectAllSkills", "Select all skills")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setSelectedSkillNames([])}
|
||||
>
|
||||
Clear skills
|
||||
{t("agents.clearSkills", "Clear skills")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="agent-import-skill-list">
|
||||
@@ -767,7 +770,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select skill ${skill.name}`}
|
||||
aria-label={t("agents.selectSkill", "Select skill {{name}}", { name: skill.name })}
|
||||
checked={selectedSkillNames.includes(skill.name)}
|
||||
onChange={() => toggleSkillSelection(skill.name)}
|
||||
/>
|
||||
@@ -800,25 +803,25 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
<div className="agent-import-result-icon">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
<h3 className="agent-import-result-title">Import Complete</h3>
|
||||
<h3 className="agent-import-result-title">{t("agents.importComplete", "Import Complete")}</h3>
|
||||
<p className="agent-import-result-company">
|
||||
From <strong>{importResult.companyName ?? "Unknown"}</strong>
|
||||
{t("agents.from", "From")} <strong>{importResult.companyName ?? "Unknown"}</strong>
|
||||
</p>
|
||||
|
||||
<div className="agent-import-result-stats">
|
||||
{importResult.created.length > 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--success">
|
||||
<span>{importResult.created.length} created</span>
|
||||
<span>{t("agents.resultCreated", "{{count}} created", { count: importResult.created.length })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.skipped.length > 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--skipped">
|
||||
<span>{importResult.skipped.length} skipped (already exist)</span>
|
||||
<span>{t("agents.resultSkipped", "{{count}} skipped (already exist)", { count: importResult.skipped.length })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--error">
|
||||
<span>{importResult.errors.length} error{importResult.errors.length !== 1 ? "s" : ""}</span>
|
||||
<span>{t("agents.resultErrors", "{{count}} error{{plural}}", { count: importResult.errors.length, plural: importResult.errors.length !== 1 ? "s" : "" })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -848,26 +851,26 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{importResult.skills && (
|
||||
<>
|
||||
<div className="agent-import-result-divider" />
|
||||
<h4 className="agent-import-result-section-title">Skills</h4>
|
||||
<h4 className="agent-import-result-section-title">{t("agents.skills", "Skills")}</h4>
|
||||
<div className="agent-import-result-stats">
|
||||
{importResult.skills.imported.length > 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--success">
|
||||
<span>{importResult.skills.imported.length} skill{importResult.skills.imported.length !== 1 ? "s" : ""} imported</span>
|
||||
<span>{t("agents.skillsImported", "{{count}} skill{{plural}} imported", { count: importResult.skills.imported.length, plural: importResult.skills.imported.length !== 1 ? "s" : "" })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.skills.skipped.length > 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--skipped">
|
||||
<span>{importResult.skills.skipped.length} skill{importResult.skills.skipped.length !== 1 ? "s" : ""} skipped (already exist)</span>
|
||||
<span>{t("agents.skillsSkipped", "{{count}} skill{{plural}} skipped (already exist)", { count: importResult.skills.skipped.length, plural: importResult.skills.skipped.length !== 1 ? "s" : "" })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.skills.errors.length > 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--error">
|
||||
<span>{importResult.skills.errors.length} skill{importResult.skills.errors.length !== 1 ? "s" : ""} error{importResult.skills.errors.length !== 1 ? "s" : ""}</span>
|
||||
<span>{t("agents.skillsErrors", "{{count}} skill{{plural}} error{{pluralError}}", { count: importResult.skills.errors.length, plural: importResult.skills.errors.length !== 1 ? "s" : "", pluralError: importResult.skills.errors.length !== 1 ? "s" : "" })}</span>
|
||||
</div>
|
||||
)}
|
||||
{importResult.skills.imported.length === 0 && importResult.skills.skipped.length === 0 && importResult.skills.errors.length === 0 && (
|
||||
<div className="agent-import-result-stat agent-import-result-stat--skipped">
|
||||
<span>No skills in package</span>
|
||||
<span>{t("agents.noSkillsInPackage", "No skills in package")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -903,11 +906,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
<div className="agent-dialog-footer">
|
||||
{step === "preview" && (
|
||||
<button className="btn" onClick={() => setStep("input")} disabled={isImporting}>
|
||||
Back
|
||||
{t("agents.back", "Back")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={handleClose} disabled={isImporting}>
|
||||
{step === "result" ? "Close" : "Cancel"}
|
||||
{step === "result" ? t("agents.close", "Close") : t("agents.cancel", "Cancel")}
|
||||
</button>
|
||||
{step === "input" && (
|
||||
<button
|
||||
@@ -926,10 +929,10 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{isParsing ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" />
|
||||
Parsing...
|
||||
{t("agents.parsing", "Parsing...")}
|
||||
</>
|
||||
) : (
|
||||
"Preview"
|
||||
t("agents.preview", "Preview")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -945,7 +948,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
||||
{importLoadingLabel}
|
||||
</>
|
||||
) : (
|
||||
`Import ${importActionLabel}`
|
||||
t("agents.importButton", "Import {{label}}", { label: importActionLabel })
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./AgentListModal.css";
|
||||
// import the styles eagerly here to avoid the modal rendering unstyled.
|
||||
import "./AgentsView.css";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Play, Pause, Square, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
@@ -33,6 +34,7 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
];
|
||||
|
||||
export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -110,7 +112,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
if (gen !== loadAgentsGenRef.current) return;
|
||||
setAgents(data);
|
||||
} catch (err) {
|
||||
addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.loadError", "Failed to load agents: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
if (gen === loadAgentsGenRef.current) setIsLoading(false);
|
||||
}
|
||||
@@ -140,12 +142,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
if (!newAgentName.trim()) return;
|
||||
try {
|
||||
await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId);
|
||||
addToast(`Agent "${newAgentName}" created`, "success");
|
||||
addToast(t("agents.createSuccess", "Agent \"{{name}}\" created", { name: newAgentName }), "success");
|
||||
setNewAgentName("");
|
||||
setIsCreating(false);
|
||||
void loadAgents(true);
|
||||
} catch (err) {
|
||||
addToast(`Failed to create agent: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.createError", "Failed to create agent: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,7 +163,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
addToast(t("agents.stateUpdateSuccess", "Agent state updated to {{state}}", { state: newState }), "success");
|
||||
await loadAgents(true);
|
||||
setOptimisticStateOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -174,7 +176,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.stateUpdateError", "Failed to update state: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setTransitioningAgentIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -186,17 +188,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
const handleDelete = async (agentId: string, agentName: string) => {
|
||||
const shouldDelete = await confirm({
|
||||
title: "Delete Agent",
|
||||
message: `Delete agent "${agentName}"? This cannot be undone.`,
|
||||
title: t("agents.deleteTitle", "Delete Agent"),
|
||||
message: t("agents.deleteMessage", "Delete agent \"{{name}}\"? This cannot be undone.", { name: agentName }),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
try {
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
addToast(t("agents.deleteSuccess", "Agent \"{{name}}\" deleted", { name: agentName }), "success");
|
||||
void loadAgents(true);
|
||||
} catch (err) {
|
||||
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.deleteError", "Failed to delete agent: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,11 +214,11 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
try {
|
||||
await updateAgent(agentId, { role: newRole }, projectId);
|
||||
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
|
||||
addToast(t("agents.roleUpdateSuccess", "Agent role updated to {{role}}", { role: AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole }), "success");
|
||||
setEditingRoleForAgent(null);
|
||||
void loadAgents(true);
|
||||
} catch (err) {
|
||||
addToast(`Failed to update role: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.roleUpdateError", "Failed to update role: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,7 +228,20 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const ROLE_LABEL_KEYS: Record<string, { key: string; defaultValue: string }> = {
|
||||
triage: { key: "agents.roleTriage", defaultValue: "Triage" },
|
||||
executor: { key: "agents.roleExecutor", defaultValue: "Executor" },
|
||||
reviewer: { key: "agents.roleReviewer", defaultValue: "Reviewer" },
|
||||
merger: { key: "agents.roleMerger", defaultValue: "Merger" },
|
||||
scheduler: { key: "agents.roleScheduler", defaultValue: "Scheduler" },
|
||||
engineer: { key: "agents.roleEngineer", defaultValue: "Engineer" },
|
||||
custom: { key: "agents.roleCustom", defaultValue: "Custom" },
|
||||
};
|
||||
const getRoleLabel = (role: AgentCapability) => {
|
||||
const entry = ROLE_LABEL_KEYS[role];
|
||||
if (entry) return t(entry.key, entry.defaultValue);
|
||||
return AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
};
|
||||
|
||||
// Use centralized health status utility for consistent labels across all views
|
||||
// This fixes the previous hardcoded 60s timeout that was inconsistent with other views
|
||||
@@ -243,7 +258,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
|
||||
const getHealthSummary = (agent: Agent, health: AgentHealthStatus): { title: string | undefined; label: string | null } => {
|
||||
if (agent.state === "error") {
|
||||
return { title: undefined, label: "Error" };
|
||||
return { title: undefined, label: t("agents.healthError", "Error") };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -260,15 +275,15 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
<Bot size={20} />
|
||||
Agents
|
||||
{t("agents.modalTitle", "Agents")}
|
||||
</h2>
|
||||
<div className="modal-actions">
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
||||
onClick={() => setView("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
title={t("agents.boardView", "Board view")}
|
||||
aria-label={t("agents.boardView", "Board view")}
|
||||
aria-pressed={view === "board"}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
@@ -276,8 +291,8 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<button
|
||||
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
||||
onClick={() => setView("list")}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
title={t("agents.listView", "List view")}
|
||||
aria-label={t("agents.listView", "List view")}
|
||||
aria-pressed={view === "list"}
|
||||
>
|
||||
<List size={16} />
|
||||
@@ -286,12 +301,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => void loadAgents()}
|
||||
title="Refresh"
|
||||
title={t("agents.refresh", "Refresh")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
|
||||
</button>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("agents.close", "Close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -306,14 +321,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="agent-state-filter-select"
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||
aria-label="Filter agents by state"
|
||||
aria-label={t("agents.filterByState", "Filter agents by state")}
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="all">{t("agents.filterAll", "All States")}</option>
|
||||
<option value="idle">{t("agents.stateIdle", "Idle")}</option>
|
||||
<option value="active">{t("agents.stateActive", "Active")}</option>
|
||||
<option value="running">{t("agents.stateRunning", "Running")}</option>
|
||||
<option value="paused">{t("agents.statePaused", "Paused")}</option>
|
||||
<option value="error">{t("agents.stateError", "Error")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +337,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
onClick={() => setIsCreating(!isCreating)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{isCreating ? "Cancel" : "New Agent"}
|
||||
{isCreating ? t("agents.cancel", "Cancel") : t("agents.newAgent", "New Agent")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -331,7 +346,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<div className="agent-create-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Agent name..."
|
||||
placeholder={t("agents.namePlaceholder", "Agent name...")}
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
@@ -345,12 +360,12 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
{AGENT_ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.icon} {role.label}
|
||||
{role.icon} {getRoleLabel(role.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-task-create btn-sm" onClick={() => void handleCreate()}>
|
||||
Create
|
||||
{t("agents.create", "Create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -360,8 +375,8 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
{displayAgents.length === 0 ? (
|
||||
<div className="agent-empty">
|
||||
<Bot size={48} opacity={0.3} />
|
||||
<p>No agents found</p>
|
||||
<p className="text-secondary">Create an agent to get started</p>
|
||||
<p>{t("agents.emptyTitle", "No agents found")}</p>
|
||||
<p className="text-secondary">{t("agents.emptySubtitle", "Create an agent to get started")}</p>
|
||||
</div>
|
||||
) : view === "board" ? (
|
||||
// Board view: compact grid layout
|
||||
@@ -394,14 +409,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Activate"
|
||||
title={t("agents.activate", "Activate")}
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
title={t("agents.delete", "Delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -413,7 +428,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Pause"
|
||||
title={t("agents.pause", "Pause")}
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
@@ -421,7 +436,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
title={t("agents.stop", "Stop")}
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
@@ -433,14 +448,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Resume"
|
||||
title={t("agents.resume", "Resume")}
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
title={t("agents.delete", "Delete")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -452,7 +467,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Pause"
|
||||
title={t("agents.pause", "Pause")}
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
@@ -460,7 +475,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
title={t("agents.stop", "Stop")}
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
@@ -472,7 +487,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Retry"
|
||||
title={t("agents.retry", "Retry")}
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
@@ -480,7 +495,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
title={t("agents.stop", "Stop")}
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
@@ -512,7 +527,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
{AGENT_ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.icon} {role.label}
|
||||
{role.icon} {getRoleLabel(role.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -520,7 +535,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<span
|
||||
className="agent-icon agent-icon--clickable"
|
||||
onClick={() => setEditingRoleForAgent(agent.id)}
|
||||
title="Click to change role"
|
||||
title={t("agents.changeRole", "Click to change role")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -568,13 +583,13 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
) : null}
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">Working on:</span>
|
||||
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
|
||||
<span className="badge">{agent.taskId}</span>
|
||||
</div>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
<div className="agent-heartbeat">
|
||||
<span className="text-secondary">Last heartbeat:</span>
|
||||
<span className="text-secondary">{t("agents.lastHeartbeat", "Last heartbeat:")}</span>
|
||||
<span>{new Date(agent.lastHeartbeatAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -587,16 +602,16 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Activate"
|
||||
title={t("agents.activate", "Activate")}
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
<Play size={14} /> {t("agents.start", "Start")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
title={t("agents.delete", "Delete")}
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
<Trash2 size={14} /> {t("agents.delete", "Delete")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -606,17 +621,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Pause"
|
||||
title={t("agents.pause", "Pause")}
|
||||
>
|
||||
<Pause size={14} /> Pause
|
||||
<Pause size={14} /> {t("agents.pause", "Pause")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
title={t("agents.stop", "Stop")}
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
<Square size={14} /> {t("agents.stop", "Stop")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -626,16 +641,16 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Resume"
|
||||
title={t("agents.resume", "Resume")}
|
||||
>
|
||||
<Play size={14} /> Resume
|
||||
<Play size={14} /> {t("agents.resume", "Resume")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
title={t("agents.delete", "Delete")}
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
<Trash2 size={14} /> {t("agents.delete", "Delete")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -645,17 +660,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Pause"
|
||||
title={t("agents.pause", "Pause")}
|
||||
>
|
||||
<Pause size={14} /> Pause
|
||||
<Pause size={14} /> {t("agents.pause", "Pause")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
title={t("agents.stop", "Stop")}
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
<Square size={14} /> {t("agents.stop", "Stop")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -665,17 +680,17 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Retry"
|
||||
title={t("agents.retry", "Retry")}
|
||||
>
|
||||
<Play size={14} /> Retry
|
||||
<Play size={14} /> {t("agents.retry", "Retry")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
title={t("agents.stop", "Stop")}
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
<Square size={14} /> {t("agents.stop", "Stop")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -125,12 +126,13 @@ interface CollapsibleToolDetailProps {
|
||||
}
|
||||
|
||||
function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactElement {
|
||||
const { t } = useTranslation("app");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const contentId = useId();
|
||||
const lineCount = detail.split("\n").length;
|
||||
const toggleLabel = expanded
|
||||
? "Hide output"
|
||||
: `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`;
|
||||
? t("agentLog.hideOutput", "Hide output")
|
||||
: t("agentLog.showOutput", `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`);
|
||||
|
||||
return (
|
||||
<div className="agent-log-tool-detail-wrapper">
|
||||
@@ -281,6 +283,7 @@ export function AgentLogViewer({
|
||||
loadingMore = false,
|
||||
totalCount = null,
|
||||
}: AgentLogViewerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef<number>(0);
|
||||
const previousScrollHeightRef = useRef<number>(0);
|
||||
@@ -470,7 +473,7 @@ export function AgentLogViewer({
|
||||
if (loading && entries.length === 0) {
|
||||
return (
|
||||
<div className="agent-log-viewer" data-testid="agent-log-viewer">
|
||||
<div className="agent-log-loading">Loading agent logs…</div>
|
||||
<div className="agent-log-loading">{t("agentLog.loading", "Loading agent logs…")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -478,7 +481,7 @@ export function AgentLogViewer({
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="agent-log-viewer" data-testid="agent-log-viewer">
|
||||
<div className="agent-log-empty">No agent output yet.</div>
|
||||
<div className="agent-log-empty">{t("agentLog.empty", "No agent output yet.")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -501,7 +504,7 @@ export function AgentLogViewer({
|
||||
<button
|
||||
className="agent-log-model-expand-btn"
|
||||
onClick={() => setModelHeaderExpanded((prev) => !prev)}
|
||||
aria-label={modelHeaderExpanded ? "Collapse model details" : "Expand model details"}
|
||||
aria-label={modelHeaderExpanded ? t("agentLog.collapseModelDetails", "Collapse model details") : t("agentLog.expandModelDetails", "Expand model details")}
|
||||
aria-expanded={modelHeaderExpanded}
|
||||
aria-controls="agent-log-model-details"
|
||||
data-testid="agent-log-model-expand"
|
||||
@@ -515,29 +518,29 @@ export function AgentLogViewer({
|
||||
<button
|
||||
className="agent-log-mode-toggle"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
aria-label={renderMarkdown ? "Switch to plain text mode" : "Switch to markdown mode"}
|
||||
aria-label={renderMarkdown ? t("agentLog.switchPlainText", "Switch to plain text mode") : t("agentLog.switchMarkdown", "Switch to markdown mode")}
|
||||
aria-pressed={renderMarkdown}
|
||||
data-testid="agent-log-mode-toggle"
|
||||
title={renderMarkdown ? "Show raw text" : "Show formatted markdown"}
|
||||
title={renderMarkdown ? t("agentLog.showRawText", "Show raw text") : t("agentLog.showFormattedMarkdown", "Show formatted markdown")}
|
||||
>
|
||||
{renderMarkdown ? "Markdown" : "Plain"}
|
||||
{renderMarkdown ? t("agentLog.markdown", "Markdown") : t("agentLog.plain", "Plain")}
|
||||
</button>
|
||||
<button
|
||||
className="agent-log-mode-toggle"
|
||||
onClick={() => setShowToolOutput((prev) => !prev)}
|
||||
aria-label={showToolOutput ? "Hide tool output" : "Show tool output"}
|
||||
aria-label={showToolOutput ? t("agentLog.hideToolOutput", "Hide tool output") : t("agentLog.showToolOutput", "Show tool output")}
|
||||
aria-pressed={showToolOutput}
|
||||
data-testid="agent-log-tool-output-toggle"
|
||||
title={showToolOutput ? "Hide tool calls and results" : "Show tool calls and results"}
|
||||
title={showToolOutput ? t("agentLog.hideToolCallsResults", "Hide tool calls and results") : t("agentLog.showToolCallsResults", "Show tool calls and results")}
|
||||
>
|
||||
{showToolOutput ? "Tools: On" : "Tools: Off"}
|
||||
{showToolOutput ? t("agentLog.toolsOn", "Tools: On") : t("agentLog.toolsOff", "Tools: Off")}
|
||||
</button>
|
||||
<button
|
||||
className="agent-log-mode-toggle"
|
||||
onClick={() => setIsFullscreen((prev) => !prev)}
|
||||
aria-label={isFullscreen ? "Exit full screen" : "Expand agent log to full screen"}
|
||||
aria-label={isFullscreen ? t("agentLog.exitFullscreen", "Exit full screen") : t("agentLog.expandFullscreen", "Expand agent log to full screen")}
|
||||
data-testid="agent-log-fullscreen-toggle"
|
||||
title={isFullscreen ? "Exit full screen" : "Expand agent log to full screen"}
|
||||
title={isFullscreen ? t("agentLog.exitFullscreen", "Exit full screen") : t("agentLog.expandFullscreen", "Expand agent log to full screen")}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
@@ -546,36 +549,36 @@ export function AgentLogViewer({
|
||||
{modelHeaderExpanded && (
|
||||
<div id="agent-log-model-details" className="agent-log-model-details">
|
||||
<div className="agent-log-model-group">
|
||||
<span className="agent-log-model-label">Executor:</span>
|
||||
<span className="agent-log-model-label">{t("agentLog.executor", "Executor")}:</span>
|
||||
{hasExecutorOverride ? (
|
||||
<span className="agent-log-model-value">
|
||||
<ProviderIcon provider={executorModel.provider!} size="sm" />
|
||||
<span>{executorModel.provider}/{executorModel.modelId}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge-default">Using default</span>
|
||||
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="agent-log-model-group">
|
||||
<span className="agent-log-model-label">Reviewer:</span>
|
||||
<span className="agent-log-model-label">{t("agentLog.reviewer", "Reviewer")}:</span>
|
||||
{hasValidatorOverride ? (
|
||||
<span className="agent-log-model-value">
|
||||
<ProviderIcon provider={validatorModel.provider!} size="sm" />
|
||||
<span>{validatorModel.provider}/{validatorModel.modelId}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge-default">Using default</span>
|
||||
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="agent-log-model-group">
|
||||
<span className="agent-log-model-label">Planning:</span>
|
||||
<span className="agent-log-model-label">{t("agentLog.planning", "Planning")}:</span>
|
||||
{hasPlanningOverride ? (
|
||||
<span className="agent-log-model-value">
|
||||
<ProviderIcon provider={planningModel.provider!} size="sm" />
|
||||
<span>{planningModel.provider}/{planningModel.modelId}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge-default">Using default</span>
|
||||
<span className="model-badge-default">{t("agentLog.usingDefault", "Using default")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -590,9 +593,9 @@ export function AgentLogViewer({
|
||||
{/* Pagination summary */}
|
||||
{totalCount !== null && (
|
||||
<div className="agent-log-summary" data-testid="agent-log-summary">
|
||||
Showing {visibleEntries.length} of {totalCount} entries
|
||||
{t("agentLog.showing", "Showing {{visible}} of {{total}} entries", { visible: visibleEntries.length, total: totalCount })}
|
||||
{!showToolOutput && entries.length !== visibleEntries.length
|
||||
? ` (${entries.length - visibleEntries.length} tool entries hidden)`
|
||||
? ` (${t("agentLog.toolEntriesHidden", "{{count}} tool entries hidden", { count: entries.length - visibleEntries.length })})`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
@@ -608,10 +611,10 @@ export function AgentLogViewer({
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading…
|
||||
{t("agentLog.loadingMore", "Loading…")}
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
t("agentLog.loadMore", "Load More")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -711,7 +714,7 @@ export function AgentLogViewer({
|
||||
data-testid="agent-log-return-to-live"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
<span>Live</span>
|
||||
<span>{t("agentLog.live", "Live")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import "./AgentMentionPopup.css";
|
||||
import type { Agent } from "@fusion/core";
|
||||
@@ -33,6 +34,7 @@ export function AgentMentionPopup({
|
||||
roomMemberIds,
|
||||
roomName,
|
||||
}: AgentMentionPopupProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const filteredAgents = useMemo(() => agents.filter((agent) => matchesAgentMentionFilter(agent.name, filter)), [agents, filter]);
|
||||
|
||||
const roomMode = Boolean(roomMemberIds);
|
||||
@@ -57,15 +59,15 @@ export function AgentMentionPopup({
|
||||
className={`agent-mention-popup agent-mention-popup--${position}`}
|
||||
data-testid="agent-mention-popup"
|
||||
role="listbox"
|
||||
aria-label="Agent mention suggestions"
|
||||
aria-label={t("agentMention.suggestionsLabel", "Agent mention suggestions")}
|
||||
>
|
||||
{visibleAgents.length === 0 ? (
|
||||
<div className="agent-mention-empty">No agents found</div>
|
||||
<div className="agent-mention-empty">{t("agentMention.noAgentsFound", "No agents found")}</div>
|
||||
) : (
|
||||
<>
|
||||
{roomMode && (
|
||||
<div className="agent-mention-section-header" data-testid="agent-mention-members-header">
|
||||
{roomName ? `Members of #${roomName}` : "Room members"}
|
||||
{roomName ? t("agentMention.membersOf", "Members of #{{roomName}}", { roomName }) : t("agentMention.roomMembers", "Room members")}
|
||||
</div>
|
||||
)}
|
||||
{memberAgents.map((agent, index) => (
|
||||
@@ -80,17 +82,17 @@ export function AgentMentionPopup({
|
||||
aria-selected={index === highlightedIndex}
|
||||
>
|
||||
<AgentAvatar agent={agent} size={20} />
|
||||
{roomMode && <span className="status-dot agent-mention-member-dot" aria-label="Room member" />}
|
||||
{roomMode && <span className="status-dot agent-mention-member-dot" aria-label={t("agentMention.roomMemberBadge", "Room member")} />}
|
||||
<span className="agent-mention-name">{agent.name}</span>
|
||||
<span className="agent-mention-role">{agent.role}</span>
|
||||
</button>
|
||||
))}
|
||||
{roomMode && !showOtherSection && otherAgents.length > 0 && (
|
||||
<div className="agent-mention-hint" data-testid="agent-mention-other-hint">Type to search other agents</div>
|
||||
<div className="agent-mention-hint" data-testid="agent-mention-other-hint">{t("agentMention.typeToSearch", "Type to search other agents")}</div>
|
||||
)}
|
||||
{roomMode && showOtherSection && otherAgents.length > 0 && (
|
||||
<>
|
||||
<div className="agent-mention-section-header" data-testid="agent-mention-others-header">Other agents</div>
|
||||
<div className="agent-mention-section-header" data-testid="agent-mention-others-header">{t("agentMention.otherAgents", "Other agents")}</div>
|
||||
{otherAgents.map((agent, index) => {
|
||||
const globalIndex = memberAgents.length + index;
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentCapability, ConversationHistoryEntry } from "../api";
|
||||
import {
|
||||
startAgentOnboardingStreaming,
|
||||
@@ -31,6 +32,7 @@ interface AgentOnboardingModalProps {
|
||||
}
|
||||
|
||||
export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, projectId, existingAgents }: AgentOnboardingModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [viewState, setViewState] = useState<ViewState>("initial");
|
||||
const [intent, setIntent] = useState("");
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
@@ -97,7 +99,7 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
if (state === "reconnecting") {
|
||||
setError("Connection lost. Retrying...");
|
||||
setError(t("agents.connectionLost", "Connection lost. Retrying..."));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -179,7 +181,7 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(`Agent "${summary.name}" created`, "success");
|
||||
addToast(t("agents.created", "Agent \"{{name}}\" created", { name: summary.name }), "success");
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -191,79 +193,79 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
|
||||
<div className="modal-overlay open" role="presentation">
|
||||
<div className="modal modal-lg agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="Agent onboarding">
|
||||
<div className="modal-header">
|
||||
<h3>Agent Onboarding</h3>
|
||||
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
|
||||
<h3>{t("agents.onboarding", "Agent Onboarding")}</h3>
|
||||
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.close", "Close")}>×</button>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && <ConversationHistory entries={history} />}
|
||||
|
||||
{viewState === "initial" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-onboarding-intent">What do you want this agent to do?</label>
|
||||
<label htmlFor="agent-onboarding-intent">{t("agents.intentPrompt", "What do you want this agent to do?")}</label>
|
||||
<textarea ref={setIntentRef} id="agent-onboarding-intent" className="input" value={intent} onChange={(e) => setIntent(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>Start onboarding</button>
|
||||
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{t("agents.startOnboarding", "Start onboarding")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(viewState === "loading" || viewState === "question") && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-onboarding-answer">{currentQuestion || "Waiting for AI question..."}</label>
|
||||
<label htmlFor="agent-onboarding-answer">{currentQuestion || t("agents.waitingForQuestion", "Waiting for AI question...")}</label>
|
||||
<textarea ref={setAnswerRef} id="agent-onboarding-answer" className="input" value={answer} onChange={(e) => setAnswer(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => sessionId && void stopAgentOnboardingGeneration(sessionId, projectId)}>Stop</button>
|
||||
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>Continue</button>
|
||||
<button className="btn" onClick={() => sessionId && void stopAgentOnboardingGeneration(sessionId, projectId)}>{t("common.stop", "Stop")}</button>
|
||||
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>{t("common.continue", "Continue")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewState === "summary" && summary && (
|
||||
<div className="form-group">
|
||||
<label>Review generated configuration</label>
|
||||
<label>{t("agents.reviewConfiguration", "Review generated configuration")}</label>
|
||||
<div className="agent-onboarding-summary">
|
||||
<p><strong>Name:</strong> {summary.name}</p>
|
||||
<p><strong>Role:</strong> {summary.role}</p>
|
||||
<label htmlFor="thinking-level">Thinking level</label>
|
||||
<p><strong>{t("agents.name", "Name")}:</strong> {summary.name}</p>
|
||||
<p><strong>{t("agents.role", "Role")}:</strong> {summary.role}</p>
|
||||
<label htmlFor="thinking-level">{t("agents.thinkingLevel", "Thinking level")}</label>
|
||||
<input id="thinking-level" className="input" value={summary.thinkingLevel} onChange={() => {}} readOnly />
|
||||
<label htmlFor="max-turns">Max turns</label>
|
||||
<label htmlFor="max-turns">{t("agents.maxTurns", "Max turns")}</label>
|
||||
<input id="max-turns" className="input" type="number" value={summary.maxTurns} onChange={() => {}} readOnly />
|
||||
<label htmlFor="runtime-mode">Runtime mode</label>
|
||||
<select id="runtime-mode" className="select" value={runtimeMode} onChange={(e) => setRuntimeMode(e.target.value as "model" | "runtime")}>
|
||||
<option value="model">Model</option>
|
||||
<option value="runtime">Runtime</option>
|
||||
<label htmlFor="runtime-mode">{t("agents.runtimeMode", "Runtime mode")}</label>
|
||||
<select id="runtime-mode" className="select" value={runtimeMode} onChange={(e) => setRuntimeMode(e.target.value as "model" | "runtime")}>
|
||||
<option value="model">{t("agents.model", "Model")}</option>
|
||||
<option value="runtime">{t("agents.runtime", "Runtime")}</option>
|
||||
</select>
|
||||
{runtimeMode === "model" && (
|
||||
<>
|
||||
<label>Model</label>
|
||||
<label>{t("agents.selectModel", "Model")}</label>
|
||||
<CustomModelDropdown
|
||||
id="agent-onboarding-model"
|
||||
label="Model"
|
||||
label={t("agents.selectModel", "Model")}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
models={availableModels}
|
||||
placeholder="Select a model…"
|
||||
placeholder={t("agents.selectModelPlaceholder", "Select a model…")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => void createFromSummary()}>Create agent</button>
|
||||
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
|
||||
<button className="btn btn-primary" onClick={() => void createFromSummary()}>{t("agents.createAgent", "Create agent")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewState === "creating" && (
|
||||
<div className="form-group agent-onboarding-creating">Creating agent...</div>
|
||||
<div className="form-group agent-onboarding-creating">{t("agents.creatingAgent", "Creating agent...")}</div>
|
||||
)}
|
||||
|
||||
{viewState === "error" && error && (
|
||||
<div className="form-group">
|
||||
<div className="form-error">{error}</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => sessionId && void retryAgentOnboardingSession(sessionId, projectId)}>Retry</button>
|
||||
<button className="btn" onClick={() => sessionId && void retryAgentOnboardingSession(sessionId, projectId)}>{t("common.retry", "Retry")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./AgentPermissionPolicyEditor.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||
AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES,
|
||||
@@ -78,6 +79,7 @@ function derivePresetFromRules(rules: AgentPermissionPolicyRules): AgentPermissi
|
||||
}
|
||||
|
||||
export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onChange, disabled = false }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const derivedPreset = value ? derivePresetFromRules(value.rules) : "unrestricted";
|
||||
const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId === "custom" ? derivedPreset : (value?.presetId ?? "unrestricted"));
|
||||
const rules = value?.rules ?? buildAllowRules();
|
||||
@@ -109,7 +111,7 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
||||
return (
|
||||
<div className="agent-policy-editor card">
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-policy-preset">Preset</label>
|
||||
<label htmlFor="agent-policy-preset">{t("agentPolicy.preset", "Preset")}</label>
|
||||
<select
|
||||
id="agent-policy-preset"
|
||||
className="select"
|
||||
@@ -117,15 +119,15 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
||||
onChange={(event) => setPreset(event.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{mode === "agent-override" ? <option value="inherit">Inherit project default</option> : null}
|
||||
<option value="unrestricted">Unrestricted</option>
|
||||
<option value="approval-required">Approval Required</option>
|
||||
<option value="locked-down">Locked Down</option>
|
||||
<option value="custom">Custom</option>
|
||||
{mode === "agent-override" ? <option value="inherit">{t("agentPolicy.inheritDefault", "Inherit project default")}</option> : null}
|
||||
<option value="unrestricted">{t("agentPolicy.unrestricted", "Unrestricted")}</option>
|
||||
<option value="approval-required">{t("agentPolicy.approvalRequired", "Approval Required")}</option>
|
||||
<option value="locked-down">{t("agentPolicy.lockedDown", "Locked Down")}</option>
|
||||
<option value="custom">{t("agentPolicy.custom", "Custom")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="agent-policy-table" role="table" aria-label="Permission policy categories">
|
||||
<div className="agent-policy-table" role="table" aria-label={t("agentPolicy.ariaLabel", "Permission policy categories")}>
|
||||
{AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.map((category) => {
|
||||
const meta = CATEGORY_LABELS[category] ?? { label: category, description: "" };
|
||||
const inherited = projectDefault?.[category] ?? "allow";
|
||||
@@ -151,15 +153,15 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
||||
onChange={(event) => setRule(category, event.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{mode === "agent-override" ? <option value="inherit">Inherit</option> : null}
|
||||
{mode === "agent-override" ? <option value="inherit">{t("agentPolicy.inherit", "Inherit")}</option> : null}
|
||||
{DISPOSITIONS.map((disposition) => (
|
||||
<option key={disposition} value={disposition}>
|
||||
{disposition === "require-approval" ? "Require approval" : disposition[0]?.toUpperCase() + disposition.slice(1)}
|
||||
{disposition === "require-approval" ? t("agentPolicy.requireApproval", "Require approval") : disposition[0]?.toUpperCase() + disposition.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{mode === "agent-override" && rowValue === "inherit" ? (
|
||||
<div className="agent-policy-inherit-note">from project default: {inherited === "require-approval" ? "Require approval" : inherited}</div>
|
||||
<div className="agent-policy-inherit-note">{t("agentPolicy.fromProjectDefault", "from project default")}: {inherited === "require-approval" ? t("agentPolicy.requireApproval", "Require approval") : inherited}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,10 +170,9 @@ export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onCha
|
||||
</div>
|
||||
|
||||
<details className="agent-policy-exempt" open={false}>
|
||||
<summary>Tools exempt from approval policy</summary>
|
||||
<summary>{t("agentPolicy.exemptTools", "Tools exempt from approval policy")}</summary>
|
||||
<p>
|
||||
These coordination tools bypass approval policy so heartbeats and inter-agent messaging cannot deadlock. They
|
||||
are not user-configurable.
|
||||
{t("agentPolicy.exemptToolsDescription", "These coordination tools bypass approval policy so heartbeats and inter-agent messaging cannot deadlock. They are not user-configurable.")}
|
||||
</p>
|
||||
<ul className="agent-policy-exempt-list">
|
||||
{AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES.map((toolName) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./AgentPromptsManager.css";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BUILTIN_AGENT_PROMPTS, PROMPT_KEY_CATALOG } from "../utils/builtinPrompts";
|
||||
import type { AgentPromptTemplate, AgentPromptsConfig, AgentCapability } from "@fusion/core";
|
||||
import type { PromptKey } from "@fusion/core";
|
||||
@@ -113,6 +114,8 @@ export function AgentPromptsManager({
|
||||
promptOverrides,
|
||||
onPromptOverridesChange,
|
||||
}: AgentPromptsManagerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabId>("templates");
|
||||
|
||||
@@ -200,7 +203,7 @@ export function AgentPromptsManager({
|
||||
const handleSaveTemplate = useCallback(() => {
|
||||
const trimmedName = templateForm.name.trim();
|
||||
if (!trimmedName) {
|
||||
setTemplateIdError("Template name is required");
|
||||
setTemplateIdError(t("agentPrompts.errors.nameRequired", "Template name is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,7 +215,7 @@ export function AgentPromptsManager({
|
||||
// Check for collision with built-in IDs (shouldn't happen with generateTemplateId, but be defensive)
|
||||
const builtinIds = new Set(BUILTIN_AGENT_PROMPTS.map((t) => t.id));
|
||||
if (builtinIds.has(templateId)) {
|
||||
setTemplateIdError(`Template ID "${templateId}" conflicts with a built-in template. Please use a different name.`);
|
||||
setTemplateIdError(t("agentPrompts.errors.idConflict", "Template ID \"{{templateId}}\" conflicts with a built-in template. Please use a different name.", { templateId }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -384,7 +387,7 @@ export function AgentPromptsManager({
|
||||
data-testid="tab-templates"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
Templates
|
||||
{t("agentPrompts.tabs.templates", "Templates")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -393,7 +396,7 @@ export function AgentPromptsManager({
|
||||
data-testid="tab-assignments"
|
||||
>
|
||||
<Users size={14} />
|
||||
Assignments
|
||||
{t("agentPrompts.tabs.assignments", "Assignments")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -402,7 +405,7 @@ export function AgentPromptsManager({
|
||||
data-testid="tab-overrides"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
Overrides
|
||||
{t("agentPrompts.tabs.overrides", "Overrides")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -415,12 +418,12 @@ export function AgentPromptsManager({
|
||||
{(isCreating || editingTemplateId !== null) && (
|
||||
<div className="prompt-template-editor" data-testid="template-editor">
|
||||
<h4 className="prompt-template-editor-title">
|
||||
{isCreating ? "New Custom Template" : "Edit Custom Template"}
|
||||
{isCreating ? t("agentPrompts.editor.newTemplate", "New Custom Template") : t("agentPrompts.editor.editTemplate", "Edit Custom Template")}
|
||||
</h4>
|
||||
|
||||
<div className="prompt-template-editor-fields">
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-name">Name</label>
|
||||
<label htmlFor="template-name">{t("agentPrompts.labels.name", "Name")}</label>
|
||||
<input
|
||||
id="template-name"
|
||||
type="text"
|
||||
@@ -428,13 +431,13 @@ export function AgentPromptsManager({
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. My Custom Executor"
|
||||
placeholder={t("agentPrompts.placeholders.templateName", "e.g. My Custom Executor")}
|
||||
data-testid="template-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-description">Description</label>
|
||||
<label htmlFor="template-description">{t("agentPrompts.labels.description", "Description")}</label>
|
||||
<input
|
||||
id="template-description"
|
||||
type="text"
|
||||
@@ -442,13 +445,13 @@ export function AgentPromptsManager({
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
placeholder="Brief description of this template"
|
||||
placeholder={t("agentPrompts.placeholders.description", "Brief description of this template")}
|
||||
data-testid="template-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-role">Role</label>
|
||||
<label htmlFor="template-role">{t("agentPrompts.labels.role", "Role")}</label>
|
||||
<select
|
||||
id="template-role"
|
||||
value={templateForm.role}
|
||||
@@ -470,12 +473,12 @@ export function AgentPromptsManager({
|
||||
|
||||
<div className="prompt-template-field">
|
||||
<div className="prompt-template-prompt-label-row">
|
||||
<label htmlFor="template-prompt">Prompt</label>
|
||||
<label htmlFor="template-prompt">{t("agentPrompts.labels.prompt", "Prompt")}</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon prompt-template-fullscreen-btn"
|
||||
onClick={toggleTemplatePromptFullscreen}
|
||||
aria-label="Expand prompt to fullscreen"
|
||||
aria-label={t("agentPrompts.ariaLabels.expandPrompt", "Expand prompt to fullscreen")}
|
||||
data-testid="template-prompt-fullscreen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
@@ -492,7 +495,7 @@ export function AgentPromptsManager({
|
||||
}}
|
||||
>
|
||||
<div className="prompt-override-fullscreen-header">
|
||||
<div className="prompt-override-fullscreen-title">Edit Prompt</div>
|
||||
<div className="prompt-override-fullscreen-title">{t("agentPrompts.titles.editPrompt", "Edit Prompt")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="prompt-override-fullscreen-close"
|
||||
@@ -500,18 +503,18 @@ export function AgentPromptsManager({
|
||||
data-testid="template-prompt-collapse"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
Collapse
|
||||
{t("agentPrompts.actions.collapse", "Collapse")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="template-prompt-fullscreen"
|
||||
aria-label="Template prompt - fullscreen"
|
||||
aria-label={t("agentPrompts.ariaLabels.templatePromptFullscreen", "Template prompt - fullscreen")}
|
||||
className="prompt-template-prompt-textarea"
|
||||
value={templateForm.prompt}
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, prompt: e.target.value }))
|
||||
}
|
||||
placeholder="Enter the system prompt for this template..."
|
||||
placeholder={t("agentPrompts.placeholders.prompt", "Enter the system prompt for this template...")}
|
||||
rows={30}
|
||||
autoFocus
|
||||
data-testid="template-prompt-input-fullscreen"
|
||||
@@ -529,7 +532,7 @@ export function AgentPromptsManager({
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, prompt: e.target.value }))
|
||||
}
|
||||
placeholder="Enter the system prompt for this template..."
|
||||
placeholder={t("agentPrompts.placeholders.prompt", "Enter the system prompt for this template...")}
|
||||
rows={8}
|
||||
className="prompt-template-prompt-textarea"
|
||||
data-testid="template-prompt-input"
|
||||
@@ -550,7 +553,7 @@ export function AgentPromptsManager({
|
||||
onClick={handleCancelEdit}
|
||||
data-testid="cancel-template-btn"
|
||||
>
|
||||
Cancel
|
||||
{t("agentPrompts.actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -558,7 +561,7 @@ export function AgentPromptsManager({
|
||||
onClick={handleSaveTemplate}
|
||||
data-testid="save-template-btn"
|
||||
>
|
||||
{isCreating ? "Create" : "Save"}
|
||||
{isCreating ? t("agentPrompts.actions.create", "Create") : t("agentPrompts.actions.save", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,9 +570,9 @@ export function AgentPromptsManager({
|
||||
|
||||
{/* Built-in Templates Section */}
|
||||
<div className="prompt-template-section" data-testid="builtin-templates">
|
||||
<h4 className="prompt-template-section-title">Built-in Templates</h4>
|
||||
<h4 className="prompt-template-section-title">{t("agentPrompts.sections.builtinTemplates", "Built-in Templates")}</h4>
|
||||
<p className="prompt-template-section-desc">
|
||||
These templates are provided by Fusion and cannot be modified.
|
||||
{t("agentPrompts.descriptions.builtinTemplates", "These templates are provided by Fusion and cannot be modified.")}
|
||||
</p>
|
||||
<div className="prompt-template-list">
|
||||
{BUILTIN_AGENT_PROMPTS.map((template) => (
|
||||
@@ -586,7 +589,7 @@ export function AgentPromptsManager({
|
||||
<span
|
||||
className={`prompt-template-badge-built-in ${getRoleToneClassName(template.role)}`}
|
||||
>
|
||||
Built-in
|
||||
{t("agentPrompts.badges.builtin", "Built-in")}
|
||||
</span>
|
||||
<span
|
||||
className={`prompt-template-badge-role ${getRoleToneClassName(template.role)}`}
|
||||
@@ -599,8 +602,8 @@ export function AgentPromptsManager({
|
||||
type="button"
|
||||
className="btn-icon prompt-template-fullscreen-btn"
|
||||
onClick={() => openTemplateViewFullscreen("builtin", template.id)}
|
||||
title="View full prompt"
|
||||
aria-label={`View full prompt for ${template.name}`}
|
||||
title={t("agentPrompts.titles.viewFullPrompt", "View full prompt")}
|
||||
aria-label={t("agentPrompts.ariaLabels.viewFullPromptFor", "View full prompt for {{name}}", { name: template.name })}
|
||||
data-testid={`expand-view-${template.id}`}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
@@ -620,14 +623,14 @@ export function AgentPromptsManager({
|
||||
|
||||
{/* Custom Templates Section */}
|
||||
<div className="prompt-template-section" data-testid="custom-templates">
|
||||
<h4 className="prompt-template-section-title">Custom Templates</h4>
|
||||
<h4 className="prompt-template-section-title">{t("agentPrompts.sections.customTemplates", "Custom Templates")}</h4>
|
||||
<p className="prompt-template-section-desc">
|
||||
Create custom templates to override built-in prompts for specific roles.
|
||||
{t("agentPrompts.descriptions.customTemplates", "Create custom templates to override built-in prompts for specific roles.")}
|
||||
</p>
|
||||
|
||||
{customTemplates.length === 0 && !isCreating && (
|
||||
<div className="prompt-template-empty">
|
||||
No custom templates yet. Create one to get started.
|
||||
{t("agentPrompts.emptyStates.noCustomTemplates", "No custom templates yet. Create one to get started.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -641,7 +644,7 @@ export function AgentPromptsManager({
|
||||
>
|
||||
{deleteConfirmId === template.id ? (
|
||||
<div className="prompt-template-delete-confirm">
|
||||
<p>Delete "{template.name}"?</p>
|
||||
<p>{t("agentPrompts.confirmations.deleteTemplate", "Delete \"{{name}}\"?", { name: template.name })}</p>
|
||||
<div className="prompt-template-delete-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -649,7 +652,7 @@ export function AgentPromptsManager({
|
||||
onClick={() => handleDeleteTemplate(template.id)}
|
||||
data-testid={`confirm-delete-${template.id}`}
|
||||
>
|
||||
Delete
|
||||
{t("agentPrompts.actions.delete", "Delete")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -657,7 +660,7 @@ export function AgentPromptsManager({
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
data-testid={`cancel-delete-${template.id}`}
|
||||
>
|
||||
Cancel
|
||||
{t("agentPrompts.actions.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -669,7 +672,7 @@ export function AgentPromptsManager({
|
||||
{template.name}
|
||||
</span>
|
||||
<span className="prompt-template-badge-custom">
|
||||
Custom
|
||||
{t("agentPrompts.badges.custom", "Custom")}
|
||||
</span>
|
||||
<span
|
||||
className={`prompt-template-badge-role ${getRoleToneClassName(template.role)}`}
|
||||
@@ -679,7 +682,7 @@ export function AgentPromptsManager({
|
||||
{/* Show override indicator if this custom template overrides a built-in */}
|
||||
{isBuiltinId(template.id) && (
|
||||
<span className="prompt-template-badge-override">
|
||||
Overrides built-in
|
||||
{t("agentPrompts.badges.overridesBuiltin", "Overrides built-in")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -688,8 +691,8 @@ export function AgentPromptsManager({
|
||||
type="button"
|
||||
className="btn-icon prompt-template-fullscreen-btn"
|
||||
onClick={() => openTemplateViewFullscreen("custom", template.id)}
|
||||
title="View full prompt"
|
||||
aria-label={`View full prompt for ${template.name}`}
|
||||
title={t("agentPrompts.titles.viewFullPrompt", "View full prompt")}
|
||||
aria-label={t("agentPrompts.ariaLabels.viewFullPromptFor", "View full prompt for {{name}}", { name: template.name })}
|
||||
data-testid={`expand-view-${template.id}`}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
@@ -698,8 +701,8 @@ export function AgentPromptsManager({
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => handleStartEdit(template)}
|
||||
title="Edit"
|
||||
aria-label={`Edit ${template.name}`}
|
||||
title={t("agentPrompts.titles.edit", "Edit")}
|
||||
aria-label={t("agentPrompts.ariaLabels.editTemplate", "Edit {{name}}", { name: template.name })}
|
||||
data-testid={`edit-${template.id}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
@@ -708,8 +711,8 @@ export function AgentPromptsManager({
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(template.id)}
|
||||
title="Delete"
|
||||
aria-label={`Delete ${template.name}`}
|
||||
title={t("agentPrompts.titles.delete", "Delete")}
|
||||
aria-label={t("agentPrompts.ariaLabels.deleteTemplate", "Delete {{name}}", { name: template.name })}
|
||||
data-testid={`delete-${template.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -738,7 +741,7 @@ export function AgentPromptsManager({
|
||||
data-testid="add-template-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Custom Template
|
||||
{t("agentPrompts.actions.addCustomTemplate", "Add Custom Template")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -773,7 +776,7 @@ export function AgentPromptsManager({
|
||||
data-testid={`collapse-view-${fullscreenTemplate.id}`}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
Collapse
|
||||
{t("agentPrompts.actions.collapse", "Collapse")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="prompt-template-fullscreen-pre">{fullscreenTemplate.prompt}</pre>
|
||||
@@ -786,8 +789,7 @@ export function AgentPromptsManager({
|
||||
{activeTab === "assignments" && (
|
||||
<div className="prompt-manager-assignments-tab" data-testid="assignments-tab">
|
||||
<p className="prompt-assignments-desc">
|
||||
Assign specific templates to agent roles. When a role has an assignment, that
|
||||
template will be used instead of the default built-in.
|
||||
{t("agentPrompts.descriptions.assignments", "Assign specific templates to agent roles. When a role has an assignment, that template will be used instead of the default built-in.")}
|
||||
</p>
|
||||
|
||||
<div className="prompt-role-assignment-list">
|
||||
@@ -813,7 +815,7 @@ export function AgentPromptsManager({
|
||||
</span>
|
||||
{isOverriding && (
|
||||
<span className="prompt-role-assignment-status">
|
||||
{selectedTemplate?.name ?? "Custom"} (overrides default)
|
||||
{selectedTemplate?.name ?? t("agentPrompts.labels.custom", "Custom")} ({t("agentPrompts.labels.overridesDefault", "overrides default")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -825,11 +827,11 @@ export function AgentPromptsManager({
|
||||
}
|
||||
data-testid={`select-${role}`}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
<option value="">{t("agentPrompts.options.useDefault", "Use default")}</option>
|
||||
{availableTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
{isBuiltinId(template.id) ? " (built-in)" : " (custom)"}
|
||||
{isBuiltinId(template.id) ? ` (${t("agentPrompts.labels.builtIn", "built-in")})` : ` (${t("agentPrompts.labels.custom", "custom")})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -840,8 +842,7 @@ export function AgentPromptsManager({
|
||||
|
||||
{Object.keys(roleAssignments).length > 0 && (
|
||||
<div className="prompt-assignments-note">
|
||||
<strong>Note:</strong> Role assignments are stored in the agentPrompts
|
||||
configuration. Custom templates override built-ins by ID.
|
||||
<strong>{t("agentPrompts.labels.note", "Note")}:</strong> {t("agentPrompts.descriptions.assignmentNote", "Role assignments are stored in the agentPrompts configuration. Custom templates override built-ins by ID.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -851,9 +852,7 @@ export function AgentPromptsManager({
|
||||
{activeTab === "overrides" && (
|
||||
<div className="prompt-manager-overrides-tab" data-testid="overrides-tab">
|
||||
<p className="prompt-overrides-desc">
|
||||
Customize specific segments of AI agent prompts. Edits override built-in
|
||||
defaults. Use the Reset button to restore the original default for any
|
||||
prompt.
|
||||
{t("agentPrompts.descriptions.overrides", "Customize specific segments of AI agent prompts. Edits override built-in defaults. Use the Reset button to restore the original default for any prompt.")}
|
||||
</p>
|
||||
|
||||
<div className="prompt-overrides-list">
|
||||
@@ -880,7 +879,7 @@ export function AgentPromptsManager({
|
||||
</span>
|
||||
<code className="prompt-override-key">{key}</code>
|
||||
{hasOverride && (
|
||||
<span className="prompt-override-badge">customized</span>
|
||||
<span className="prompt-override-badge">{t("agentPrompts.badges.customized", "customized")}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="prompt-override-header-actions">
|
||||
@@ -892,7 +891,7 @@ export function AgentPromptsManager({
|
||||
e.stopPropagation();
|
||||
toggleFullscreenOverride(key);
|
||||
}}
|
||||
aria-label="Expand to fullscreen"
|
||||
aria-label={t("agentPrompts.ariaLabels.expandToFullscreen", "Expand to fullscreen")}
|
||||
data-testid={`fullscreen-${key}`}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
@@ -901,7 +900,7 @@ export function AgentPromptsManager({
|
||||
<button
|
||||
type="button"
|
||||
className="prompt-override-expand-btn"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
aria-label={isExpanded ? t("agentPrompts.ariaLabels.collapse", "Collapse") : t("agentPrompts.ariaLabels.expand", "Expand")}
|
||||
data-testid={`expand-${key}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -939,12 +938,12 @@ export function AgentPromptsManager({
|
||||
data-testid={`collapse-fullscreen-${key}`}
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
Collapse
|
||||
{t("agentPrompts.actions.collapse", "Collapse")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id={`prompt-${key}-fullscreen`}
|
||||
aria-label={`${promptMeta.name} prompt override (${key}) - fullscreen`}
|
||||
aria-label={t("agentPrompts.ariaLabels.promptOverrideFullscreen", "{{name}} prompt override ({{key}}) - fullscreen", { name: promptMeta.name, key })}
|
||||
className="prompt-override-textarea"
|
||||
value={currentOverride}
|
||||
onChange={(e) => {
|
||||
@@ -963,13 +962,13 @@ export function AgentPromptsManager({
|
||||
onClick={() => handleResetOverride(key)}
|
||||
data-testid={`reset-fullscreen-${key}`}
|
||||
>
|
||||
Reset
|
||||
{t("agentPrompts.actions.reset", "Reset")}
|
||||
</button>
|
||||
)}
|
||||
<small className="prompt-override-hint">
|
||||
{hasOverride
|
||||
? "Custom override active. Click Reset to restore default."
|
||||
: `No override set. Using built-in default (${promptMeta.defaultContent.length} chars).`}
|
||||
? t("agentPrompts.hints.customOverrideActive", "Custom override active. Click Reset to restore default.")
|
||||
: t("agentPrompts.hints.noOverrideSet", "No override set. Using built-in default ({{chars}} chars).", { chars: promptMeta.defaultContent.length })}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -977,13 +976,13 @@ export function AgentPromptsManager({
|
||||
<div className="prompt-override-editor">
|
||||
<textarea
|
||||
id={`prompt-${key}`}
|
||||
aria-label={`${promptMeta.name} prompt override (${key})`}
|
||||
aria-label={t("agentPrompts.ariaLabels.promptOverride", "{{name}} prompt override ({{key}})", { name: promptMeta.name, key })}
|
||||
className="prompt-override-textarea"
|
||||
value={currentOverride}
|
||||
onChange={(e) => {
|
||||
handlePromptOverrideChange(key, e.target.value);
|
||||
}}
|
||||
placeholder={`Default: ${promptMeta.defaultContent.slice(0, 100)}${promptMeta.defaultContent.length > 100 ? "..." : ""}`}
|
||||
placeholder={t("agentPrompts.placeholders.promptDefault", "Default: {{preview}}", { preview: promptMeta.defaultContent.slice(0, 100) + (promptMeta.defaultContent.length > 100 ? "..." : "") })}
|
||||
rows={4}
|
||||
data-testid={`override-input-${key}`}
|
||||
/>
|
||||
@@ -998,13 +997,13 @@ export function AgentPromptsManager({
|
||||
}}
|
||||
data-testid={`reset-${key}`}
|
||||
>
|
||||
Reset
|
||||
{t("agentPrompts.actions.reset", "Reset")}
|
||||
</button>
|
||||
)}
|
||||
<small className="prompt-override-hint">
|
||||
{hasOverride
|
||||
? "Custom override active. Click Reset to restore default."
|
||||
: `No override set. Using built-in default (${promptMeta.defaultContent.length} chars).`}
|
||||
? t("agentPrompts.hints.customOverrideActive", "Custom override active. Click Reset to restore default.")
|
||||
: t("agentPrompts.hints.noOverrideSet", "No override set. Using built-in default ({{chars}} chars).", { chars: promptMeta.defaultContent.length })}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./AgentProvisioningPolicyEditor.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AGENT_PROVISIONING_APPROVAL_MODES,
|
||||
type AgentProvisioningApprovalMode,
|
||||
@@ -11,20 +12,7 @@ interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<AgentProvisioningApprovalMode, { label: string; description: string }> = {
|
||||
always: {
|
||||
label: "Always require approval",
|
||||
description: "All fn_agent_create/fn_agent_delete requests require approval unless caller is trusted.",
|
||||
},
|
||||
"trusted-only": {
|
||||
label: "Trusted-only",
|
||||
description: "Trusted roles/agent IDs bypass approval; other callers require approval.",
|
||||
},
|
||||
never: {
|
||||
label: "Never require approval",
|
||||
description: "Allow provisioning without approval for non-privileged callers.",
|
||||
},
|
||||
};
|
||||
// Note: MODE_LABELS moved into component for i18n access
|
||||
|
||||
function tokenizeList(value: string): string[] {
|
||||
return Array.from(
|
||||
@@ -38,9 +26,25 @@ function tokenizeList(value: string): string[] {
|
||||
}
|
||||
|
||||
export function AgentProvisioningPolicyEditor({ value, onChange, disabled = false }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const approvalMode = value?.approvalMode ?? "trusted-only";
|
||||
const alwaysApproveDelete = value?.alwaysApproveDelete ?? true;
|
||||
|
||||
const MODE_LABELS: Record<AgentProvisioningApprovalMode, { label: string; description: string }> = {
|
||||
always: {
|
||||
label: t("agentProvisioning.always", "Always require approval"),
|
||||
description: t("agentProvisioning.alwaysDesc", "All fn_agent_create/fn_agent_delete requests require approval unless caller is trusted."),
|
||||
},
|
||||
"trusted-only": {
|
||||
label: t("agentProvisioning.trustedOnly", "Trusted-only"),
|
||||
description: t("agentProvisioning.trustedOnlyDesc", "Trusted roles/agent IDs bypass approval; other callers require approval."),
|
||||
},
|
||||
never: {
|
||||
label: t("agentProvisioning.never", "Never require approval"),
|
||||
description: t("agentProvisioning.neverDesc", "Allow provisioning without approval for non-privileged callers."),
|
||||
},
|
||||
};
|
||||
|
||||
const update = (patch: Partial<NonNullable<ProjectSettings["agentProvisioning"]>>) => {
|
||||
onChange({ ...(value ?? {}), ...patch });
|
||||
};
|
||||
@@ -48,7 +52,7 @@ export function AgentProvisioningPolicyEditor({ value, onChange, disabled = fals
|
||||
return (
|
||||
<div className="agent-provisioning-policy-editor card">
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-provisioning-approval-mode">Approval mode</label>
|
||||
<label htmlFor="agent-provisioning-approval-mode">{t("agentProvisioning.approvalMode", "Approval mode")}</label>
|
||||
<select
|
||||
id="agent-provisioning-approval-mode"
|
||||
className="select"
|
||||
@@ -74,12 +78,12 @@ export function AgentProvisioningPolicyEditor({ value, onChange, disabled = fals
|
||||
onChange={(event) => update({ alwaysApproveDelete: event.target.checked })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
Always require approval for fn_agent_delete
|
||||
{t("agentProvisioning.alwaysApproveDelete", "Always require approval for fn_agent_delete")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-provisioning-trusted-roles">Trusted roles</label>
|
||||
<label htmlFor="agent-provisioning-trusted-roles">{t("agentProvisioning.trustedRoles", "Trusted roles")}</label>
|
||||
<textarea
|
||||
id="agent-provisioning-trusted-roles"
|
||||
className="input agent-provisioning-policy-textarea"
|
||||
@@ -92,7 +96,7 @@ export function AgentProvisioningPolicyEditor({ value, onChange, disabled = fals
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-provisioning-trusted-agent-ids">Trusted agent IDs</label>
|
||||
<label htmlFor="agent-provisioning-trusted-agent-ids">{t("agentProvisioning.trustedAgentIds", "Trusted agent IDs")}</label>
|
||||
<textarea
|
||||
id="agent-provisioning-trusted-agent-ids"
|
||||
className="input agent-provisioning-policy-textarea"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./AgentReflectionsTab.css";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
@@ -132,6 +133,7 @@ function renderStars(score: number, maxScore: number = 5) {
|
||||
}
|
||||
|
||||
export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentReflectionsTabProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [reflections, setReflections] = useState<AgentReflection[]>([]);
|
||||
const [performance, setPerformance] = useState<AgentPerformanceSummary | null>(null);
|
||||
const [ratingSummary, setRatingSummary] = useState<AgentRatingSummary | null>(null);
|
||||
@@ -154,7 +156,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
setReflections(reflectionsData);
|
||||
setPerformance(performanceData);
|
||||
} catch (err) {
|
||||
addToast(`Failed to load reflections: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.reflections.loadError", "Failed to load reflections: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setIsLoadingReflections(false);
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
setRatingSummary(summaryData);
|
||||
setRatings(ratingsData);
|
||||
} catch (err) {
|
||||
addToast(`Failed to load ratings: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.ratings.loadError", "Failed to load ratings: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setIsLoadingRatings(false);
|
||||
}
|
||||
@@ -185,11 +187,11 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
try {
|
||||
const reflection = await triggerAgentReflection(agentId, projectId);
|
||||
if (!reflection) {
|
||||
addToast("Not enough history to generate a reflection yet", "error");
|
||||
addToast(t("agents.reflections.insufficientHistory", "Not enough history to generate a reflection yet"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
addToast("Reflection generated successfully", "success");
|
||||
addToast(t("agents.reflections.generateSuccess", "Reflection generated successfully"), "success");
|
||||
setIsLoadingReflections(true);
|
||||
await loadReflectionData();
|
||||
} catch (err: unknown) {
|
||||
@@ -197,11 +199,11 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
const normalizedMessage = message.toLowerCase();
|
||||
|
||||
if (normalizedMessage.includes("agent not found") || normalizedMessage.includes("not found")) {
|
||||
addToast("This agent is no longer available. It may have been deleted.", "error");
|
||||
addToast(t("agents.reflections.agentNotFound", "This agent is no longer available. It may have been deleted."), "error");
|
||||
} else if (normalizedMessage.includes("insufficient history")) {
|
||||
addToast("Not enough history to generate a reflection yet", "error");
|
||||
addToast(t("agents.reflections.insufficientHistory", "Not enough history to generate a reflection yet"), "error");
|
||||
} else {
|
||||
addToast(`Failed to generate reflection: ${message}`, "error");
|
||||
addToast(t("agents.reflections.generateError", "Failed to generate reflection: {{error}}", { error: message }), "error");
|
||||
}
|
||||
} finally {
|
||||
setIsReflecting(false);
|
||||
@@ -224,10 +226,10 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
setNewScore(0);
|
||||
setNewCategory("");
|
||||
setNewComment("");
|
||||
addToast("Rating added", "success");
|
||||
addToast(t("agents.ratings.addSuccess", "Rating added"), "success");
|
||||
await loadRatingsData();
|
||||
} catch (err) {
|
||||
addToast(`Failed to add rating: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.ratings.addError", "Failed to add rating: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setIsSubmittingRating(false);
|
||||
}
|
||||
@@ -236,10 +238,10 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
const handleDeleteRating = async (ratingId: string) => {
|
||||
try {
|
||||
await deleteAgentRating(agentId, ratingId, projectId);
|
||||
addToast("Rating deleted", "success");
|
||||
addToast(t("agents.ratings.deleteSuccess", "Rating deleted"), "success");
|
||||
await loadRatingsData();
|
||||
} catch (err) {
|
||||
addToast(`Failed to delete rating: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.ratings.deleteError", "Failed to delete rating: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,7 +254,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
<div className="reflections-tab">
|
||||
<div className="reflections-loading-indicator">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-muted">Loading evaluation...</span>
|
||||
<span className="text-muted">{t("agents.reflections.loadingEvaluation", "Loading evaluation...")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -269,23 +271,23 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
<div className="reflections-header">
|
||||
<h3>
|
||||
<BarChart3 size={16} />
|
||||
Performance, Reflections & Ratings
|
||||
{t("agents.reflections.sectionTitle", "Performance, Reflections & Ratings")}
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleReflectNow}
|
||||
disabled={isReflecting}
|
||||
title="Generate a manual reflection"
|
||||
title={t("agents.reflections.reflectNowTitle", "Generate a manual reflection")}
|
||||
>
|
||||
{isReflecting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Reflecting...
|
||||
{t("agents.reflections.reflecting", "Reflecting...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={14} />
|
||||
Reflect Now
|
||||
{t("agents.reflections.reflectNow", "Reflect Now")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -298,7 +300,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
<TrendingUp size={16} style={{ color: "var(--color-success)" }} />
|
||||
{performance.totalTasksCompleted}
|
||||
</div>
|
||||
<div className="stat-label">Tasks Completed</div>
|
||||
<div className="stat-label">{t("agents.performance.tasksCompleted", "Tasks Completed")}</div>
|
||||
</div>
|
||||
|
||||
<div className="reflections-stat-card">
|
||||
@@ -306,7 +308,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
<TrendingDown size={16} style={{ color: "var(--color-error)" }} />
|
||||
{performance.totalTasksFailed}
|
||||
</div>
|
||||
<div className="stat-label">Tasks Failed</div>
|
||||
<div className="stat-label">{t("agents.performance.tasksFailed", "Tasks Failed")}</div>
|
||||
</div>
|
||||
|
||||
<div className="reflections-stat-card">
|
||||
@@ -314,7 +316,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
<Zap size={16} style={{ color: "var(--in-progress)" }} />
|
||||
{formatDuration(performance.avgDurationMs)}
|
||||
</div>
|
||||
<div className="stat-label">Avg Duration</div>
|
||||
<div className="stat-label">{t("agents.performance.avgDuration", "Avg Duration")}</div>
|
||||
</div>
|
||||
|
||||
<div className="reflections-stat-card">
|
||||
@@ -332,7 +334,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
/>
|
||||
{formatPercent(performance.successRate)}
|
||||
</div>
|
||||
<div className="stat-label">Success Rate</div>
|
||||
<div className="stat-label">{t("agents.performance.successRate", "Success Rate")}</div>
|
||||
</div>
|
||||
|
||||
<div className="reflections-stat-card">
|
||||
@@ -340,7 +342,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
<Lightbulb size={16} style={{ color: "var(--color-info)" }} />
|
||||
{performance.recentReflectionCount}
|
||||
</div>
|
||||
<div className="stat-label">Reflections</div>
|
||||
<div className="stat-label">{t("agents.performance.reflections", "Reflections")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -348,17 +350,17 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
{hasNoPerformanceData && (
|
||||
<div className="reflections-no-data">
|
||||
<BarChart3 size={24} opacity={0.3} />
|
||||
<p>No performance data yet</p>
|
||||
<p>{t("agents.performance.noData", "No performance data yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="reflections-ratings-section">
|
||||
<h4>User Ratings</h4>
|
||||
<h4>{t("agents.ratings.title", "User Ratings")}</h4>
|
||||
|
||||
{isLoadingRatings ? (
|
||||
<div className="reflections-loading-indicator">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-muted">Loading ratings...</span>
|
||||
<span className="text-muted">{t("agents.ratings.loading", "Loading ratings...")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -369,7 +371,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
{renderStars(Math.round(ratingSummary.averageScore))}
|
||||
</div>
|
||||
<div className="rating-stats">
|
||||
<span className="rating-count">{ratingSummary.totalRatings} ratings</span>
|
||||
<span className="rating-count">{t("agents.ratings.count", "{{count}} ratings", { count: ratingSummary.totalRatings })}</span>
|
||||
<span className={`rating-trend-badge ${getTrendClass(ratingSummary.trend)}`}>
|
||||
{getTrendLabel(ratingSummary.trend)}
|
||||
</span>
|
||||
@@ -379,7 +381,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
|
||||
{ratingSummary && Object.keys(ratingSummary.categoryAverages).length > 0 && (
|
||||
<div className="category-breakdown">
|
||||
<h4>Category Averages</h4>
|
||||
<h4>{t("agents.ratings.categoryAverages", "Category Averages")}</h4>
|
||||
{Object.entries(ratingSummary.categoryAverages as Record<string, number>).map(([category, avg]) => (
|
||||
<div key={category} className="category-item">
|
||||
<span className="category-name">{category}</span>
|
||||
@@ -390,7 +392,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
)}
|
||||
|
||||
<form className="add-rating-form" onSubmit={handleSubmitRating}>
|
||||
<h4>Add Rating</h4>
|
||||
<h4>{t("agents.ratings.addRating", "Add Rating")}</h4>
|
||||
<div className="star-selector">
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<button
|
||||
@@ -398,7 +400,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
type="button"
|
||||
className="star-btn touch-target"
|
||||
onClick={() => setNewScore(score)}
|
||||
title={`${score} star${score > 1 ? "s" : ""}`}
|
||||
title={t("agents.ratings.starCount", "{{count}} star", { count: score, defaultValue_one: "{{count}} star", defaultValue_other: "{{count}} stars" })}
|
||||
>
|
||||
<Star
|
||||
size={24}
|
||||
@@ -413,17 +415,17 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
className="select add-rating-category-select"
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
<option value="quality">Quality</option>
|
||||
<option value="speed">Speed</option>
|
||||
<option value="communication">Communication</option>
|
||||
<option value="reliability">Reliability</option>
|
||||
<option value="other">Other</option>
|
||||
<option value="">{t("agents.ratings.categorySelect", "Select category...")}</option>
|
||||
<option value="quality">{t("agents.ratings.categoryQuality", "Quality")}</option>
|
||||
<option value="speed">{t("agents.ratings.categorySpeed", "Speed")}</option>
|
||||
<option value="communication">{t("agents.ratings.categoryCommunication", "Communication")}</option>
|
||||
<option value="reliability">{t("agents.ratings.categoryReliability", "Reliability")}</option>
|
||||
<option value="other">{t("agents.ratings.categoryOther", "Other")}</option>
|
||||
</select>
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Optional comment..."
|
||||
placeholder={t("agents.ratings.commentPlaceholder", "Optional comment...")}
|
||||
className="input add-rating-comment-input"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -432,14 +434,14 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
className="btn btn-task-create"
|
||||
disabled={newScore === 0 || isSubmittingRating}
|
||||
>
|
||||
{isSubmittingRating ? "Submitting..." : "Submit Rating"}
|
||||
{isSubmittingRating ? t("agents.ratings.submitting", "Submitting...") : t("agents.ratings.submitRating", "Submit Rating")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="rating-history">
|
||||
<h4>Rating History</h4>
|
||||
<h4>{t("agents.ratings.historyTitle", "Rating History")}</h4>
|
||||
{ratings.length === 0 ? (
|
||||
<p className="no-ratings">No ratings yet</p>
|
||||
<p className="no-ratings">{t("agents.ratings.noRatings", "No ratings yet")}</p>
|
||||
) : (
|
||||
ratings.map((rating) => (
|
||||
<div key={rating.id} className="rating-history-item">
|
||||
@@ -453,7 +455,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
type="button"
|
||||
className="rating-delete-btn touch-target"
|
||||
onClick={() => void handleDeleteRating(rating.id)}
|
||||
title="Delete rating"
|
||||
title={t("agents.ratings.deleteRating", "Delete rating")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -470,18 +472,18 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
</div>
|
||||
|
||||
<div className="reflections-list">
|
||||
<h4>Reflection History</h4>
|
||||
<h4>{t("agents.reflections.historyTitle", "Reflection History")}</h4>
|
||||
|
||||
{isLoadingReflections ? (
|
||||
<div className="reflections-loading-indicator">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span className="text-muted">Loading reflections...</span>
|
||||
<span className="text-muted">{t("agents.reflections.loading", "Loading reflections...")}</span>
|
||||
</div>
|
||||
) : reflections.length === 0 ? (
|
||||
<div className="reflection-empty">
|
||||
<Lightbulb size={32} opacity={0.3} />
|
||||
<p>No reflections yet</p>
|
||||
<p className="text-secondary">Trigger a reflection to get started</p>
|
||||
<p>{t("agents.reflections.noReflections", "No reflections yet")}</p>
|
||||
<p className="text-secondary">{t("agents.reflections.getStarted", "Trigger a reflection to get started")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="reflection-cards">
|
||||
@@ -513,7 +515,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
{reflection.insights.length > 0 && (
|
||||
<div className="reflection-insights">
|
||||
<h5>
|
||||
<Lightbulb size={14} /> Insights
|
||||
<Lightbulb size={14} /> {t("agents.reflections.insights", "Insights")}
|
||||
</h5>
|
||||
<ul>
|
||||
{reflection.insights.map((insight, i) => (
|
||||
@@ -526,7 +528,7 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
{reflection.suggestedImprovements.length > 0 && (
|
||||
<div className="reflection-suggestions">
|
||||
<h5>
|
||||
<TrendingUp size={14} /> Suggested Improvements
|
||||
<TrendingUp size={14} /> {t("agents.reflections.suggestedImprovements", "Suggested Improvements")}
|
||||
</h5>
|
||||
<ul>
|
||||
{reflection.suggestedImprovements.map((suggestion, i) => (
|
||||
@@ -538,29 +540,29 @@ export function AgentReflectionsTab({ agentId, projectId, addToast }: AgentRefle
|
||||
|
||||
{reflection.metrics && (
|
||||
<div className="reflection-metrics">
|
||||
<h5>Metrics</h5>
|
||||
<h5>{t("agents.reflections.metrics", "Metrics")}</h5>
|
||||
<div className="metrics-grid">
|
||||
{reflection.metrics.tasksCompleted !== undefined && (
|
||||
<div className="metric">
|
||||
<span className="metric-label">Tasks:</span>
|
||||
<span className="metric-label">{t("agents.reflections.metricTasks", "Tasks:")}</span>
|
||||
<span className="metric-value">{reflection.metrics.tasksCompleted}</span>
|
||||
</div>
|
||||
)}
|
||||
{reflection.metrics.tasksFailed !== undefined && (
|
||||
<div className="metric">
|
||||
<span className="metric-label">Failed:</span>
|
||||
<span className="metric-label">{t("agents.reflections.metricFailed", "Failed:")}</span>
|
||||
<span className="metric-value">{reflection.metrics.tasksFailed}</span>
|
||||
</div>
|
||||
)}
|
||||
{reflection.metrics.avgDurationMs !== undefined && (
|
||||
<div className="metric">
|
||||
<span className="metric-label">Avg Duration:</span>
|
||||
<span className="metric-label">{t("agents.reflections.metricAvgDuration", "Avg Duration:")}</span>
|
||||
<span className="metric-value">{formatDuration(reflection.metrics.avgDurationMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
{reflection.metrics.errorCount !== undefined && (
|
||||
<div className="metric">
|
||||
<span className="metric-label">Errors:</span>
|
||||
<span className="metric-label">{t("agents.reflections.metricErrors", "Errors:")}</span>
|
||||
<span className="metric-value">{reflection.metrics.errorCount}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle, XCircle, Loader2, Square, Clock } from "lucide-react";
|
||||
import type { AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgentRuns, stopAgentRun } from "../api";
|
||||
@@ -19,6 +20,7 @@ const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }>
|
||||
};
|
||||
|
||||
export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHistoryProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const { confirm } = useConfirm();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -41,8 +43,8 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
const shouldStop = await confirm({
|
||||
title: "Stop Run",
|
||||
message: "Stop this run?",
|
||||
title: t("agents.runs.stopTitle", "Stop Run"),
|
||||
message: t("agents.runs.stopMessage", "Stop this run?"),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldStop) {
|
||||
@@ -58,11 +60,11 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
|
||||
}, [agentId, projectId, loadRuns, confirm]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> Loading runs...</div>;
|
||||
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> {t("agents.runs.loading", "Loading runs...")}</div>;
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return <div className="agent-run-empty">No runs yet</div>;
|
||||
return <div className="agent-run-empty">{t("agents.runs.empty", "No runs yet")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,10 +118,10 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
|
||||
e.stopPropagation();
|
||||
void handleStop();
|
||||
}}
|
||||
aria-label="Stop run"
|
||||
aria-label={t("agents.runs.stopAriaLabel", "Stop run")}
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
<Square size={12} /> Stop
|
||||
<Square size={12} /> {t("agents.runs.stop", "Stop")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Agent } from "../api";
|
||||
import "./AgentTokenStatsPanel.css";
|
||||
|
||||
@@ -23,6 +24,7 @@ function formatTokenCount(value: number): string {
|
||||
}
|
||||
|
||||
export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { rows, totalInputTokens, totalOutputTokens, totalTokens } = useMemo(() => {
|
||||
const computedRows = agents
|
||||
.map((agent): AgentTokenRow => {
|
||||
@@ -49,22 +51,22 @@ export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
|
||||
const hasUsageData = totalTokens > 0;
|
||||
|
||||
return (
|
||||
<section className="agent-token-stats-panel" aria-label="Agent token usage statistics">
|
||||
<section className="agent-token-stats-panel" aria-label={t("agents.tokenStatistics", "Agent token usage statistics")}>
|
||||
<header className="agent-token-stats-panel__header">
|
||||
<h3 className="agent-token-stats-panel__title">Token Usage by Agent</h3>
|
||||
<h3 className="agent-token-stats-panel__title">{t("agents.tokenUsageByAgent", "Token Usage by Agent")}</h3>
|
||||
</header>
|
||||
|
||||
<div className="agent-token-stats-panel__totals" role="list" aria-label="Token usage totals">
|
||||
<div className="agent-token-stats-panel__totals" role="list" aria-label={t("agents.tokenUsageTotals", "Token usage totals")}>
|
||||
<div className="agent-token-stats-panel__total-card" role="listitem">
|
||||
<span className="agent-token-stats-panel__total-label">Input Tokens</span>
|
||||
<span className="agent-token-stats-panel__total-label">{t("agents.inputTokens", "Input Tokens")}</span>
|
||||
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalInputTokens)}</span>
|
||||
</div>
|
||||
<div className="agent-token-stats-panel__total-card" role="listitem">
|
||||
<span className="agent-token-stats-panel__total-label">Output Tokens</span>
|
||||
<span className="agent-token-stats-panel__total-label">{t("agents.outputTokens", "Output Tokens")}</span>
|
||||
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalOutputTokens)}</span>
|
||||
</div>
|
||||
<div className="agent-token-stats-panel__total-card" role="listitem">
|
||||
<span className="agent-token-stats-panel__total-label">Combined Tokens</span>
|
||||
<span className="agent-token-stats-panel__total-label">{t("agents.combinedTokens", "Combined Tokens")}</span>
|
||||
<span className="agent-token-stats-panel__total-value">{formatTokenCount(totalTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,10 +76,10 @@ export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
|
||||
<table className="agent-token-stats-panel__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Agent</th>
|
||||
<th scope="col">Input</th>
|
||||
<th scope="col">Output</th>
|
||||
<th scope="col">Total</th>
|
||||
<th scope="col">{t("agents.agent", "Agent")}</th>
|
||||
<th scope="col">{t("agents.input", "Input")}</th>
|
||||
<th scope="col">{t("agents.output", "Output")}</th>
|
||||
<th scope="col">{t("agents.total", "Total")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -97,7 +99,7 @@ export function AgentTokenStatsPanel({ agents }: AgentTokenStatsPanelProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-token-stats-panel__empty" role="status">
|
||||
No token usage recorded yet. Token totals appear here once agents run.
|
||||
{t("agents.noTokenUsageYet", "No token usage recorded yet. Token totals appear here once agents run.")}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import type { Agent, AgentStats } from "../api";
|
||||
@@ -23,6 +24,10 @@ export function AgentsOverviewBar({
|
||||
onSelectAgent,
|
||||
onOpenTaskLogs,
|
||||
}: AgentsOverviewBarProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const activeCount = activeAgents.filter((a) => a.state === "active").length;
|
||||
const runningCount = activeAgents.filter((a) => a.state === "running").length;
|
||||
|
||||
return (
|
||||
<section className="agents-overview-bar" aria-label="Agents overview">
|
||||
<button
|
||||
@@ -33,10 +38,10 @@ export function AgentsOverviewBar({
|
||||
>
|
||||
<span className="agents-overview-bar__title-wrap">
|
||||
{isOpen ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronRight size={16} aria-hidden="true" />}
|
||||
<span className="agents-overview-bar__title">Overview</span>
|
||||
<span className="agents-overview-bar__title">{t("agents.overview", "Overview")}</span>
|
||||
</span>
|
||||
<span className="agents-overview-bar__meta text-secondary">
|
||||
{activeAgents.filter((a) => a.state === "active").length} active · {activeAgents.filter((a) => a.state === "running").length} running
|
||||
{t("agents.statusCount", "{{activeCount}} active · {{runningCount}} running", { activeCount, runningCount })}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./AgentsView.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, useLayoutEffect, lazy, Suspense, type CSSProperties, type ReactNode, type MutableRefObject, type RefObject, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal, ZoomIn, ZoomOut, Minimize2, Move, Info } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||
@@ -127,6 +128,7 @@ type OrgChartNodeProps = {
|
||||
};
|
||||
|
||||
function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, registerNodeElement, linksRef }: OrgChartNodeProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { agent, children } = node;
|
||||
const health = getHealthStatus(agent);
|
||||
const healthSummary = getHealthSummary(agent, health);
|
||||
@@ -166,7 +168,7 @@ function OrgChartNode({ node, onSelect, getHealthStatus, selectedAgentId, regist
|
||||
</div>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<div className="org-chart-children" role="group" aria-label={`${agent.name} employees`}>
|
||||
<div className="org-chart-children" role="group" aria-label={t("agents.orgChartEmployees", "{{name}} employees", { name: agent.name })}>
|
||||
{children.map((child) => {
|
||||
linksRef.current.push({ parentId: agent.id, childId: child.agent.id });
|
||||
return (
|
||||
@@ -262,6 +264,7 @@ function OrgChartConnectors({
|
||||
}
|
||||
|
||||
export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardingEnabled = false }: AgentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobileViewport = viewportMode === "mobile";
|
||||
@@ -379,9 +382,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
setIsSavingMultiplier(true);
|
||||
try {
|
||||
await updateSettings({ heartbeatMultiplier: clampedValue }, projectId);
|
||||
addToast(`Heartbeat speed set to ×${clampedValue.toFixed(1)}`, "success");
|
||||
addToast(t("agents.heartbeatSpeedSet", "Heartbeat speed set to ×{{value}}", { value: clampedValue.toFixed(1) }), "success");
|
||||
} catch (err) {
|
||||
addToast(`Failed to save heartbeat multiplier: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.heartbeatSpeedSaveFailed", "Failed to save heartbeat multiplier: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSavingMultiplier(false);
|
||||
@@ -472,7 +475,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
addToast(`Failed to load org chart: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.orgChartLoadFailed", "Failed to load org chart: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
setOrgTree([]);
|
||||
}
|
||||
})
|
||||
@@ -518,7 +521,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
if (cancelled) return;
|
||||
setBulkPauseEligibleCount(0);
|
||||
setBulkResumeEligibleCount(0);
|
||||
addToast(`Failed to load bulk agent actions: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.bulkActionsLoadFailed", "Failed to load bulk agent actions: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
@@ -567,13 +570,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const skippedCount = nonEphemeralAgents.length - eligibleAgents.length;
|
||||
|
||||
if (eligibleAgents.length === 0) {
|
||||
addToast(`No agents eligible to ${targetState === "paused" ? "pause" : "resume"}`, "error");
|
||||
addToast(
|
||||
targetState === "paused"
|
||||
? t("agents.noAgentsToPause", "No agents eligible to pause")
|
||||
: t("agents.noAgentsToResume", "No agents eligible to resume"),
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: targetState === "paused" ? "Pause All Agents" : "Resume All Agents",
|
||||
message: `${targetState === "paused" ? "Pause" : "Resume"} ${eligibleAgents.length} agent${eligibleAgents.length === 1 ? "" : "s"} in this project?`,
|
||||
title: targetState === "paused" ? t("agents.pauseAllTitle", "Pause All Agents") : t("agents.resumeAllTitle", "Resume All Agents"),
|
||||
message: targetState === "paused"
|
||||
? t("agents.pauseAllConfirm", { count: eligibleAgents.length, defaultValue_one: "Pause {{count}} agent in this project?", defaultValue_other: "Pause {{count}} agents in this project?" })
|
||||
: t("agents.resumeAllConfirm", { count: eligibleAgents.length, defaultValue_one: "Resume {{count}} agent in this project?", defaultValue_other: "Resume {{count}} agents in this project?" }),
|
||||
danger: targetState === "paused",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
@@ -586,7 +596,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
.filter((entry): entry is { result: PromiseRejectedResult; agent: Agent } => entry.result.status === "rejected");
|
||||
const successCount = results.length - failedResults.length;
|
||||
const failureCount = failedResults.length;
|
||||
const baseSummary = `${targetState === "paused" ? "Paused" : "Resumed"} ${successCount} agent${successCount === 1 ? "" : "s"}; skipped ${skippedCount}`;
|
||||
const baseSummary = targetState === "paused"
|
||||
? t("agents.pausedSummary", { count: successCount, skipped: skippedCount, defaultValue_one: "Paused {{count}} agent; skipped {{skipped}}", defaultValue_other: "Paused {{count}} agents; skipped {{skipped}}" })
|
||||
: t("agents.resumedSummary", { count: successCount, skipped: skippedCount, defaultValue_one: "Resumed {{count}} agent; skipped {{skipped}}", defaultValue_other: "Resumed {{count}} agents; skipped {{skipped}}" });
|
||||
|
||||
if (failureCount > 0) {
|
||||
const failureSummary = failedResults
|
||||
@@ -600,7 +612,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
await loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to ${targetState === "paused" ? "pause" : "resume"} agents: ${getErrorMessage(err)}`, "error");
|
||||
addToast(
|
||||
targetState === "paused"
|
||||
? t("agents.pauseAgentsFailed", "Failed to pause agents: {{error}}", { error: getErrorMessage(err) })
|
||||
: t("agents.resumeAgentsFailed", "Failed to resume agents: {{error}}", { error: getErrorMessage(err) }),
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setIsBulkActionRunning(false);
|
||||
}
|
||||
@@ -618,7 +635,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
addToast(`Agent state updated to ${newState}`, "success");
|
||||
addToast(t("agents.stateUpdated", "Agent state updated to {{state}}", { state: newState }), "success");
|
||||
await loadAgents();
|
||||
setOptimisticStateOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -631,7 +648,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.stateUpdateFailed", "Failed to update state: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setTransitioningAgentIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -643,17 +660,17 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
const handleDelete = async (agentId: string, agentName: string) => {
|
||||
const shouldDelete = await confirm({
|
||||
title: "Delete Agent",
|
||||
message: `Delete agent "${agentName}"? This cannot be undone.`,
|
||||
title: t("agents.deleteTitle", "Delete Agent"),
|
||||
message: t("agents.deleteConfirm", "Delete agent \"{{name}}\"? This cannot be undone.", { name: agentName }),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
try {
|
||||
await deleteAgent(agentId, projectId);
|
||||
addToast(`Agent "${agentName}" deleted`, "success");
|
||||
addToast(t("agents.deleted", "Agent \"{{name}}\" deleted", { name: agentName }), "success");
|
||||
await loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.deleteFailed", "Failed to delete agent: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -669,11 +686,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
try {
|
||||
await updateAgent(agentId, { role: newRole }, projectId);
|
||||
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
|
||||
addToast(t("agents.roleUpdated", "Agent role updated to {{role}}", { role: AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole }), "success");
|
||||
setEditingRoleForAgent(null);
|
||||
void loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to update role: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.roleUpdateFailed", "Failed to update role: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -706,10 +723,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(newIntervalMs)} for ${agent.name}`, "success");
|
||||
addToast(t("agents.heartbeatIntervalUpdated", "Heartbeat interval updated to {{interval}} for {{name}}", { interval: formatHeartbeatInterval(newIntervalMs), name: agent.name }), "success");
|
||||
void loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setUpdatingHeartbeatAgentId(null);
|
||||
}
|
||||
@@ -729,20 +746,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
// Validate: empty value
|
||||
if (inputValue.trim() === "") {
|
||||
addToast("Please enter a heartbeat interval in minutes", "error");
|
||||
addToast(t("agents.heartbeatEnterMinutes", "Please enter a heartbeat interval in minutes"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: non-numeric value
|
||||
const minutes = Number(inputValue);
|
||||
if (isNaN(minutes)) {
|
||||
addToast("Heartbeat interval must be a valid number", "error");
|
||||
addToast(t("agents.heartbeatMustBeNumber", "Heartbeat interval must be a valid number"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: zero or negative
|
||||
if (minutes <= 0) {
|
||||
addToast("Heartbeat interval must be greater than 0", "error");
|
||||
addToast(t("agents.heartbeatMustBePositive", "Heartbeat interval must be greater than 0"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -760,7 +777,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(`Heartbeat interval set to 5 minutes (minimum). ${minutes} minute${minutes !== 1 ? "s" : ""} was below the 5-minute minimum.`, "success");
|
||||
addToast(t("agents.heartbeatClampedToMin", { count: minutes, defaultValue_one: "Heartbeat interval set to 5 minutes (minimum). {{count}} minute was below the 5-minute minimum.", defaultValue_other: "Heartbeat interval set to 5 minutes (minimum). {{count}} minutes was below the 5-minute minimum." }), "success");
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -769,7 +786,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
});
|
||||
void loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setUpdatingHeartbeatAgentId(null);
|
||||
}
|
||||
@@ -790,7 +807,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
addToast(`Heartbeat interval updated to ${formatHeartbeatInterval(intervalMs)} for ${agent.name}`, "success");
|
||||
addToast(t("agents.heartbeatIntervalUpdated", "Heartbeat interval updated to {{interval}} for {{name}}", { interval: formatHeartbeatInterval(intervalMs), name: agent.name }), "success");
|
||||
setCustomHeartbeatAgentId(null);
|
||||
setCustomHeartbeatMinutes((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -799,7 +816,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
});
|
||||
void loadAgents();
|
||||
} catch (err) {
|
||||
addToast(`Failed to update heartbeat interval: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
} finally {
|
||||
setUpdatingHeartbeatAgentId(null);
|
||||
}
|
||||
@@ -867,7 +884,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
});
|
||||
try {
|
||||
await startAgentRun(agentId, projectId, { source: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
addToast(`Heartbeat run started for ${agentName}`, "success");
|
||||
addToast(t("agents.heartbeatRunStarted", "Heartbeat run started for {{name}}", { name: agentName }), "success");
|
||||
await loadAgents();
|
||||
setOptimisticStateOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -880,7 +897,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
addToast(`Failed to start heartbeat run: ${getErrorMessage(err)}`, "error");
|
||||
addToast(t("agents.heartbeatRunFailed", "Failed to start heartbeat run: {{error}}", { error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -981,20 +998,20 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
const renderOrgChartZoomControls = useCallback(() => (
|
||||
<>
|
||||
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }))} aria-label="Zoom out org chart" title="Zoom out">
|
||||
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 0.9 }))} aria-label={t("agents.orgChartZoomOut", "Zoom out org chart")} title={t("agents.zoomOut", "Zoom out")}>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartTransform.scale * 100)}%</span>
|
||||
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }))} aria-label="Zoom in org chart" title="Zoom in">
|
||||
<button type="button" className="btn-icon touch-target" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, scale: current.scale * 1.1 }))} aria-label={t("agents.orgChartZoomIn", "Zoom in org chart")} title={t("agents.zoomIn", "Zoom in")}>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button type="button" className="btn touch-target btn-sm agent-org-chart-controls__fit-btn" onClick={fitToViewport} aria-label="Fit org chart" title="Fit org chart">
|
||||
<button type="button" className="btn touch-target btn-sm agent-org-chart-controls__fit-btn" onClick={fitToViewport} aria-label={t("agents.orgChartFit", "Fit org chart")} title={t("agents.orgChartFit", "Fit org chart")}>
|
||||
<Minimize2 size={16} />
|
||||
Fit
|
||||
{t("agents.fit", "Fit")}
|
||||
</button>
|
||||
<button type="button" className="btn touch-target btn-sm" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, x: 0, y: 0 }))} aria-label="Center org chart" title="Center org chart">
|
||||
<button type="button" className="btn touch-target btn-sm" onClick={() => setOrgChartTransform((current) => clampTransform({ ...current, x: 0, y: 0 }))} aria-label={t("agents.orgChartCenter", "Center org chart")} title={t("agents.orgChartCenter", "Center org chart")}>
|
||||
<Move size={16} />
|
||||
Center
|
||||
{t("agents.center", "Center")}
|
||||
</button>
|
||||
</>
|
||||
), [clampTransform, fitToViewport, orgChartTransform.scale]);
|
||||
@@ -1074,9 +1091,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
const renderOrgChartLayoutToggle = useCallback(() => {
|
||||
const options: Array<{ value: OrgChartLayoutPreference; label: string; icon: ReactNode; ariaLabel: string }> = [
|
||||
{ value: "horizontal", label: "Horizontal", icon: <Network size={16} />, ariaLabel: "Horizontal layout" },
|
||||
{ value: "vertical", label: "Vertical", icon: <List size={16} />, ariaLabel: "Vertical layout" },
|
||||
{ value: "auto", label: "Auto", icon: <RefreshCw size={16} />, ariaLabel: "Automatic layout" },
|
||||
{ value: "horizontal", label: t("agents.layoutHorizontal", "Horizontal"), icon: <Network size={16} />, ariaLabel: t("agents.layoutHorizontalAria", "Horizontal layout") },
|
||||
{ value: "vertical", label: t("agents.layoutVertical", "Vertical"), icon: <List size={16} />, ariaLabel: t("agents.layoutVerticalAria", "Vertical layout") },
|
||||
{ value: "auto", label: t("agents.layoutAuto", "Auto"), icon: <RefreshCw size={16} />, ariaLabel: t("agents.layoutAutoAria", "Automatic layout") },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1129,15 +1146,15 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div className="agents-view-header">
|
||||
<div className="agents-view-title">
|
||||
<Bot size={24} />
|
||||
<h2>Agents</h2>
|
||||
<h2>{t("agents.title", "Agents")}</h2>
|
||||
</div>
|
||||
<div className="agents-view-controls">
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "list" ? " active" : ""}`}
|
||||
onClick={() => handleAgentViewChange("list")}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
title={t("agents.listView", "List view")}
|
||||
aria-label={t("agents.listView", "List view")}
|
||||
aria-pressed={agentView === "list"}
|
||||
>
|
||||
<List size={16} />
|
||||
@@ -1145,8 +1162,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "board" ? " active" : ""}`}
|
||||
onClick={() => handleAgentViewChange("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
title={t("agents.boardView", "Board view")}
|
||||
aria-label={t("agents.boardView", "Board view")}
|
||||
aria-pressed={agentView === "board"}
|
||||
>
|
||||
<Activity size={16} />
|
||||
@@ -1154,8 +1171,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
|
||||
onClick={() => handleAgentViewChange("org")}
|
||||
title="Org Chart view"
|
||||
aria-label="Org Chart view"
|
||||
title={t("agents.orgChartView", "Org Chart view")}
|
||||
aria-label={t("agents.orgChartView", "Org Chart view")}
|
||||
aria-pressed={agentView === "org"}
|
||||
>
|
||||
<Network size={16} />
|
||||
@@ -1166,8 +1183,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
ref={controlsTriggerRef}
|
||||
className={`btn-icon agent-controls-trigger${isControlsPanelOpen ? " agent-controls-trigger--active" : ""}`}
|
||||
onClick={() => setIsControlsPanelOpen((open) => !open)}
|
||||
title="Controls"
|
||||
aria-label="Controls"
|
||||
title={t("agents.controls", "Controls")}
|
||||
aria-label={t("agents.controls", "Controls")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isControlsPanelOpen}
|
||||
aria-controls={controlsPanelId}
|
||||
@@ -1177,8 +1194,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => void loadAgents()}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
title={t("agents.refresh", "Refresh")}
|
||||
aria-label={t("agents.refresh", "Refresh")}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : undefined} />
|
||||
</button>
|
||||
@@ -1190,11 +1207,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
setIsImporting(true);
|
||||
setIsControlsPanelOpen(false);
|
||||
}}
|
||||
aria-label="Import"
|
||||
title="Import"
|
||||
aria-label={t("agents.import", "Import")}
|
||||
title={t("agents.import", "Import")}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Import
|
||||
{t("agents.import", "Import")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-task-create btn-sm"
|
||||
@@ -1202,11 +1219,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
handleOpenNewAgent();
|
||||
setIsControlsPanelOpen(false);
|
||||
}}
|
||||
aria-label="New Agent"
|
||||
title="New Agent"
|
||||
aria-label={t("agents.newAgent", "New Agent")}
|
||||
title={t("agents.newAgent", "New Agent")}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Agent
|
||||
{t("agents.newAgent", "New Agent")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -1216,7 +1233,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
id={controlsPanelId}
|
||||
className="agent-controls-panel agent-controls-panel--scrollable"
|
||||
role="dialog"
|
||||
aria-label="Agent controls"
|
||||
aria-label={t("agents.agentControls", "Agent controls")}
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="agent-controls">
|
||||
@@ -1227,14 +1244,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
className="agent-state-filter-select"
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||
aria-label="Filter agents by state"
|
||||
aria-label={t("agents.filterByState", "Filter agents by state")}
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="all">{t("agents.stateAll", "All States")}</option>
|
||||
<option value="idle">{t("agents.stateIdle", "Idle")}</option>
|
||||
<option value="active">{t("agents.stateActive", "Active")}</option>
|
||||
<option value="running">{t("agents.stateRunning", "Running")}</option>
|
||||
<option value="paused">{t("agents.statePaused", "Paused")}</option>
|
||||
<option value="error">{t("agents.stateError", "Error")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1243,9 +1260,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
type="checkbox"
|
||||
checked={showSystemAgents}
|
||||
onChange={(e) => setShowSystemAgents(e.target.checked)}
|
||||
aria-label="Show system agents"
|
||||
aria-label={t("agents.showSystemAgents", "Show system agents")}
|
||||
/>
|
||||
Show system agents
|
||||
{t("agents.showSystemAgents", "Show system agents")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1258,11 +1275,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
setIsImporting(true);
|
||||
setIsControlsPanelOpen(false);
|
||||
}}
|
||||
aria-label="Import"
|
||||
title="Import"
|
||||
aria-label={t("agents.import", "Import")}
|
||||
title={t("agents.import", "Import")}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Import
|
||||
{t("agents.import", "Import")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-task-create btn-sm"
|
||||
@@ -1270,16 +1287,16 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
handleOpenNewAgent();
|
||||
setIsControlsPanelOpen(false);
|
||||
}}
|
||||
aria-label="New Agent"
|
||||
title="New Agent"
|
||||
aria-label={t("agents.newAgent", "New Agent")}
|
||||
title={t("agents.newAgent", "New Agent")}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Agent
|
||||
{t("agents.newAgent", "New Agent")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="agent-controls-bulk-actions" role="menu" aria-label="Bulk agent actions">
|
||||
<div className="agent-controls-bulk-actions" role="menu" aria-label={t("agents.bulkAgentActions", "Bulk agent actions")}>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-detail-bulk-menu-item"
|
||||
@@ -1292,14 +1309,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
<span className="agent-controls-bulk-actions__label">
|
||||
<Pause />
|
||||
<span>Pause All Agents</span>
|
||||
<span>{t("agents.pauseAllAgents", "Pause All Agents")}</span>
|
||||
</span>
|
||||
<span className="agent-detail-bulk-menu-item-hint">
|
||||
{isBulkEligibilityLoading
|
||||
? "Loading eligibility…"
|
||||
? t("agents.loadingEligibility", "Loading eligibility…")
|
||||
: bulkPauseEligibleCount === 0
|
||||
? "No active or running project agents to pause"
|
||||
: `Pause ${bulkPauseEligibleCount} active/running agent${bulkPauseEligibleCount === 1 ? "" : "s"}`}
|
||||
? t("agents.noAgentsToPauseHint", "No active or running project agents to pause")
|
||||
: t("agents.pauseCountHint", { count: bulkPauseEligibleCount, defaultValue_one: "Pause {{count}} active/running agent", defaultValue_other: "Pause {{count}} active/running agents" })}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -1314,14 +1331,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
<span className="agent-controls-bulk-actions__label">
|
||||
<Play />
|
||||
<span>Resume All Agents</span>
|
||||
<span>{t("agents.resumeAllAgents", "Resume All Agents")}</span>
|
||||
</span>
|
||||
<span className="agent-detail-bulk-menu-item-hint">
|
||||
{isBulkEligibilityLoading
|
||||
? "Loading eligibility…"
|
||||
? t("agents.loadingEligibility", "Loading eligibility…")
|
||||
: bulkResumeEligibleCount === 0
|
||||
? "No paused project agents to resume"
|
||||
: `Resume ${bulkResumeEligibleCount} paused agent${bulkResumeEligibleCount === 1 ? "" : "s"}`}
|
||||
? t("agents.noAgentsToResumeHint", "No paused project agents to resume")
|
||||
: t("agents.resumeCountHint", { count: bulkResumeEligibleCount, defaultValue_one: "Resume {{count}} paused agent", defaultValue_other: "Resume {{count}} paused agents" })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1330,7 +1347,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div className="heartbeat-multiplier-group">
|
||||
<div className="heartbeat-multiplier-controls">
|
||||
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
||||
Heartbeat Speed
|
||||
{t("agents.heartbeatSpeed", "Heartbeat Speed")}
|
||||
</label>
|
||||
<input
|
||||
id="globalHeartbeatMultiplier"
|
||||
@@ -1359,7 +1376,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||
}}
|
||||
disabled={isSavingMultiplier}
|
||||
aria-label="Heartbeat speed preset"
|
||||
aria-label={t("agents.heartbeatSpeedPreset", "Heartbeat speed preset")}
|
||||
>
|
||||
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
||||
<option key={multiplier} value={String(multiplier)}>
|
||||
@@ -1369,7 +1386,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
</select>
|
||||
</div>
|
||||
<small className="text-secondary">
|
||||
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
||||
{t("agents.heartbeatSpeedHint", "Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1421,9 +1438,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
type="button"
|
||||
className="btn btn-sm agents-org-detail-back"
|
||||
onClick={handleCloseDetail}
|
||||
aria-label="Back to org chart"
|
||||
aria-label={t("agents.backToOrgChart", "Back to org chart")}
|
||||
>
|
||||
Back to org chart
|
||||
{t("agents.backToOrgChart", "Back to org chart")}
|
||||
</button>
|
||||
<Suspense fallback={null}>
|
||||
<AgentDetailView
|
||||
@@ -1444,7 +1461,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
) : showInitialAgentsLoading ? (
|
||||
<div className="agents-view-loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading agents...</span>
|
||||
<span>{t("agents.loadingAgents", "Loading agents...")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
|
||||
@@ -1467,7 +1484,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
data-testid="agent-org-chart-viewport"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Org chart canvas"
|
||||
aria-label={t("agents.orgChartCanvas", "Org chart canvas")}
|
||||
onWheel={handleOrgChartWheel}
|
||||
onPointerDown={handleOrgChartPointerDown}
|
||||
onPointerMove={handleOrgChartPointerMove}
|
||||
@@ -1490,7 +1507,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading org chart...</span>
|
||||
<span>{t("agents.loadingOrgChart", "Loading org chart...")}</span>
|
||||
</div>
|
||||
) : displayOrgTree.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||
@@ -1533,7 +1550,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{showInitialAgentsLoading ? (
|
||||
<div className="agents-view-loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading agents...</span>
|
||||
<span>{t("agents.loadingAgents", "Loading agents...")}</span>
|
||||
</div>
|
||||
) : agentView === "board" ? (
|
||||
<div className="agent-board">
|
||||
@@ -1566,7 +1583,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
|
||||
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
|
||||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||||
<span className="agent-board-badge badge agent-approval-badge" title="Pending approvals">
|
||||
<span className="agent-board-badge badge agent-approval-badge" title={t("agents.pendingApprovals", "Pending approvals")}>
|
||||
<span className="status-dot status-dot--pending" />
|
||||
{agent.pendingApprovalCount}
|
||||
</span>
|
||||
@@ -1627,7 +1644,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
openAgentDetail(agent.id);
|
||||
}
|
||||
}}
|
||||
aria-label={`Open details for ${agent.name}`}
|
||||
aria-label={t("agents.openDetails", "Open details for {{name}}", { name: agent.name })}
|
||||
>
|
||||
<div className="agent-card-header">
|
||||
<div className="agent-info">
|
||||
@@ -1654,7 +1671,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
e.stopPropagation();
|
||||
setEditingRoleForAgent(agent.id);
|
||||
}}
|
||||
title="Click to change role"
|
||||
title={t("agents.clickToChangeRole", "Click to change role")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -1686,7 +1703,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
{getRoleLabel(agent.role)}
|
||||
</span>
|
||||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||||
<span className="badge agent-approval-badge" title="Pending approvals">
|
||||
<span className="badge agent-approval-badge" title={t("agents.pendingApprovals", "Pending approvals")}>
|
||||
<span className="status-dot status-dot--pending" />
|
||||
{agent.pendingApprovalCount}
|
||||
</span>
|
||||
@@ -1724,12 +1741,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
) : null}
|
||||
{agent.taskId && (
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">Working on:</span>
|
||||
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
|
||||
<span className="badge">{agent.taskId}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-heartbeat-control">
|
||||
<span className="text-secondary">Heartbeat:</span>
|
||||
<span className="text-secondary">{t("agents.heartbeat", "Heartbeat:")}</span>
|
||||
{customHeartbeatAgentId === agent.id ? (
|
||||
// Custom input mode
|
||||
<>
|
||||
@@ -1756,16 +1773,16 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
}
|
||||
}}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
aria-label={`Custom heartbeat interval in minutes for ${agent.name}`}
|
||||
aria-label={t("agents.customHeartbeatAria", "Custom heartbeat interval in minutes for {{name}}", { name: agent.name })}
|
||||
/>
|
||||
<span className="text-secondary">min</span>
|
||||
<span className="text-secondary">{t("agents.minutesUnit", "min")}</span>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleCustomHeartbeatSave(agent)}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
title="Save custom interval"
|
||||
title={t("agents.saveCustomInterval", "Save custom interval")}
|
||||
>
|
||||
Save
|
||||
{t("agents.save", "Save")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
@@ -1778,9 +1795,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
});
|
||||
}}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
title="Cancel custom interval"
|
||||
title={t("agents.cancelCustomInterval", "Cancel custom interval")}
|
||||
>
|
||||
Cancel
|
||||
{t("agents.cancel", "Cancel")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -1798,7 +1815,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
}
|
||||
}}
|
||||
disabled={isUpdatingHeartbeat}
|
||||
aria-label={`Set heartbeat interval for ${agent.name}`}
|
||||
aria-label={t("agents.setHeartbeatAria", "Set heartbeat interval for {{name}}", { name: agent.name })}
|
||||
>
|
||||
{heartbeatOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -1807,12 +1824,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
))}
|
||||
{/* Only show "Custom..." if current value is a preset; if it's already custom, it's already in the list */}
|
||||
{HEARTBEAT_INTERVAL_PRESETS.some((p) => p.value === configuredIntervalMs) && (
|
||||
<option value="__custom__">Custom...</option>
|
||||
<option value="__custom__">{t("agents.customHeartbeatOption", "Custom...")}</option>
|
||||
)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{isUpdatingHeartbeat && <span className="agent-heartbeat-saving text-secondary">Saving…</span>}
|
||||
{isUpdatingHeartbeat && <span className="agent-heartbeat-saving text-secondary">{t("agents.saving", "Saving…")}</span>}
|
||||
{agent.lastHeartbeatAt && (() => {
|
||||
const lastAt = new Date(agent.lastHeartbeatAt);
|
||||
const nextAt = new Date(lastAt.getTime() + configuredIntervalMs);
|
||||
@@ -1820,11 +1837,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
return (
|
||||
<>
|
||||
<span className="agent-heartbeat-last text-secondary" title={lastAt.toLocaleString()}>
|
||||
Last: {lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
{t("agents.lastHeartbeat", "Last: {{time}}", { time: lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })}
|
||||
</span>
|
||||
{isTicking && (
|
||||
<span className="agent-heartbeat-next text-secondary" title={nextAt.toLocaleString()}>
|
||||
Next: {nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
{t("agents.nextHeartbeat", "Next: {{time}}", { time: nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -1840,9 +1857,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Activate"
|
||||
title={t("agents.activate", "Activate")}
|
||||
>
|
||||
<Play size={14} /> <span className="agent-card-action-label">Start</span>
|
||||
<Play size={14} /> <span className="agent-card-action-label">{t("agents.start", "Start")}</span>
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "active" && (
|
||||
@@ -1851,18 +1868,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Run Now"
|
||||
aria-label={`Run now for ${agent.name}`}
|
||||
title={t("agents.runNow", "Run Now")}
|
||||
aria-label={t("agents.runNowAria", "Run now for {{name}}", { name: agent.name })}
|
||||
>
|
||||
<Activity size={14} /> <span className="agent-card-action-label">Run Now</span>
|
||||
<Activity size={14} /> <span className="agent-card-action-label">{t("agents.runNow", "Run Now")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Pause"
|
||||
title={t("agents.pause", "Pause")}
|
||||
>
|
||||
<Pause size={14} /> <span className="agent-card-action-label">Pause</span>
|
||||
<Pause size={14} /> <span className="agent-card-action-label">{t("agents.pause", "Pause")}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -1871,9 +1888,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Resume"
|
||||
title={t("agents.resume", "Resume")}
|
||||
>
|
||||
<Play size={14} /> <span className="agent-card-action-label">Resume</span>
|
||||
<Play size={14} /> <span className="agent-card-action-label">{t("agents.resume", "Resume")}</span>
|
||||
</button>
|
||||
)}
|
||||
{agent.state === "running" && (
|
||||
@@ -1881,18 +1898,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => openAgentDetail(agent.id, { initialTab: "runs", initialRunId: null, preferActiveRun: true })}
|
||||
title="View live run details"
|
||||
aria-label={`View live run details for ${agent.name}`}
|
||||
title={t("agents.viewLiveRun", "View live run details")}
|
||||
aria-label={t("agents.viewLiveRunAria", "View live run details for {{name}}", { name: agent.name })}
|
||||
>
|
||||
<Activity size={14} /> <span className="agent-card-action-label">Running</span>
|
||||
<Activity size={14} /> <span className="agent-card-action-label">{t("agents.running", "Running")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Pause"
|
||||
title={t("agents.pause", "Pause")}
|
||||
>
|
||||
<Pause size={14} /> <span className="agent-card-action-label">Pause</span>
|
||||
<Pause size={14} /> <span className="agent-card-action-label">{t("agents.pause", "Pause")}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -1901,9 +1918,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Retry"
|
||||
title={t("agents.retry", "Retry")}
|
||||
>
|
||||
<Play size={14} /> <span className="agent-card-action-label">Retry</span>
|
||||
<Play size={14} /> <span className="agent-card-action-label">{t("agents.retry", "Retry")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1911,18 +1928,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<button
|
||||
className="btn btn-sm agent-card-details-btn"
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
title={`View details for ${agent.name}`}
|
||||
aria-label={`View details for ${agent.name}`}
|
||||
title={t("agents.viewDetailsFor", "View details for {{name}}", { name: agent.name })}
|
||||
aria-label={t("agents.viewDetailsFor", "View details for {{name}}", { name: agent.name })}
|
||||
>
|
||||
<Info size={14} /> <span className="agent-card-action-label">Details</span>
|
||||
<Info size={14} /> <span className="agent-card-action-label">{t("agents.details", "Details")}</span>
|
||||
</button>
|
||||
{(agent.state === "idle" || agent.state === "paused") && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
title={t("agents.delete", "Delete")}
|
||||
>
|
||||
<Trash2 size={14} /> <span className="agent-card-action-label">Delete</span>
|
||||
<Trash2 size={14} /> <span className="agent-card-action-label">{t("agents.delete", "Delete")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1958,8 +1975,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
) : (
|
||||
<div className="agents-detail-empty-state">
|
||||
<Bot size={48} />
|
||||
<h3>Select an agent</h3>
|
||||
<p>Choose an agent from the sidebar to view details</p>
|
||||
<h3>{t("agents.selectAnAgent", "Select an agent")}</h3>
|
||||
<p>{t("agents.selectAgentHint", "Choose an agent from the sidebar to view details")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, Inbox, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./ApprovalNotificationBanner.css";
|
||||
|
||||
interface ApprovalNotificationBannerProps {
|
||||
@@ -12,22 +13,23 @@ export function ApprovalNotificationBanner({
|
||||
onOpenMailbox,
|
||||
onDismiss,
|
||||
}: ApprovalNotificationBannerProps) {
|
||||
const noun = pendingCount === 1 ? "request" : "requests";
|
||||
const { t } = useTranslation("app");
|
||||
const noun = pendingCount === 1 ? t("approval.requestSingular", "request") : t("approval.requestPlural", "requests");
|
||||
|
||||
return (
|
||||
<section className="approval-notification-banner" role="region" aria-live="polite" aria-label="Approval requests">
|
||||
<section className="approval-notification-banner" role="region" aria-live="polite" aria-label={t("approval.requests", "Approval requests")}>
|
||||
<div className="approval-notification-banner__content">
|
||||
<div className="approval-notification-banner__headline">
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<span>{pendingCount} approval {noun} need your attention</span>
|
||||
<span>{t("approval.needAttention", "{{count}} approval {{noun}} need your attention", { count: pendingCount, noun })}</span>
|
||||
</div>
|
||||
<div className="approval-notification-banner__actions">
|
||||
<button type="button" className="btn btn-sm" onClick={onOpenMailbox}>
|
||||
<Inbox aria-hidden="true" />
|
||||
<span>Open Mailbox</span>
|
||||
<span>{t("approval.openMailbox", "Open Mailbox")}</span>
|
||||
</button>
|
||||
<button type="button" className="btn-icon approval-notification-banner__dismiss" onClick={onDismiss} aria-label="Dismiss approval notification banner">
|
||||
<button type="button" className="btn-icon approval-notification-banner__dismiss" onClick={onDismiss} aria-label={t("approval.dismissBanner", "Dismiss approval notification banner")}>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./AuthTokenRecoveryDialog.css";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clearAuthToken, setAuthToken } from "../auth";
|
||||
|
||||
export interface AuthTokenRecoveryDialogProps {
|
||||
@@ -7,6 +8,7 @@ export interface AuthTokenRecoveryDialogProps {
|
||||
}
|
||||
|
||||
export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const tokenInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -51,17 +53,16 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
|
||||
aria-describedby="auth-token-recovery-description"
|
||||
>
|
||||
<div className="modal-header auth-token-recovery-header">
|
||||
<h3 id="auth-token-recovery-title">Authentication token required</h3>
|
||||
<h3 id="auth-token-recovery-title">{t("auth.tokenRequired", "Authentication token required")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="auth-token-recovery-content">
|
||||
<p id="auth-token-recovery-description">
|
||||
This dashboard session can't authenticate with the daemon. Set a replacement token or clear the
|
||||
current token and retry.
|
||||
{t("auth.tokenRecoveryDescription", "This dashboard session can't authenticate with the daemon. Set a replacement token or clear the current token and retry.")}
|
||||
</p>
|
||||
|
||||
<div className="auth-token-recovery-field">
|
||||
<label htmlFor="auth-token-recovery-input">Replacement token</label>
|
||||
<label htmlFor="auth-token-recovery-input">{t("auth.replacementToken", "Replacement token")}</label>
|
||||
<input
|
||||
ref={tokenInputRef}
|
||||
id="auth-token-recovery-input"
|
||||
@@ -69,7 +70,7 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(event) => setTokenInput(event.target.value)}
|
||||
placeholder="Paste token"
|
||||
placeholder={t("auth.pasteToken", "Paste token")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
@@ -82,7 +83,7 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
|
||||
className="btn"
|
||||
onClick={handleClearAndRetry}
|
||||
>
|
||||
Clear token and retry
|
||||
{t("auth.clearAndRetry", "Clear token and retry")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,7 +91,7 @@ export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps)
|
||||
onClick={handleSetToken}
|
||||
disabled={tokenInput.trim().length === 0}
|
||||
>
|
||||
Set token and reload
|
||||
{t("auth.setAndReload", "Set token and reload")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./BackendConnectionErrorPage.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BackendConnectionErrorPageProps {
|
||||
errorMessage: string;
|
||||
@@ -31,26 +32,27 @@ export function BackendConnectionErrorPage({
|
||||
onRetry,
|
||||
onManageConnection,
|
||||
}: BackendConnectionErrorPageProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const showChangeLaunchMode = isDesktopShell();
|
||||
return (
|
||||
<div className="project-overview-empty" role="alert" aria-live="polite">
|
||||
<h2>Can't reach the Fusion backend</h2>
|
||||
<h2>{t("backend.connectionError", "Can't reach the Fusion backend")}</h2>
|
||||
<p className="settings-muted">
|
||||
Fusion couldn't load your projects right now. Please make sure the backend is running and try again.
|
||||
{t("backend.couldNotLoad", "Fusion couldn't load your projects right now. Please make sure the backend is running and try again.")}
|
||||
</p>
|
||||
<p className="settings-muted">Error: {errorMessage}</p>
|
||||
<p className="settings-muted">{t("backend.error", "Error: {{error}}", { error: errorMessage })}</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={onRetry} disabled={isRetrying}>
|
||||
{isRetrying ? "Retrying…" : "Retry Connection"}
|
||||
{isRetrying ? t("backend.retrying", "Retrying…") : t("backend.retryConnection", "Retry Connection")}
|
||||
</button>
|
||||
{showChangeLaunchMode && (
|
||||
<button type="button" className="btn" onClick={() => void changeLaunchMode()}>
|
||||
Change Launch Mode…
|
||||
{t("backend.changeLaunchMode", "Change Launch Mode…")}
|
||||
</button>
|
||||
)}
|
||||
{onManageConnection && (
|
||||
<button type="button" className="btn" onClick={onManageConnection}>
|
||||
Manage Connection
|
||||
{t("backend.manageConnection", "Manage Connection")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./BackgroundTasksIndicator.css";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
@@ -22,14 +23,6 @@ const TYPE_ICONS = {
|
||||
slice_interview: Target,
|
||||
} as const;
|
||||
|
||||
const TYPE_LABELS = {
|
||||
planning: "Planning",
|
||||
subtask: "Subtask Breakdown",
|
||||
mission_interview: "Mission Interview",
|
||||
milestone_interview: "Milestone Interview",
|
||||
slice_interview: "Slice Interview",
|
||||
} as const;
|
||||
|
||||
export function BackgroundTasksIndicator({
|
||||
sessions,
|
||||
generating,
|
||||
@@ -37,6 +30,7 @@ export function BackgroundTasksIndicator({
|
||||
onOpenSession,
|
||||
onDismissSession,
|
||||
}: BackgroundTasksIndicatorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [recentlyUpdated, setRecentlyUpdated] = useState<Set<string>>(new Set());
|
||||
@@ -47,6 +41,18 @@ export function BackgroundTasksIndicator({
|
||||
const { activeTabMap } = useAiSessionSync();
|
||||
const localSessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
|
||||
// Type labels that are translatable
|
||||
const TYPE_LABELS = useMemo(
|
||||
() => ({
|
||||
planning: t("backgroundTasks.typeLabel.planning", "Planning"),
|
||||
subtask: t("backgroundTasks.typeLabel.subtask", "Subtask Breakdown"),
|
||||
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
|
||||
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
|
||||
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Close popover on outside click
|
||||
useEffect(() => {
|
||||
if (!popoverOpen) return;
|
||||
@@ -128,7 +134,7 @@ export function BackgroundTasksIndicator({
|
||||
{popoverOpen && (
|
||||
<div className="background-tasks-indicator__popover">
|
||||
<div className="background-tasks-indicator__popover-header">
|
||||
Background Tasks
|
||||
{t("backgroundTasks.popoverHeader", "Background Tasks")}
|
||||
</div>
|
||||
<div className="background-tasks-indicator__popover-list">
|
||||
{sessions.map((session) => {
|
||||
@@ -157,8 +163,8 @@ export function BackgroundTasksIndicator({
|
||||
onClick={async () => {
|
||||
if (activeElsewhere) {
|
||||
const shouldOpen = await confirm({
|
||||
title: "Open Active Session",
|
||||
message: "This session is active in another tab. Open anyway?",
|
||||
title: t("backgroundTasks.confirmTitle", "Open Active Session"),
|
||||
message: t("backgroundTasks.confirmMessage", "This session is active in another tab. Open anyway?"),
|
||||
});
|
||||
if (!shouldOpen) {
|
||||
return;
|
||||
@@ -179,10 +185,10 @@ export function BackgroundTasksIndicator({
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="background-tasks-indicator__session-meta">
|
||||
{isError ? "Failed" : TYPE_LABELS[session.type]}
|
||||
{isGenerating && " — generating..."}
|
||||
{isAwaiting && !activeElsewhere && " — needs input"}
|
||||
{isAwaiting && activeElsewhere && " — active in another tab"}
|
||||
{isError ? t("backgroundTasks.status.failed", "Failed") : TYPE_LABELS[session.type]}
|
||||
{isGenerating && ` — ${t("backgroundTasks.status.generating", "generating...")}`}
|
||||
{isAwaiting && !activeElsewhere && ` — ${t("backgroundTasks.status.needsInput", "needs input")}`}
|
||||
{isAwaiting && activeElsewhere && ` — ${t("backgroundTasks.status.activeElsewhere", "active in another tab")}`}
|
||||
</div>
|
||||
</div>
|
||||
{isGenerating && (
|
||||
@@ -212,7 +218,7 @@ export function BackgroundTasksIndicator({
|
||||
e.stopPropagation();
|
||||
onDismissSession(session.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
title={t("backgroundTasks.dismissButton", "Dismiss")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./BranchGroupCard.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
|
||||
import type { BranchGroupSummary } from "../api";
|
||||
import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
|
||||
@@ -11,6 +12,7 @@ interface BranchGroupCardProps {
|
||||
}
|
||||
|
||||
export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -23,12 +25,12 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
setGroup(response.group);
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
const message = loadError instanceof Error ? loadError.message : "Failed to load branch group";
|
||||
const message = loadError instanceof Error ? loadError.message : t("branchGroup.loadError", "Failed to load branch group");
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupId, projectId]);
|
||||
}, [groupId, projectId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -74,8 +76,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
|
||||
const completionText = useMemo(() => {
|
||||
if (!group) return "";
|
||||
return `${group.completion.landed} of ${group.completion.total} members finished`;
|
||||
}, [group]);
|
||||
return t("branchGroup.completionText", "{{landed}} of {{total}} members finished", {
|
||||
landed: group.completion.landed,
|
||||
total: group.completion.total,
|
||||
});
|
||||
}, [group, t]);
|
||||
|
||||
const onPromote = useCallback(async () => {
|
||||
setPromoting(true);
|
||||
@@ -88,11 +93,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
}, [groupId, loadGroup, projectId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> Loading branch group…</div>;
|
||||
return <div className="card branch-group-card"><Loader2 className="spin" size={14} /> {t("branchGroup.loading", "Loading branch group…")}</div>;
|
||||
}
|
||||
|
||||
if (error || !group) {
|
||||
return <div className="card branch-group-card branch-group-card-error">{error ?? "Branch group unavailable"}</div>;
|
||||
return <div className="card branch-group-card branch-group-card-error">{error ?? t("branchGroup.unavailable", "Branch group unavailable")}</div>;
|
||||
}
|
||||
|
||||
const completionPercent = group.completion.total > 0
|
||||
@@ -108,13 +113,13 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
<strong>{group.branchName}</strong>
|
||||
</div>
|
||||
<div className="branch-group-card-header-meta">
|
||||
<span className="badge branch-group-card-badge">Group {group.id}</span>
|
||||
<span className="badge branch-group-card-badge">{t("branchGroup.groupLabel", "Group {{id}}", { id: group.id })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? "Expand branch group" : "Collapse branch group"}
|
||||
aria-label={collapsed ? t("branchGroup.expandLabel", "Expand branch group") : t("branchGroup.collapseLabel", "Collapse branch group")}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
@@ -146,11 +151,11 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
</a>
|
||||
)}
|
||||
{group.autoMerge ? (
|
||||
<span className="badge">Auto-merge enabled</span>
|
||||
<span className="badge">{t("branchGroup.autoMergeEnabled", "Auto-merge enabled")}</span>
|
||||
) : (
|
||||
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
|
||||
{promoting ? <Loader2 size={14} className="spin" /> : <GitPullRequest size={14} />}
|
||||
{group.prState === "none" ? "Open PR" : "Merge group into main"}
|
||||
{group.prState === "none" ? t("branchGroup.openPr", "Open PR") : t("branchGroup.mergeIntoMain", "Merge group into main")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CapacityRiskSignal } from "@fusion/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import "./CapacityRiskBanner.css";
|
||||
|
||||
@@ -8,6 +9,7 @@ interface CapacityRiskBannerProps {
|
||||
}
|
||||
|
||||
export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (!signal || !signal.atRisk) {
|
||||
return null;
|
||||
}
|
||||
@@ -15,13 +17,13 @@ export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProp
|
||||
return (
|
||||
<div className={`capacity-risk-banner${onDismiss ? " capacity-risk-banner--dismissible" : ""}`} role="status" aria-live="polite">
|
||||
<div className="capacity-risk-banner__content">
|
||||
<strong>Capacity risk:</strong> Todo {signal.todoCount} (threshold {signal.threshold}) · In Progress {signal.inProgressCount} · In Review {signal.inReviewCount} · Idle agents {signal.idleNonEphemeralAgentCount}
|
||||
<strong>{t("capacity.risk", "Capacity risk:")}</strong> {t("capacity.status", "Todo {{todoCount}} (threshold {{threshold}}) · In Progress {{inProgress}} · In Review {{inReview}} · Idle agents {{idleAgents}}", { todoCount: signal.todoCount, threshold: signal.threshold, inProgress: signal.inProgressCount, inReview: signal.inReviewCount, idleAgents: signal.idleNonEphemeralAgentCount })}
|
||||
</div>
|
||||
{onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
className="capacity-risk-banner__dismiss touch-target"
|
||||
aria-label="Dismiss capacity warning"
|
||||
aria-label={t("capacity.dismiss", "Dismiss capacity warning")}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import {
|
||||
@@ -66,6 +67,7 @@ export function ChangesDiffModal({
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: ChangesDiffModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [wordWrap, setWordWrap] = useState(true);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -128,7 +130,7 @@ export function ChangesDiffModal({
|
||||
<div className="modal-header changes-diff-modal-header">
|
||||
<div className="changes-diff-header-title">
|
||||
<FileCode size={18} />
|
||||
<span>Changes — {taskId}</span>
|
||||
<span>{t("changes.title", "Changes")} — {taskId}</span>
|
||||
<span className="changes-stat-summary">
|
||||
<span className="diff-add">+{stats.additions}</span>{" "}
|
||||
<span className="diff-del">-{stats.deletions}</span>
|
||||
@@ -141,8 +143,8 @@ export function ChangesDiffModal({
|
||||
className="btn btn-sm btn-icon"
|
||||
onClick={navigatePrev}
|
||||
disabled={selectedIndex === null || selectedIndex <= 0}
|
||||
title="Previous file (Ctrl+↑)"
|
||||
aria-label="Previous file"
|
||||
title={t("changes.previousFile", "Previous file (Ctrl+↑)")}
|
||||
aria-label={t("changes.previousFileAria", "Previous file")}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
@@ -157,8 +159,8 @@ export function ChangesDiffModal({
|
||||
disabled={
|
||||
selectedIndex === null || selectedIndex >= files.length - 1
|
||||
}
|
||||
title="Next file (Ctrl+↓)"
|
||||
aria-label="Next file"
|
||||
title={t("changes.nextFile", "Next file (Ctrl+↓)")}
|
||||
aria-label={t("changes.nextFileAria", "Next file")}
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
@@ -167,18 +169,18 @@ export function ChangesDiffModal({
|
||||
<button
|
||||
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
|
||||
onClick={() => setWordWrap((prev) => !prev)}
|
||||
title={wordWrap ? "Disable word wrap" : "Enable word wrap"}
|
||||
aria-label="Toggle word wrap"
|
||||
title={wordWrap ? t("changes.disableWrap", "Disable word wrap") : t("changes.enableWrap", "Enable word wrap")}
|
||||
aria-label={t("changes.toggleWrap", "Toggle word wrap")}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</button>
|
||||
{onRefresh && (
|
||||
<button className="btn btn-sm" onClick={onRefresh}>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
{t("actions.refresh", "Refresh")}
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -204,7 +206,7 @@ export function ChangesDiffModal({
|
||||
)}
|
||||
{mergeDetails.mergedAt && (
|
||||
<div className="commit-diff-timestamp">
|
||||
Merged {new Date(mergeDetails.mergedAt).toLocaleString()}
|
||||
{t("changes.merged", "Merged {{date}}", { date: new Date(mergeDetails.mergedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -255,14 +257,14 @@ export function ChangesDiffModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="changes-diff-empty">
|
||||
No diff available for this file.
|
||||
{t("changes.noDiff", "No diff available for this file.")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="changes-diff-empty">
|
||||
<FileCode size={48} opacity={0.3} />
|
||||
<p>Select a file to view its diff</p>
|
||||
<p>{t("changes.selectFile", "Select a file to view its diff")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -207,7 +208,7 @@ function buildFailureReferenceHref(reference: FailureInfo["reference"]): string
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderFailureReference(reference: FailureInfo["reference"]): ReactNode {
|
||||
function renderFailureReference(reference: FailureInfo["reference"], t: (key: string, defaultValue: string) => string): ReactNode {
|
||||
if (!reference) {
|
||||
return null;
|
||||
}
|
||||
@@ -220,27 +221,27 @@ function renderFailureReference(reference: FailureInfo["reference"]): ReactNode
|
||||
|
||||
return (
|
||||
<div className="chat-message-failure-reference">
|
||||
<span className="chat-message-failure-reference-label">Reference</span>
|
||||
<span className="chat-message-failure-reference-label">{t("chat.failureReferenceLabel", "Reference")}</span>
|
||||
<span className="chat-message-failure-reference-value">{referenceLabel}</span>
|
||||
{referenceHref ? (
|
||||
<a className="btn btn-sm chat-message-failure-reference-link" href={referenceHref}>
|
||||
Open mailbox message
|
||||
{t("chat.openMailboxMessage", "Open mailbox message")}
|
||||
</a>
|
||||
) : (
|
||||
<details className="chat-message-failure-reference-details">
|
||||
<summary className="btn btn-sm chat-message-failure-reference-link">View failure details</summary>
|
||||
<summary className="btn btn-sm chat-message-failure-reference-link">{t("chat.viewFailureDetails", "View failure details")}</summary>
|
||||
<dl className="chat-message-failure-reference-meta" id={referenceDetailsId}>
|
||||
<div>
|
||||
<dt>Kind</dt>
|
||||
<dt>{t("chat.failureReferenceKind", "Kind")}</dt>
|
||||
<dd>{reference.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dt>{t("chat.failureReferenceId", "ID")}</dt>
|
||||
<dd>{reference.id}</dd>
|
||||
</div>
|
||||
{reference.label && (
|
||||
<div>
|
||||
<dt>Label</dt>
|
||||
<dt>{t("chat.failureReferenceMetaLabel", "Label")}</dt>
|
||||
<dd>{reference.label}</dd>
|
||||
</div>
|
||||
)}
|
||||
@@ -251,7 +252,7 @@ function renderFailureReference(reference: FailureInfo["reference"]): ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
function renderToolCalls(toolCalls: ToolCallInfo[] | undefined, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): ReactNode {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
||||
@@ -262,11 +263,11 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
const summaryPreview = isRunning
|
||||
? argsSummary
|
||||
: resultSummary
|
||||
? `result: ${resultSummary}`
|
||||
? `${t("chat.toolCallResultPrefix", "result")}: ${resultSummary}`
|
||||
: argsSummary
|
||||
? `args: ${argsSummary}`
|
||||
? `${t("chat.toolCallArgsPrefix", "args")}: ${argsSummary}`
|
||||
: null;
|
||||
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
|
||||
const statusLabel = isRunning ? t("chat.toolCallStatusRunning", "running") : isError ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusCompleted", "completed");
|
||||
|
||||
return (
|
||||
<details
|
||||
@@ -287,13 +288,13 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
<div className="chat-tool-call-content">
|
||||
{argsSummary && (
|
||||
<div className="chat-tool-call-row">
|
||||
<span className="chat-tool-call-label">args</span>
|
||||
<span className="chat-tool-call-label">{t("chat.toolCallArgsPrefix", "args")}</span>
|
||||
<span className="chat-tool-call-value">{argsSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
{resultSummary && (
|
||||
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
||||
<span className="chat-tool-call-label">result</span>
|
||||
<span className="chat-tool-call-label">{t("chat.toolCallResultPrefix", "result")}</span>
|
||||
<span className="chat-tool-call-value">{resultSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -308,7 +309,7 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
<div className={className} data-testid="chat-tool-calls">
|
||||
<div className="chat-tool-calls-header">
|
||||
<Wrench size={12} aria-hidden="true" />
|
||||
<span>Tool calls</span>
|
||||
<span>{t("chat.toolCallsHeader", "Tool calls")}</span>
|
||||
</div>
|
||||
{renderToolCallItem(toolCalls[0], 0)}
|
||||
</div>
|
||||
@@ -325,9 +326,9 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
? `${visibleNames.join(", ")}, +${overflowCount} more`
|
||||
: visibleNames.join(", ");
|
||||
const statusSummary = hasRunning
|
||||
? `(${runningCount} running)`
|
||||
? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})`
|
||||
: errorCount > 0
|
||||
? `(${errorCount} ${errorCount === 1 ? "error" : "errors"})`
|
||||
? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})`
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -335,7 +336,7 @@ function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}>
|
||||
<summary className="chat-tool-calls-group-summary">
|
||||
<Wrench size={12} aria-hidden="true" />
|
||||
<span className="chat-tool-calls-count">{toolCalls.length} tool calls</span>
|
||||
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })}</span>
|
||||
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
|
||||
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
||||
</summary>
|
||||
@@ -525,6 +526,7 @@ interface NewChatDialogProps {
|
||||
}
|
||||
|
||||
function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDialogProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
|
||||
const { agents, loading: agentsLoading } = useAgentsMapCache(projectId);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
@@ -611,7 +613,7 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
|
||||
return (
|
||||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={onClose} role="dialog" aria-modal="true">
|
||||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>New Chat</h3>
|
||||
<h3>{t("chat.newChatTitle", "New Chat")}</h3>
|
||||
<div className="chat-new-dialog-mode-toggle" data-testid="chat-new-dialog-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
@@ -621,7 +623,7 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
|
||||
setChatMode("agent");
|
||||
}}
|
||||
>
|
||||
Agent
|
||||
{t("chat.newChatModeAgent", "Agent")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -633,17 +635,17 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
|
||||
setSelectedModel((current) => current || defaultModelValue);
|
||||
}}
|
||||
>
|
||||
Model
|
||||
{t("chat.newChatModeModel", "Model")}
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{chatMode === "agent" && (
|
||||
<label className="chat-new-dialog-model-label">
|
||||
Agent
|
||||
{t("chat.newChatModeAgent", "Agent")}
|
||||
{agentsLoading ? (
|
||||
<div className="chat-new-dialog-loading">Loading agents...</div>
|
||||
<div className="chat-new-dialog-loading">{t("chat.loadingAgents", "Loading agents...")}</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="chat-new-dialog-empty">No agents available</div>
|
||||
<div className="chat-new-dialog-empty">{t("chat.noAgentsAvailable", "No agents available")}</div>
|
||||
) : (
|
||||
<div className="chat-new-dialog-agent-list">
|
||||
{agents.map((agent) => (
|
||||
@@ -666,14 +668,14 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
|
||||
{chatMode === "model" && (
|
||||
<div className="chat-new-dialog-model-dropdown" data-testid="chat-new-dialog-model-section">
|
||||
{modelsLoading ? (
|
||||
<div className="chat-new-dialog-loading">Loading models...</div>
|
||||
<div className="chat-new-dialog-loading">{t("chat.loadingModels", "Loading models...")}</div>
|
||||
) : (
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
label="Model"
|
||||
placeholder="Select a model"
|
||||
label={t("chat.newChatModeModel", "Model")}
|
||||
placeholder={t("chat.selectModel", "Select a model")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
@@ -684,14 +686,14 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi
|
||||
)}
|
||||
<div className="chat-new-dialog-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
{t("chat.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
Create
|
||||
{t("chat.create", "Create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -753,6 +755,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
copyAction,
|
||||
onScrollToTop,
|
||||
}: ChatMessageItemProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const isAssistantMessage = message.role === "assistant";
|
||||
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
|
||||
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
|
||||
@@ -848,7 +851,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<div className="chat-message-content chat-message-content--failure">
|
||||
<div className="chat-message-failure-summary-row">
|
||||
<span className="status-dot status-dot--error" aria-hidden="true" />
|
||||
<span className="chat-message-failure-label">Response failed</span>
|
||||
<span className="chat-message-failure-label">{t("chat.responseFailed", "Response failed")}</span>
|
||||
</div>
|
||||
<div className="chat-message-failure-summary">{failureInfo.summary}</div>
|
||||
{(failureInfo.errorClass || failureInfo.code) && (
|
||||
@@ -861,10 +864,10 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<details className="chat-message-failure-details">
|
||||
<summary>
|
||||
<TriangleAlert size={14} aria-hidden="true" />
|
||||
<span>Failure details</span>
|
||||
<span>{t("chat.failureDetails", "Failure details")}</span>
|
||||
</summary>
|
||||
{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}
|
||||
{renderFailureReference(failureInfo.reference)}
|
||||
{renderFailureReference(failureInfo.reference, t)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
@@ -905,7 +908,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-message-scroll-to-top-action"
|
||||
aria-label="Scroll message to top"
|
||||
aria-label={t("chat.scrollMessageToTop", "Scroll message to top")}
|
||||
data-testid={`chat-message-scroll-to-top-${message.id}`}
|
||||
onClick={() => onScrollToTop(message.id)}
|
||||
>
|
||||
@@ -914,10 +917,10 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderToolCalls(message.toolCalls)}
|
||||
{renderToolCalls(message.toolCalls, t)}
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre>
|
||||
</details>
|
||||
)}
|
||||
@@ -928,6 +931,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
});
|
||||
|
||||
export function ChatView({ projectId, addToast, experimentalFeatures }: ChatViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
useEffect(() => {
|
||||
recordResumeEvent({
|
||||
view: "ChatView",
|
||||
@@ -1814,7 +1818,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
// On mobile, hide sidebar after selecting
|
||||
if (isMobile) setSidebarVisible(false);
|
||||
} catch {
|
||||
addToast("Failed to create chat session", "error");
|
||||
addToast(t("chat.failedToCreateSession", "Failed to create chat session"), "error");
|
||||
}
|
||||
},
|
||||
[createSession, addToast, isMobile],
|
||||
@@ -1883,7 +1887,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
modelProvider: activeSession.modelProvider ?? undefined,
|
||||
modelId: activeSession.modelId ?? undefined,
|
||||
}).catch(() => {
|
||||
addToast("Failed to clear conversation", "error");
|
||||
addToast(t("chat.failedToClearConversation", "Failed to clear conversation"), "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1919,7 +1923,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
try {
|
||||
await rooms.clearRoom(rooms.activeRoom.id);
|
||||
} catch {
|
||||
addToast("Failed to clear room conversation", "error");
|
||||
addToast(t("chat.failedToClearRoomConversation", "Failed to clear room conversation"), "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1938,15 +1942,15 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
if (error instanceof RoomMessageDeliveredButReplyFailedError) {
|
||||
const message = error.message.trim()
|
||||
? error.message
|
||||
: "Message sent, but assistant reply failed";
|
||||
addToast(`Message sent, but assistant reply failed: ${message}`, "error");
|
||||
: t("chat.messageSentButReplyFailed", "Message sent, but assistant reply failed");
|
||||
addToast(t("chat.messageSentButReplyFailedDetail", "Message sent, but assistant reply failed: {{detail}}", { detail: message }), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMessageInput(previousInput);
|
||||
const message = error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Failed to send room message";
|
||||
: t("chat.failedToSendRoomMessage", "Failed to send room message");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
roomSendInFlightRef.current = false;
|
||||
@@ -2292,9 +2296,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
setContextMenu(null);
|
||||
try {
|
||||
await archiveSession(id);
|
||||
addToast("Conversation archived", "success");
|
||||
addToast(t("chat.conversationArchived", "Conversation archived"), "success");
|
||||
} catch {
|
||||
addToast("Failed to archive conversation", "error");
|
||||
addToast(t("chat.failedToArchiveConversation", "Failed to archive conversation"), "error");
|
||||
}
|
||||
},
|
||||
[archiveSession, addToast],
|
||||
@@ -2307,9 +2311,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
setContextMenu(null);
|
||||
try {
|
||||
await deleteSession(id);
|
||||
addToast("Conversation deleted", "success");
|
||||
addToast(t("chat.conversationDeleted", "Conversation deleted"), "success");
|
||||
} catch {
|
||||
addToast("Failed to delete conversation", "error");
|
||||
addToast(t("chat.failedToDeleteConversation", "Failed to delete conversation"), "error");
|
||||
}
|
||||
},
|
||||
[deleteSession, addToast],
|
||||
@@ -2413,10 +2417,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
return (
|
||||
<div className="chat-empty-state">
|
||||
<MessageSquare size={48} strokeWidth={1.5} />
|
||||
<h2>Start a new conversation</h2>
|
||||
<h2>{t("chat.startNewConversation", "Start a new conversation")}</h2>
|
||||
<button className="btn btn-primary" onClick={() => setShowNewDialog(true)}>
|
||||
<Plus size={16} />
|
||||
New Chat
|
||||
{t("chat.newChat", "New Chat")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -2597,7 +2601,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className={`btn-icon chat-message-copy-action${copyFeedbackByMessageId[messageId] === "success" ? " chat-message-copy-action--success" : ""}${copyFeedbackByMessageId[messageId] === "error" ? " chat-message-copy-action--error" : ""}`}
|
||||
data-testid={testId ?? `chat-copy-response-${messageId}`}
|
||||
aria-label={copyFeedbackByMessageId[messageId] === "success" ? "Response copied" : copyFeedbackByMessageId[messageId] === "error" ? "Copy failed" : "Copy response"}
|
||||
aria-label={copyFeedbackByMessageId[messageId] === "success" ? t("chat.responseCopied", "Response copied") : copyFeedbackByMessageId[messageId] === "error" ? t("chat.copyFailed", "Copy failed") : t("chat.copyResponse", "Copy response")}
|
||||
onClick={() => {
|
||||
void handleCopyResponse(messageId, content);
|
||||
}}
|
||||
@@ -2635,7 +2639,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid="chat-sidebar-scope-direct"
|
||||
onClick={() => setChatScope("direct")}
|
||||
>
|
||||
Direct
|
||||
{t("chat.scopeDirect", "Direct")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2645,7 +2649,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid="chat-sidebar-scope-rooms"
|
||||
onClick={() => setChatScope("rooms")}
|
||||
>
|
||||
Rooms
|
||||
{t("chat.scopeRooms", "Rooms")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2658,7 +2662,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<input
|
||||
type="text"
|
||||
className="chat-sidebar-search"
|
||||
placeholder="Search conversations..."
|
||||
placeholder={t("chat.searchConversations", "Search conversations...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
data-testid="chat-search-input"
|
||||
@@ -2668,9 +2672,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
{/* Session list section */}
|
||||
<div className="chat-session-list chat-sidebar-list">
|
||||
{sessionsLoading ? (
|
||||
<div className="chat-empty-state chat-empty-state--padded">Loading...</div>
|
||||
<div className="chat-empty-state chat-empty-state--padded">{t("chat.loadingConversations", "Loading...")}</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="chat-empty-state chat-empty-state--padded">No conversations yet</div>
|
||||
<div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div>
|
||||
) : (
|
||||
filteredSessions.map((session) => {
|
||||
const isActive = activeSession?.id === session.id;
|
||||
@@ -2700,22 +2704,22 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
setConfirmDelete(session.id);
|
||||
}}
|
||||
data-testid="chat-session-delete-btn"
|
||||
aria-label="Delete conversation"
|
||||
aria-label={t("chat.deleteConversation", "Delete conversation")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="chat-session-title">
|
||||
{session.title || "Untitled"}
|
||||
{session.title || t("chat.untitledSession", "Untitled")}
|
||||
{showUnreadDot ? (
|
||||
<span
|
||||
className="chat-unread-dot"
|
||||
data-testid={`chat-unread-dot-${session.id}`}
|
||||
aria-label="Unread messages"
|
||||
aria-label={t("chat.unreadMessages", "Unread messages")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-session-preview">
|
||||
{session.lastMessagePreview || "No messages"}
|
||||
{session.lastMessagePreview || t("chat.noMessages", "No messages")}
|
||||
</div>
|
||||
<div className="chat-session-meta">
|
||||
<span className="chat-session-meta-model">
|
||||
@@ -2744,13 +2748,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
onClick={() => setCreateRoomOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create room
|
||||
{t("chat.createRoom", "Create room")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{rooms.rooms.length === 0 ? (
|
||||
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
|
||||
No rooms yet.
|
||||
{t("chat.noRoomsYet", "No rooms yet.")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-session-list chat-sidebar-list">
|
||||
@@ -2789,13 +2793,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<span
|
||||
className="chat-unread-dot"
|
||||
data-testid={`chat-unread-dot-${room.id}`}
|
||||
aria-label="Unread messages"
|
||||
aria-label={t("chat.unreadMessages", "Unread messages")}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="chat-room-item-meta">
|
||||
{rooms.activeRoomMembers.length} {rooms.activeRoomMembers.length === 1 ? "member" : "members"}
|
||||
{t("chat.roomMemberCount", "{{count}} member", { count: rooms.activeRoomMembers.length, defaultValue_one: "{{count}} member", defaultValue_other: "{{count}} members" })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -2803,7 +2807,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className="btn-icon chat-room-item-delete"
|
||||
data-testid={`chat-room-delete-${room.slug}`}
|
||||
aria-label={`Delete room ${room.name}`}
|
||||
aria-label={t("chat.deleteRoom", "Delete room {{name}}", { name: room.name })}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setConfirmDeleteRoomId(room.id);
|
||||
@@ -2828,7 +2832,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
onClick={() => setCreateRoomOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create room
|
||||
{t("chat.createRoom", "Create room")}
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
@@ -2840,7 +2844,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid="chat-new-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Chat
|
||||
{t("chat.newChat", "New Chat")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2854,7 +2858,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
aria-valuemin={CHAT_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={CHAT_SIDEBAR_MAX_WIDTH}
|
||||
aria-valuenow={sidebarWidth}
|
||||
aria-label="Resize chat sidebar"
|
||||
aria-label={t("chat.resizeSidebar", "Resize chat sidebar")}
|
||||
tabIndex={0}
|
||||
onPointerDown={handleResizeStart}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
@@ -2873,7 +2877,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid="chat-context-archive"
|
||||
>
|
||||
<Archive size={14} />
|
||||
Archive
|
||||
{t("chat.archive", "Archive")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -2883,7 +2887,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid="chat-context-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
{t("chat.delete", "Delete")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2892,19 +2896,19 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
{confirmDelete && (
|
||||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}>
|
||||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Delete Conversation?</h3>
|
||||
<h3>{t("chat.deleteConversationTitle", "Delete Conversation?")}</h3>
|
||||
<p className="chat-view-delete-dialog-copy">
|
||||
This action cannot be undone. All messages in this conversation will be permanently deleted.
|
||||
{t("chat.deleteConversationBody", "This action cannot be undone. All messages in this conversation will be permanently deleted.")}
|
||||
</p>
|
||||
<div className="chat-new-dialog-actions">
|
||||
<button className="btn btn-sm" onClick={() => setConfirmDelete(null)}>
|
||||
Cancel
|
||||
{t("chat.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => void handleDelete(confirmDelete)}
|
||||
>
|
||||
Delete
|
||||
{t("chat.delete", "Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2914,13 +2918,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
{chatRoomsEnabled && confirmDeleteRoomId && (
|
||||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteRoomId(null)}>
|
||||
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Delete Room?</h3>
|
||||
<h3>{t("chat.deleteRoomTitle", "Delete Room?")}</h3>
|
||||
<p className="chat-view-delete-dialog-copy">
|
||||
This action cannot be undone. This room and all its messages will be permanently deleted.
|
||||
{t("chat.deleteRoomBody", "This action cannot be undone. This room and all its messages will be permanently deleted.")}
|
||||
</p>
|
||||
<div className="chat-new-dialog-actions">
|
||||
<button className="btn btn-sm" onClick={() => setConfirmDeleteRoomId(null)}>
|
||||
Cancel
|
||||
{t("chat.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
@@ -2930,12 +2934,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
await rooms.deleteRoom(confirmDeleteRoomId);
|
||||
setConfirmDeleteRoomId(null);
|
||||
} catch {
|
||||
addToast("Failed to delete room", "error");
|
||||
addToast(t("chat.failedToDeleteRoom", "Failed to delete room"), "error");
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{t("chat.delete", "Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3005,14 +3009,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</div>
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
{rooms.messagesLoading ? (
|
||||
<div className="chat-empty-state">Loading messages...</div>
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
) : rooms.messages.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL).length === 0 ? (
|
||||
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
rooms.messages
|
||||
.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL)
|
||||
.map((message) => {
|
||||
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You";
|
||||
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : t("chat.you", "You");
|
||||
const roomMessage: ChatMessageInfo = {
|
||||
id: message.id,
|
||||
sessionId: message.roomId,
|
||||
@@ -3052,12 +3056,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
Latest
|
||||
{t("chat.latest", "Latest")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="chat-room-empty-pane" data-testid="chat-rooms-empty-pane">Select a room or create one</div>
|
||||
<div className="chat-room-empty-pane" data-testid="chat-rooms-empty-pane">{t("chat.selectRoomOrCreate", "Select a room or create one")}</div>
|
||||
)}
|
||||
|
||||
{rooms.activeRoom && (
|
||||
@@ -3067,7 +3071,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder="Type a message..."
|
||||
placeholder={t("chat.typeMessage", "Type a message...")}
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
@@ -3172,7 +3176,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid={`chat-mobile-session-option-${session.id}`}
|
||||
onClick={() => handleSessionClick(session.id)}
|
||||
>
|
||||
<span className="chat-mobile-session-option-title">{session.title || "Untitled"}</span>
|
||||
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -3191,7 +3195,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className={`chat-thread-header-render-toggle${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
|
||||
data-testid="chat-thread-render-toggle"
|
||||
aria-label={showAllAsPlain ? "Show all messages as rendered Markdown" : "Show all messages as plain text"}
|
||||
aria-label={showAllAsPlain ? t("chat.showRenderedMarkdown", "Show all messages as rendered Markdown") : t("chat.showPlainText", "Show all messages as plain text")}
|
||||
onClick={toggleAllAsPlain}
|
||||
>
|
||||
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
@@ -3204,7 +3208,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
data-testid="chat-thread-new-chat-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Chat
|
||||
{t("chat.newChat", "New Chat")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -3215,7 +3219,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
|
||||
{hasMoreMessages && messagesLoading && (
|
||||
<div className="chat-loading-older">Loading older messages…</div>
|
||||
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
@@ -3249,14 +3253,14 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
renderAssistantContent(streamingText, showAllAsPlain)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
|
||||
</div>
|
||||
)}
|
||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||||
{renderToolCalls(streamingToolCalls)}
|
||||
{renderToolCalls(streamingToolCalls, t)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
@@ -3268,11 +3272,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</div>
|
||||
</>
|
||||
) : messagesLoading ? (
|
||||
<div className="chat-empty-state">Loading messages...</div>
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
) : messages.length === 0 && !activeSession ? (
|
||||
renderEmptyState()
|
||||
) : messages.length === 0 && activeSession ? (
|
||||
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
@@ -3304,7 +3308,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
Latest
|
||||
{t("chat.latest", "Latest")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -3323,12 +3327,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
}}
|
||||
/>
|
||||
{showSkillMenu && (
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label="Skill suggestions">
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
|
||||
{skillsLoading ? (
|
||||
<div className="chat-skill-menu-empty">Loading skills…</div>
|
||||
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">
|
||||
{skillFilter ? "No skills found" : "No skills available"}
|
||||
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill, index) => (
|
||||
@@ -3382,7 +3386,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
type="button"
|
||||
className="btn-icon chat-attach-btn"
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label="Attach files"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
@@ -3403,7 +3407,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder="Type a message..."
|
||||
placeholder={t("chat.typeMessage", "Type a message...")}
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
@@ -3452,11 +3456,11 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
/>
|
||||
{pendingMessage && (
|
||||
<div className="chat-pending-message" data-testid="chat-pending-indicator">
|
||||
<span>{`Queued: ${pendingPreview}`}</span>
|
||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-pending-message-dismiss"
|
||||
aria-label="Dismiss queued message"
|
||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||
data-testid="chat-pending-dismiss"
|
||||
onClick={clearPendingMessage}
|
||||
>
|
||||
@@ -3469,7 +3473,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<button
|
||||
className="chat-input-stop"
|
||||
onClick={stopStreaming}
|
||||
aria-label="Stop generation"
|
||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||
data-testid="chat-stop-btn"
|
||||
>
|
||||
<Square size={14} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
fetchClaudeCliStatus,
|
||||
@@ -45,6 +46,7 @@ export function ClaudeCliProviderCard({
|
||||
onToggled,
|
||||
compact = false,
|
||||
}: ClaudeCliProviderCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = useState<ClaudeCliStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(
|
||||
null,
|
||||
@@ -125,8 +127,7 @@ export function ClaudeCliProviderCard({
|
||||
|
||||
const description = (
|
||||
<span className="onboarding-provider-card__description">
|
||||
Route AI calls through your locally-installed <code>claude</code> CLI.
|
||||
Uses your existing Claude subscription / quota instead of an API key.
|
||||
{t("setup.claudeCli.description", "Route AI calls through your locally-installed claude CLI. Uses your existing Claude subscription / quota instead of an API key.")}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -141,10 +142,10 @@ export function ClaudeCliProviderCard({
|
||||
{busy === "testing" ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Testing…
|
||||
{t("setup.claudeCli.testing", "Testing…")}
|
||||
</>
|
||||
) : (
|
||||
"Test"
|
||||
t("setup.claudeCli.test", "Test")
|
||||
)}
|
||||
</button>
|
||||
{currentlyEnabled ? (
|
||||
@@ -154,7 +155,7 @@ export function ClaudeCliProviderCard({
|
||||
onClick={() => void handleToggle(false)}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "disabling" ? "Disabling…" : "Disable"}
|
||||
{busy === "disabling" ? t("setup.claudeCli.disabling", "Disabling…") : t("setup.claudeCli.disable", "Disable")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -164,11 +165,11 @@ export function ClaudeCliProviderCard({
|
||||
disabled={busy !== null || !binaryAvailable}
|
||||
title={
|
||||
!binaryAvailable
|
||||
? "`claude` binary not detected on PATH — install Claude CLI first."
|
||||
? t("setup.claudeCli.binaryNotFound", "`claude` binary not detected on PATH — install Claude CLI first.")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{busy === "enabling" ? "Enabling…" : "Enable"}
|
||||
{busy === "enabling" ? t("setup.claudeCli.enabling", "Enabling…") : t("setup.claudeCli.enable", "Enable")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -230,15 +231,16 @@ function ClaudeCliBadge({
|
||||
status: ClaudeCliStatus | null;
|
||||
authenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const enabled = status?.enabled ?? authenticated;
|
||||
const available = status?.binary.available ?? false;
|
||||
if (enabled) {
|
||||
return <span className="auth-status-badge authenticated">✓ Active</span>;
|
||||
return <span className="auth-status-badge authenticated">{t("setup.claudeCli.active", "✓ Active")}</span>;
|
||||
}
|
||||
if (!available && status) {
|
||||
return <span className="auth-status-badge not-authenticated">✗ Not installed</span>;
|
||||
return <span className="auth-status-badge not-authenticated">{t("setup.claudeCli.notInstalled", "✗ Not installed")}</span>;
|
||||
}
|
||||
return <span className="auth-status-badge not-authenticated">✗ Not connected</span>;
|
||||
return <span className="auth-status-badge not-authenticated">{t("setup.claudeCli.notConnected", "✗ Not connected")}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,10 +255,11 @@ function ClaudeCliStatusLine({
|
||||
status: ClaudeCliStatus | null;
|
||||
authenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
if (!status) {
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
<Loader2 size={10} className="animate-spin" /> Probing local CLI…
|
||||
<Loader2 size={10} className="animate-spin" /> {t("setup.claudeCli.probing", "Probing local CLI…")}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
@@ -264,37 +267,35 @@ function ClaudeCliStatusLine({
|
||||
if (!binary.available) {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--error">
|
||||
✗ {binary.reason ?? "`claude` not found on PATH"}
|
||||
✗ {binary.reason ?? t("setup.claudeCli.binaryNotFoundPath", "`claude` not found on PATH")}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
if (!enabled) {
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
<code>claude</code> {binary.version ? `(${binary.version})` : ""} detected
|
||||
{binary.binaryPath ? ` at ${binary.binaryPath}` : ""}. Click Enable to
|
||||
route AI calls through it.
|
||||
<code>claude</code> {binary.version ? `(${binary.version})` : ""} {t("setup.claudeCli.detectedPrompt", "detected{{path}}. Click Enable to route AI calls through it.", { path: binary.binaryPath ? ` at ${binary.binaryPath}` : "" })}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
if (extension && extension.status !== "ok") {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--warning">
|
||||
⚠ Extension load failed: {extension.reason ?? extension.status}
|
||||
⚠ {t("setup.claudeCli.extensionFailed", "Extension load failed: {{reason}}", { reason: extension.reason ?? extension.status })}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
if (ready || authenticated) {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--connected">
|
||||
✓ Connected{binary.version ? ` — ${binary.version}` : ""}
|
||||
{t("setup.claudeCli.connected", "✓ Connected{{version}}", { version: binary.version ? ` — ${binary.version}` : "" })}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
// Enabled but `ready` is false and we have no specific reason — usually a
|
||||
// transient state after flipping the toggle before the first probe
|
||||
// completes.
|
||||
return <small className="settings-muted">Enabled. Validating…</small>;
|
||||
return <small className="settings-muted">{t("setup.claudeCli.validating", "Enabled. Validating…")}</small>;
|
||||
}
|
||||
|
||||
function ClaudeCliActionToast({
|
||||
@@ -305,6 +306,7 @@ function ClaudeCliActionToast({
|
||||
| { kind: "disabled"; restartRequired: boolean }
|
||||
| { kind: "error"; message: string };
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
if (action.kind === "error") {
|
||||
return (
|
||||
<p className="onboarding-helper-text onboarding-helper-text--error">
|
||||
@@ -312,13 +314,13 @@ function ClaudeCliActionToast({
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const verb = action.kind === "enabled" ? "Enabled" : "Disabled";
|
||||
const verb = action.kind === "enabled" ? t("setup.claudeCli.enabledVerb", "Enabled") : t("setup.claudeCli.disabledVerb", "Disabled");
|
||||
return (
|
||||
<p className="onboarding-helper-text">
|
||||
{verb}.{" "}
|
||||
{action.kind === "enabled"
|
||||
? "Claude-CLI-routed models are now visible in the model picker."
|
||||
: "Claude-CLI-routed models are hidden from the model picker."}
|
||||
? t("setup.claudeCli.enabledMessage", "Claude-CLI-routed models are now visible in the model picker.")
|
||||
: t("setup.claudeCli.disabledMessage", "Claude-CLI-routed models are hidden from the model picker.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
fetchFnBinaryStatus,
|
||||
@@ -45,6 +46,7 @@ function persistDismissal(): void {
|
||||
* Binary panel always lets the user reinstall later.
|
||||
*/
|
||||
export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => isDismissed());
|
||||
const [installing, setInstalling] = useState(false);
|
||||
@@ -106,21 +108,20 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
|
||||
const isMismatch = status.state === "version-mismatch";
|
||||
const installedVersion = status.binary.version;
|
||||
const targetVersion = status.expectedVersion;
|
||||
const title = isMismatch ? "Update the Fusion CLI" : "Install the Fusion CLI";
|
||||
const title = isMismatch ? t("cli.updateTitle", "Update the Fusion CLI") : t("cli.installTitle", "Install the Fusion CLI");
|
||||
const body = isMismatch ? (
|
||||
<>
|
||||
Your installed <code>fn</code>/<code>fusion</code> CLI is{" "}
|
||||
<strong>v{installedVersion ?? "unknown"}</strong> but this dashboard expects{" "}
|
||||
<strong>v{targetVersion}</strong>. Update to stay in sync.
|
||||
{t("cli.versionMismatchPrefix", "Your installed")} <code>fn</code>/<code>fusion</code> CLI is{" "}
|
||||
<strong>v{installedVersion ?? "unknown"}</strong> {t("cli.versionMismatchInfix", "but this dashboard expects")} {" "}
|
||||
<strong>v{targetVersion}</strong>. {t("cli.versionMismatchSuffix", "Update to stay in sync.")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Get the <code>fn</code> and <code>fusion</code> commands on your terminal so you
|
||||
can drive Fusion from anywhere. One click below or copy the command into your shell.
|
||||
{t("cli.installBody", "Get the {{fn}} and {{fusion}} commands on your terminal so you can drive Fusion from anywhere. One click below or copy the command into your shell.", { fn: "fn", fusion: "fusion" })}
|
||||
</>
|
||||
);
|
||||
const idleLabel = isMismatch ? "Update with npm" : "Install with npm";
|
||||
const busyLabel = isMismatch ? "Updating…" : "Installing…";
|
||||
const idleLabel = isMismatch ? t("cli.updateButton", "Update with npm") : t("cli.installButton", "Install with npm");
|
||||
const busyLabel = isMismatch ? t("cli.updating", "Updating…") : t("cli.installing", "Installing…");
|
||||
|
||||
return (
|
||||
<div className="cli-binary-banner" role="status">
|
||||
@@ -141,7 +142,7 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
|
||||
className="cli-binary-banner__secondary"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
Open Settings
|
||||
{t("cli.openSettings", "Open Settings")}
|
||||
</button>
|
||||
</div>
|
||||
{installError && (
|
||||
@@ -151,7 +152,7 @@ export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="cli-binary-banner__dismiss"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t("actions.dismiss", "Dismiss")}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X size={16} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchFnBinaryStatus,
|
||||
installFnBinary,
|
||||
@@ -16,12 +17,8 @@ interface Props {
|
||||
defer?: boolean;
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<FnBinaryStatus["state"], { text: string; tone: "ok" | "warn" | "err" }> = {
|
||||
installed: { text: "Installed", tone: "ok" },
|
||||
missing: { text: "Not installed", tone: "err" },
|
||||
"version-mismatch": { text: "Version mismatch", tone: "warn" },
|
||||
skipped: { text: "Check disabled", tone: "warn" },
|
||||
};
|
||||
// Note: These labels are fetched dynamically in the component via useTranslation
|
||||
// to support i18n. See below for the actual label rendering.
|
||||
|
||||
/**
|
||||
* Settings panel for the `fn` / `fusion` global CLI binary.
|
||||
@@ -31,6 +28,7 @@ const STATE_LABELS: Record<FnBinaryStatus["state"], { text: string; tone: "ok" |
|
||||
* commands so users with non-default npm setups can install themselves.
|
||||
*/
|
||||
export function CliBinaryPanel({ defer = false }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
@@ -38,6 +36,19 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const getStateLabel = (state: FnBinaryStatus["state"]): { text: string; tone: "ok" | "warn" | "err" } => {
|
||||
switch (state) {
|
||||
case "installed":
|
||||
return { text: t("cliBinary.stateInstalled", "Installed"), tone: "ok" };
|
||||
case "missing":
|
||||
return { text: t("cliBinary.stateMissing", "Not installed"), tone: "err" };
|
||||
case "version-mismatch":
|
||||
return { text: t("cliBinary.stateVersionMismatch", "Version mismatch"), tone: "warn" };
|
||||
case "skipped":
|
||||
return { text: t("cliBinary.stateCheckDisabled", "Check disabled"), tone: "warn" };
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -86,12 +97,12 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stateMeta = status ? STATE_LABELS[status.state] : null;
|
||||
const stateMeta = status ? getStateLabel(status.state) : null;
|
||||
|
||||
return (
|
||||
<div className="cli-binary-panel">
|
||||
<div className="cli-binary-header">
|
||||
<h4 className="settings-section-heading">CLI Binary</h4>
|
||||
<h4 className="settings-section-heading">{t("cliBinary.heading", "CLI Binary")}</h4>
|
||||
{stateMeta && (
|
||||
<span className={`cli-binary-pill cli-binary-pill--${stateMeta.tone}`}>
|
||||
{stateMeta.text}
|
||||
@@ -99,12 +110,10 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<small className="cli-binary-help">
|
||||
Installing the global CLI lets you run <code>fn</code> and <code>fusion</code> from any
|
||||
terminal. Automations and scripts work without it via <code>npx</code>, but a global
|
||||
install is faster and more convenient.
|
||||
{t("cliBinary.help", "Installing the global CLI lets you run fn and fusion from any terminal. Automations and scripts work without it via npx, but a global install is faster and more convenient.")}
|
||||
</small>
|
||||
|
||||
{loading && !status && <p className="cli-binary-status-line">Checking…</p>}
|
||||
{loading && !status && <p className="cli-binary-status-line">{t("cliBinary.checking", "Checking…")}</p>}
|
||||
|
||||
{status && (
|
||||
<div className="cli-binary-detail">
|
||||
@@ -130,7 +139,7 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
</ul>
|
||||
) : (
|
||||
<p className="cli-binary-status-line">
|
||||
Neither <code>fn</code> nor <code>fusion</code> was found on PATH.
|
||||
{t("cliBinary.notOnPath", "Neither fn nor fusion was found on PATH.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -142,10 +151,10 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
disabled={installing}
|
||||
>
|
||||
{installing
|
||||
? "Installing…"
|
||||
? t("cliBinary.installing", "Installing…")
|
||||
: status.binary.installed
|
||||
? "Reinstall"
|
||||
: "Install with npm"}
|
||||
? t("cliBinary.reinstall", "Reinstall")
|
||||
: t("cliBinary.installWithNpm", "Install with npm")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -153,12 +162,12 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading || installing}
|
||||
>
|
||||
Refresh
|
||||
{t("cliBinary.refresh", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="cli-binary-commands">
|
||||
<label>Or copy and run yourself:</label>
|
||||
<label>{t("cliBinary.orCopyLabel", "Or copy and run yourself:")}</label>
|
||||
{[
|
||||
{ label: "npm", command: status.install.npm },
|
||||
{ label: "curl", command: status.install.curl },
|
||||
@@ -170,7 +179,7 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
onClick={() => void copy(label, command)}
|
||||
className="cli-binary-copy-btn"
|
||||
>
|
||||
{copied === label ? "Copied" : "Copy"}
|
||||
{copied === label ? t("cliBinary.copied", "Copied") : t("cliBinary.copy", "Copy")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -182,8 +191,8 @@ export function CliBinaryPanel({ defer = false }: Props) {
|
||||
<details className="cli-binary-install-log" open={!installResult.success}>
|
||||
<summary>
|
||||
{installResult.success
|
||||
? `Install succeeded in ${(installResult.durationMs / 1000).toFixed(1)}s`
|
||||
: `Install failed (exit ${installResult.exitCode ?? "n/a"})`}
|
||||
? t("cliBinary.succeededDuration", "Install succeeded in {{duration}}s", { duration: (installResult.durationMs / 1000).toFixed(1) })
|
||||
: t("cliBinary.failedExit", "Install failed (exit {{code}})", { code: installResult.exitCode ?? "n/a" })}
|
||||
</summary>
|
||||
{installResult.permissionsHint && (
|
||||
<p className="cli-binary-permissions-hint">{installResult.permissionsHint}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
|
||||
@@ -79,6 +80,7 @@ interface ColumnProps {
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable }: ColumnProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -159,20 +161,20 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
|
||||
if (shouldPrompt) {
|
||||
const keepProgress = await confirm({
|
||||
title: "Preserve Progress?",
|
||||
message: "This task has completed steps. Keep progress before moving?",
|
||||
confirmLabel: "Keep Progress",
|
||||
cancelLabel: "Reset Progress",
|
||||
title: t("column.preserveProgressTitle", "Preserve Progress?"),
|
||||
message: t("column.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
|
||||
confirmLabel: t("column.keepProgress", "Keep Progress"),
|
||||
cancelLabel: t("column.resetProgress", "Reset Progress"),
|
||||
});
|
||||
|
||||
if (keepProgress) {
|
||||
moveOptions = { preserveProgress: true };
|
||||
} else {
|
||||
const resetProgress = await confirm({
|
||||
title: "Reset Progress?",
|
||||
message: "Reset all step progress before moving this task?",
|
||||
confirmLabel: "Reset Progress",
|
||||
cancelLabel: "Cancel Move",
|
||||
title: t("column.resetProgressTitle", "Reset Progress?"),
|
||||
message: t("column.resetProgressMessage", "Reset all step progress before moving this task?"),
|
||||
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
|
||||
cancelLabel: t("column.cancelMove", "Cancel Move"),
|
||||
danger: true,
|
||||
});
|
||||
if (!resetProgress) {
|
||||
@@ -208,8 +210,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Replan All Tasks",
|
||||
message: `Move all ${tasks.length} todo task${tasks.length === 1 ? "" : "s"} back to planning to be replanned?`,
|
||||
title: t("column.replanAllTitle", "Replan All Tasks"),
|
||||
message: t("column.replanAllMessage", "Move all {{count}} todo task{{plural}} back to planning to be replanned?", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" }),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -222,9 +224,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
const moved = results.length - failed;
|
||||
if (failed === 0) {
|
||||
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to planning for replanning`, "success");
|
||||
addToast(t("column.movedToPlanning", "Moved {{count}} task{{plural}} to planning for replanning", { count: moved, plural: moved === 1 ? "" : "s" }), "success");
|
||||
} else {
|
||||
addToast(`Moved ${moved} of ${results.length} tasks; ${failed} failed`, "error");
|
||||
addToast(t("column.movePartialFailure", "Moved {{moved}} of {{total}} tasks; {{failed}} failed", { moved, total: results.length, failed }), "error");
|
||||
}
|
||||
} finally {
|
||||
setIsReplanning(false);
|
||||
@@ -246,8 +248,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
if (pauseEligibleCount === 0) return;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Stop All Tasks",
|
||||
message: `Stop all ${pauseEligibleCount} ${COLUMN_LABELS[column].toLowerCase()} task${pauseEligibleCount === 1 ? "" : "s"}?`,
|
||||
title: t("column.stopAllTitle", "Stop All Tasks"),
|
||||
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
@@ -260,9 +262,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
const paused = results.length - failed;
|
||||
if (failed === 0) {
|
||||
addToast(`Stopped ${paused} task${paused === 1 ? "" : "s"}`, "success");
|
||||
addToast(t("column.stoppedTasks", "Stopped {{count}} task{{plural}}", { count: paused, plural: paused === 1 ? "" : "s" }), "success");
|
||||
} else {
|
||||
addToast(`Stopped ${paused} of ${results.length} tasks; ${failed} failed`, "error");
|
||||
addToast(t("column.stopPartialFailure", "Stopped {{paused}} of {{total}} tasks; {{failed}} failed", { paused, total: results.length, failed }), "error");
|
||||
}
|
||||
} finally {
|
||||
setIsPausingAll(false);
|
||||
@@ -274,8 +276,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Move All to Todo",
|
||||
message: `Move all ${tasks.length} ${COLUMN_LABELS[column].toLowerCase()} task${tasks.length === 1 ? "" : "s"} to Todo?`,
|
||||
title: t("column.moveAllToTodoTitle", "Move All to Todo"),
|
||||
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -283,20 +285,20 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
let preserveProgress = false;
|
||||
if (hasAnyProgress) {
|
||||
const keepProgress = await confirm({
|
||||
title: "Preserve Progress?",
|
||||
message: "Some tasks have completed steps. Keep progress before moving to Todo?",
|
||||
confirmLabel: "Keep Progress",
|
||||
cancelLabel: "Reset Progress",
|
||||
title: t("column.preserveProgressTitle", "Preserve Progress?"),
|
||||
message: t("column.preserveProgressMoveTodoMessage", "Some tasks have completed steps. Keep progress before moving to Todo?"),
|
||||
confirmLabel: t("column.keepProgress", "Keep Progress"),
|
||||
cancelLabel: t("column.resetProgress", "Reset Progress"),
|
||||
});
|
||||
|
||||
if (keepProgress) {
|
||||
preserveProgress = true;
|
||||
} else {
|
||||
const resetProgress = await confirm({
|
||||
title: "Reset Progress?",
|
||||
message: "Reset step progress for tasks before moving to Todo?",
|
||||
confirmLabel: "Reset Progress",
|
||||
cancelLabel: "Cancel Move",
|
||||
title: t("column.resetProgressTitle", "Reset Progress?"),
|
||||
message: t("column.resetProgressMoveTodoMessage", "Reset step progress for tasks before moving to Todo?"),
|
||||
confirmLabel: t("column.resetProgressConfirm", "Reset Progress"),
|
||||
cancelLabel: t("column.cancelMove", "Cancel Move"),
|
||||
danger: true,
|
||||
});
|
||||
if (!resetProgress) {
|
||||
@@ -313,9 +315,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
const moved = results.length - failed;
|
||||
if (failed === 0) {
|
||||
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to Todo`, "success");
|
||||
addToast(t("column.movedToTodo", "Moved {{count}} task{{plural}} to Todo", { count: moved, plural: moved === 1 ? "" : "s" }), "success");
|
||||
} else {
|
||||
addToast(`Moved ${moved} of ${results.length} tasks to Todo; ${failed} failed`, "error");
|
||||
addToast(t("column.moveToTodoPartialFailure", "Moved {{moved}} of {{total}} tasks to Todo; {{failed}} failed", { moved, total: results.length, failed }), "error");
|
||||
}
|
||||
} finally {
|
||||
setIsMovingAllToTodo(false);
|
||||
@@ -327,19 +329,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Archive All Done",
|
||||
message: `Archive all ${tasks.length} done tasks?`,
|
||||
title: t("column.archiveAllTitle", "Archive All Done"),
|
||||
message: t("column.archiveAllMessage", "Archive all {{count}} done tasks?", { count: tasks.length }),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const archived = await onArchiveAllDone();
|
||||
addToast(`Archived ${archived.length} tasks`, "success");
|
||||
addToast(t("column.archivedTasks", "Archived {{count}} tasks", { count: archived.length }), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to archive tasks", "error");
|
||||
addToast(getErrorMessage(err) || t("column.failedToArchive", "Failed to archive tasks"), "error");
|
||||
}
|
||||
}, [onArchiveAllDone, tasks.length, addToast, confirm]);
|
||||
}, [onArchiveAllDone, tasks.length, addToast, confirm, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -366,7 +368,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
)}
|
||||
{onNewTask && (
|
||||
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
+ {t("column.newTask", "New Task")}
|
||||
</button>
|
||||
)}
|
||||
{column === "done" && onArchiveAllDone && (
|
||||
@@ -374,8 +376,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={handleArchiveAll}
|
||||
disabled={tasks.length === 0}
|
||||
title="Archive all done tasks"
|
||||
aria-label="Archive all done tasks"
|
||||
title={t("column.archiveAllDoneTitle", "Archive all done tasks")}
|
||||
aria-label={t("column.archiveAllDoneAriaLabel", "Archive all done tasks")}
|
||||
>
|
||||
<Archive />
|
||||
</button>
|
||||
@@ -384,8 +386,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
|
||||
aria-label={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
|
||||
title={collapsed ? t("column.expandArchivedTitle", "Expand archived tasks") : t("column.collapseArchivedTitle", "Collapse archived tasks")}
|
||||
aria-label={collapsed ? t("column.expandArchivedLabel", "Expand archived tasks") : t("column.collapseArchivedLabel", "Collapse archived tasks")}
|
||||
>
|
||||
{/* Directional chevrons stay explicit for clearer collapsed-state affordance in compact headers. */}
|
||||
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
@@ -399,8 +401,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onClick={() => setIsMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-label={`${COLUMN_LABELS[column]} column actions`}
|
||||
title="Column actions"
|
||||
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: COLUMN_LABELS[column] })}
|
||||
title={t("column.actionsTitle", "Column actions")}
|
||||
disabled={isMenuBusy}
|
||||
>
|
||||
<MoreVertical />
|
||||
@@ -415,9 +417,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onClick={() => void handleReplanAll()}
|
||||
disabled={tasks.length === 0 || isReplanning}
|
||||
>
|
||||
Replan All
|
||||
{t("column.replanAll", "Replan All")}
|
||||
<span className="column-menu-item-hint">
|
||||
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Planning
|
||||
{t("column.replanAllHint", "Move {{count}} task{{plural}} to Planning", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -430,13 +432,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onClick={() => void handlePauseAll()}
|
||||
disabled={pauseEligibleCount === 0 || isPausingAll || !onPauseTask}
|
||||
>
|
||||
Stop All
|
||||
{t("column.stopAll", "Stop All")}
|
||||
<span className="column-menu-item-hint">
|
||||
{tasks.length === 0
|
||||
? "No tasks in this column"
|
||||
? t("column.noTasksInColumn", "No tasks in this column")
|
||||
: pauseEligibleCount === 0
|
||||
? "No manually pausable tasks"
|
||||
: `Pause ${pauseEligibleCount} active unassigned task${pauseEligibleCount === 1 ? "" : "s"}`}
|
||||
? t("column.noManuallyPausableTasks", "No manually pausable tasks")
|
||||
: t("column.pauseHint", "Pause {{count}} active unassigned task{{plural}}", { count: pauseEligibleCount, plural: pauseEligibleCount === 1 ? "" : "s" })}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -446,9 +448,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onClick={() => void handleMoveAllToTodo()}
|
||||
disabled={tasks.length === 0 || isMovingAllToTodo}
|
||||
>
|
||||
Move All to Todo
|
||||
{t("column.moveAllToTodo", "Move All to Todo")}
|
||||
<span className="column-menu-item-hint">
|
||||
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Todo
|
||||
{t("column.moveToTodoHint", "Move {{count}} task{{plural}} to Todo", { count: tasks.length, plural: tasks.length === 1 ? "" : "s" })}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
@@ -489,7 +491,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
worktreeGroups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
|
||||
) : (
|
||||
worktreeGroups.map((group) => (
|
||||
<WorktreeGroup
|
||||
@@ -515,7 +517,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
))
|
||||
)
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
|
||||
) : (
|
||||
<>
|
||||
{visibleTasks.map((task) => (
|
||||
@@ -549,7 +551,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load {Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount)} more ({hiddenTaskCount} remaining)
|
||||
{t("column.loadMore", "Load {{count}} more ({{remaining}} remaining)", { count: Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount), remaining: hiddenTaskCount })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileCode, ChevronDown, ChevronRight, AlertCircle, GitCommit } from "lucide-react";
|
||||
import type { MergeDetails } from "@fusion/core";
|
||||
import { fetchCommitDiff } from "../api";
|
||||
@@ -73,6 +74,7 @@ export function parsePatch(rawPatch: string): ParsedFile[] {
|
||||
* the in-progress `TaskChangesTab` but sourced from git history.
|
||||
*/
|
||||
export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [files, setFiles] = useState<ParsedFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -95,7 +97,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
setExpandedFiles(new Set([parsed[0].path]));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Failed to load commit diff");
|
||||
setError(getErrorMessage(err) || t("commitDiff.loadError", "Failed to load commit diff"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -122,9 +124,9 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
<div className="detail-section">
|
||||
<div className="task-changes-state task-changes-state--empty">
|
||||
<GitCommit size={24} />
|
||||
<p>No commit SHA available.</p>
|
||||
<p>{t("commitDiff.noSha", "No commit SHA available.")}</p>
|
||||
<span className="task-changes-state-hint">
|
||||
Commit diff is only available for tasks that were merged.
|
||||
{t("commitDiff.mergedOnly", "Commit diff is only available for tasks that were merged.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +138,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
<div className="detail-section">
|
||||
<div className="task-changes-state task-changes-state--loading">
|
||||
<div className="loading-spinner" />
|
||||
<span>Loading commit diff...</span>
|
||||
<span>{t("commitDiff.loading", "Loading commit diff...")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -147,7 +149,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
<div className="detail-section">
|
||||
<div className="task-changes-state task-changes-state--error">
|
||||
<AlertCircle size={16} />
|
||||
<span>Error loading commit diff: {error}</span>
|
||||
<span>{t("commitDiff.error", "Error loading commit diff: {{error}}", { error })}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -158,7 +160,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
<div className="detail-section">
|
||||
<div className="task-changes-state task-changes-state--empty">
|
||||
<FileCode size={24} />
|
||||
<p>No files changed in this commit.</p>
|
||||
<p>{t("commitDiff.noFiles", "No files changed in this commit.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -193,7 +195,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
|
||||
</span>
|
||||
</h4>
|
||||
<button className="btn btn-sm" onClick={loadDiff} disabled={loading}>
|
||||
Refresh
|
||||
{t("actions.refresh", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ConfirmOptions } from "../hooks/useConfirm";
|
||||
import "./ConfirmDialog.css";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function ConfirmDialog({
|
||||
checkboxChecked = false,
|
||||
onCheckboxChange,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -60,7 +62,7 @@ export function ConfirmDialog({
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>{options.title}</h3>
|
||||
<button className="modal-close" onClick={onCancel} aria-label="Close confirmation dialog">
|
||||
<button className="modal-close" onClick={onCancel} aria-label={t("confirm.closeDialog", "Close confirmation dialog")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -81,7 +83,7 @@ export function ConfirmDialog({
|
||||
|
||||
<div className="modal-actions confirm-dialog__actions">
|
||||
<button ref={cancelButtonRef} className="btn" onClick={onCancel}>
|
||||
{options.cancelLabel ?? "Cancel"}
|
||||
{options.cancelLabel ?? t("confirm.cancel", "Cancel")}
|
||||
</button>
|
||||
{options.tertiaryLabel && onTertiary ? (
|
||||
<button className={`btn ${options.tertiaryDanger ? "btn-danger" : ""}`.trim()} onClick={onTertiary}>
|
||||
@@ -89,7 +91,7 @@ export function ConfirmDialog({
|
||||
</button>
|
||||
) : null}
|
||||
<button className={`btn ${options.danger ? "btn-danger" : "btn-primary"}`} onClick={onConfirm}>
|
||||
{options.confirmLabel ?? "Confirm"}
|
||||
{options.confirmLabel ?? t("confirm.confirm", "Confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NodeCreateInput, NodeInfo } from "../api";
|
||||
|
||||
export interface ConnectNodeInput {
|
||||
@@ -28,24 +30,24 @@ const DEFAULT_PORT = 3001;
|
||||
const MAX_CONCURRENT_MIN = 1;
|
||||
const MAX_CONCURRENT_MAX = 10;
|
||||
|
||||
function validateInput(input: { name: string; host: string; port: string; maxConcurrent: number }): FormErrors {
|
||||
function validateInput(input: { name: string; host: string; port: string; maxConcurrent: number }, t: TFunction<"app">): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!input.name.trim()) {
|
||||
errors.name = "Node name is required";
|
||||
errors.name = t("nodes.validation.nameRequired", "Node name is required");
|
||||
}
|
||||
|
||||
if (!input.host.trim()) {
|
||||
errors.host = "Host / IP address is required";
|
||||
errors.host = t("nodes.validation.hostRequired", "Host / IP address is required");
|
||||
}
|
||||
|
||||
const portNum = Number(input.port);
|
||||
if (input.port && (isNaN(portNum) || portNum < 1 || portNum > 65535)) {
|
||||
errors.port = "Port must be between 1 and 65535";
|
||||
errors.port = t("nodes.validation.portRange", "Port must be between 1 and 65535");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) {
|
||||
errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`;
|
||||
errors.maxConcurrent = t("nodes.validation.concurrencyRange", `Concurrency must be between {{min}} and {{max}}`, { min: MAX_CONCURRENT_MIN, max: MAX_CONCURRENT_MAX });
|
||||
}
|
||||
|
||||
return errors;
|
||||
@@ -58,6 +60,7 @@ function buildUrl(host: string, port: string): string {
|
||||
}
|
||||
|
||||
export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmit }: ConnectNodeModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [name, setName] = useState("");
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState(String(DEFAULT_PORT));
|
||||
@@ -106,7 +109,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const validationErrors = validateInput({ name, host, port, maxConcurrent });
|
||||
const validationErrors = validateInput({ name, host, port, maxConcurrent }, t);
|
||||
setErrors(validationErrors);
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
@@ -142,23 +145,23 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: "Failed to connect" }));
|
||||
const error = await response.json().catch(() => ({ error: t("nodes.errors.connectFailed", "Failed to connect") }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
node = await response.json() as NodeInfo;
|
||||
}
|
||||
|
||||
addToast(`Connected to "${node.name}"`, "success");
|
||||
addToast(t("nodes.success.connected", `Connected to "{{name}}"`, { name: node.name }), "success");
|
||||
onConnected(node);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to connect to node";
|
||||
const message = error instanceof Error ? error.message : t("nodes.errors.connectToNode", "Failed to connect to node");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port]);
|
||||
}, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -169,25 +172,25 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Connect to Node"
|
||||
aria-label={t("nodes.modal.title", "Connect to Node")}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Connect to Node</h3>
|
||||
<button className="modal-close" onClick={onClose} disabled={isSubmitting} aria-label="Close connect node modal">
|
||||
<h3>{t("nodes.modal.title", "Connect to Node")}</h3>
|
||||
<button className="modal-close" onClick={onClose} disabled={isSubmitting} aria-label={t("nodes.modal.closeButton", "Close connect node modal")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body connect-node-form">
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-name">Node Name</label>
|
||||
<label htmlFor="connect-node-name">{t("nodes.fields.name", "Node Name")}</label>
|
||||
<input
|
||||
id="connect-node-name"
|
||||
className="input connect-node-field__input"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Build Server"
|
||||
placeholder={t("nodes.placeholders.name", "Build Server")}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
@@ -195,14 +198,14 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
</div>
|
||||
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-host">Host / IP Address</label>
|
||||
<label htmlFor="connect-node-host">{t("nodes.fields.host", "Host / IP Address")}</label>
|
||||
<input
|
||||
id="connect-node-host"
|
||||
className="input connect-node-field__input"
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(event) => setHost(event.target.value)}
|
||||
placeholder="192.0.2.10 or my-server.local"
|
||||
placeholder={t("nodes.placeholders.host", "192.0.2.10 or my-server.local")}
|
||||
disabled={isSubmitting}
|
||||
aria-invalid={Boolean(errors.host)}
|
||||
/>
|
||||
@@ -210,7 +213,7 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
</div>
|
||||
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-port">Port</label>
|
||||
<label htmlFor="connect-node-port">{t("nodes.fields.port", "Port")}</label>
|
||||
<input
|
||||
id="connect-node-port"
|
||||
className="input connect-node-field__input"
|
||||
@@ -227,26 +230,26 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
|
||||
{constructedUrl && (
|
||||
<div className="connect-node-url-preview">
|
||||
<span className="connect-node-url-preview-label">URL:</span>
|
||||
<span className="connect-node-url-preview-label">{t("nodes.fields.url", "URL")}:</span>
|
||||
<code>{constructedUrl}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-auth-key">Auth Key</label>
|
||||
<label htmlFor="connect-node-auth-key">{t("nodes.fields.authKey", "Auth Key")}</label>
|
||||
<input
|
||||
id="connect-node-auth-key"
|
||||
className="input connect-node-field__input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="Optional"
|
||||
placeholder={t("nodes.placeholders.optional", "Optional")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group connect-node-field">
|
||||
<label htmlFor="connect-node-max-concurrent">Max Concurrent</label>
|
||||
<label htmlFor="connect-node-max-concurrent">{t("nodes.fields.maxConcurrent", "Max Concurrent")}</label>
|
||||
<input
|
||||
id="connect-node-max-concurrent"
|
||||
className="input connect-node-field__input"
|
||||
@@ -264,10 +267,10 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
|
||||
<div className="modal-actions connect-node-actions">
|
||||
<button className="btn btn-sm" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={isSubmitting || !host.trim()}>
|
||||
{isSubmitting ? "Connecting..." : "Connect"}
|
||||
{isSubmitting ? t("nodes.actions.connecting", "Connecting...") : t("nodes.actions.connect", "Connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./ConversationHistory.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { useState } from "react";
|
||||
import type { ConversationHistoryEntry } from "../api";
|
||||
@@ -83,6 +84,7 @@ function normalizeEntries(entries: ConversationHistoryEntry[]): NumberedEntry[]
|
||||
}
|
||||
|
||||
export function ConversationHistory({ entries, defaultShowThinking = false }: ConversationHistoryProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [expandedThinking, setExpandedThinking] = useState<Record<number, boolean>>({});
|
||||
const normalizedEntries = normalizeEntries(entries);
|
||||
|
||||
@@ -118,13 +120,13 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversation-entry-question">
|
||||
<span className="conversation-entry-question-label">AI Reasoning</span>
|
||||
<span className="conversation-entry-question-label">{t("conversation.aiReasoning", "AI Reasoning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasQuestion && (
|
||||
<div className="conversation-entry-response">
|
||||
<strong>Your response</strong>
|
||||
<strong>{t("conversation.yourResponse", "Your response")}</strong>
|
||||
<p>{formattedResponse || "—"}</p>
|
||||
{comment && <p className="conversation-comment">💬 {comment}</p>}
|
||||
</div>
|
||||
@@ -145,8 +147,8 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
|
||||
>
|
||||
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
|
||||
{isExpanded
|
||||
? `Hide ${hasQuestion ? "AI thinking" : "AI reasoning"}`
|
||||
: `Show ${hasQuestion ? "AI thinking" : "AI reasoning"}`}
|
||||
? 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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { fetchAgents } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
@@ -14,16 +15,17 @@ export interface RoomDraft {
|
||||
memberAgentIds: string[];
|
||||
}
|
||||
|
||||
export function validateRoomName(input: string, existingRoomNames: string[] = []): { ok: true; name: string } | { ok: false; error: string } {
|
||||
export function validateRoomName(input: string, existingRoomNames: string[] = [], t?: (key: string, defaultValue: string) => string): { ok: true; name: string } | { ok: false; error: string } {
|
||||
const raw = input.trim().replace(/^#/, "");
|
||||
if (!raw) return { ok: false, error: "Room name is required." };
|
||||
if (/[A-Z]/.test(raw)) return { ok: false, error: "Use lowercase letters only." };
|
||||
const getError = (key: string, defaultValue: string) => t ? t(key, defaultValue) : defaultValue;
|
||||
if (!raw) return { ok: false, error: getError("createRoom.nameRequired", "Room name is required.") };
|
||||
if (/[A-Z]/.test(raw)) return { ok: false, error: getError("createRoom.lowercase", "Use lowercase letters only.") };
|
||||
const stripped = raw.toLowerCase();
|
||||
if (stripped.length > 80) return { ok: false, error: "Room names can be at most 80 characters." };
|
||||
if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: "Use lowercase letters, numbers, hyphens, or underscores only." };
|
||||
if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: "Room names cannot start or end with a hyphen or underscore." };
|
||||
if (stripped.length > 80) return { ok: false, error: getError("createRoom.maxLength", "Room names can be at most 80 characters.") };
|
||||
if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: getError("createRoom.validChars", "Use lowercase letters, numbers, hyphens, or underscores only.") };
|
||||
if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: getError("createRoom.noEdgeChars", "Room names cannot start or end with a hyphen or underscore.") };
|
||||
if (existingRoomNames.some((name) => name.toLowerCase() === stripped)) {
|
||||
return { ok: false, error: "A room with this name already exists." };
|
||||
return { ok: false, error: getError("createRoom.duplicate", "A room with this name already exists.") };
|
||||
}
|
||||
return { ok: true, name: stripped };
|
||||
}
|
||||
@@ -37,6 +39,7 @@ interface CreateRoomModalProps {
|
||||
}
|
||||
|
||||
export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existingRoomNames = [] }: CreateRoomModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [rawName, setRawName] = useState("");
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -56,7 +59,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
.then((result) => setAgents(result))
|
||||
.catch(() => {
|
||||
setAgents([]);
|
||||
setSubmitError("Failed to load agents.");
|
||||
setSubmitError(t("createRoom.failedLoadAgents", "Failed to load agents."));
|
||||
})
|
||||
.finally(() => setLoadingAgents(false));
|
||||
}, [isOpen, projectId]);
|
||||
@@ -88,7 +91,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
previousFocusRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
const validation = useMemo(() => validateRoomName(rawName, existingRoomNames), [rawName, existingRoomNames]);
|
||||
const validation = useMemo(() => validateRoomName(rawName, existingRoomNames, t), [rawName, existingRoomNames, t]);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const normalized = search.trim().toLowerCase();
|
||||
@@ -116,7 +119,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
return;
|
||||
}
|
||||
if (selectedAgentIds.length === 0) {
|
||||
setSubmitError("Select at least one member.");
|
||||
setSubmitError(t("createRoom.selectMember", "Select at least one member."));
|
||||
return;
|
||||
}
|
||||
setSubmitError(null);
|
||||
@@ -129,7 +132,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Failed to create room.");
|
||||
setSubmitError(error instanceof Error ? error.message : t("createRoom.failedCreate", "Failed to create room."));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -137,14 +140,14 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label="Create room" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label={t("createRoom.title", "Create room")} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Create room</h3>
|
||||
<button type="button" className="modal-close" aria-label="Close" onClick={onClose}>×</button>
|
||||
<h3>{t("createRoom.title", "Create room")}</h3>
|
||||
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="form-group create-room-modal-name-group">
|
||||
<label htmlFor="create-room-name">Room name</label>
|
||||
<label htmlFor="create-room-name">{t("createRoom.nameLabel", "Room name")}</label>
|
||||
<div className="create-room-modal-name-field">
|
||||
<span aria-hidden="true" className="create-room-modal-name-hash">#</span>
|
||||
<input
|
||||
@@ -163,11 +166,11 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="create-room-member-search">Members</label>
|
||||
<label htmlFor="create-room-member-search">{t("createRoom.members", "Members")}</label>
|
||||
<input
|
||||
id="create-room-member-search"
|
||||
className="input"
|
||||
placeholder="Search agents"
|
||||
placeholder={t("createRoom.searchAgents", "Search agents")}
|
||||
value={search}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
@@ -192,10 +195,10 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
|
||||
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
|
||||
{loadingAgents ? (
|
||||
<div className="create-room-modal-empty">Loading agents...</div>
|
||||
<div className="create-room-modal-empty">{t("createRoom.loadingAgents", "Loading agents...")}</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="create-room-modal-empty">
|
||||
{agents.length === 0 ? "No agents in this project yet." : "No agents match your search."}
|
||||
{agents.length === 0 ? t("createRoom.noAgents", "No agents in this project yet.") : t("createRoom.noMatch", "No agents match your search.")}
|
||||
</div>
|
||||
) : (
|
||||
filteredAgents.map((agent) => {
|
||||
@@ -220,9 +223,9 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
|
||||
{submitError && <div className="form-group"><div className="form-error">{submitError}</div></div>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose} disabled={isSubmitting}>Cancel</button>
|
||||
<button type="button" className="btn" onClick={onClose} disabled={isSubmitting}>{t("actions.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>
|
||||
{isSubmitting ? "Creating..." : "Create room"}
|
||||
{isSubmitting ? t("createRoom.creating", "Creating...") : t("createRoom.create", "Create room")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { fetchCursorCliStatus, setCursorCliEnabled, type CursorCliStatus } from "../api";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -11,6 +12,7 @@ interface CursorCliProviderCardProps {
|
||||
}
|
||||
|
||||
export function CursorCliProviderCard({ authenticated, compact = false, onToggled }: CursorCliProviderCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = useState<CursorCliStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -61,27 +63,27 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
|
||||
if (mountedRef.current) setBusy(null);
|
||||
});
|
||||
}} disabled={busy !== null}>
|
||||
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" /> Testing…</> : "Test"}
|
||||
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" /> {t("setup.cursorCli.testing", "Testing…")}</> : t("setup.cursorCli.test", "Test")}
|
||||
</button>
|
||||
{currentlyEnabled ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
|
||||
{busy === "disabling" ? "Disabling…" : "Disable"}
|
||||
{busy === "disabling" ? t("setup.cursorCli.disabling", "Disabling…") : t("setup.cursorCli.disable", "Disable")}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => void handleToggle(true)} disabled={busy !== null || !binaryAvailable}>
|
||||
{busy === "enabling" ? "Enabling…" : "Enable"}
|
||||
{busy === "enabling" ? t("setup.cursorCli.enabling", "Enabling…") : t("setup.cursorCli.enable", "Enable")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const statusText = !status
|
||||
? "Probing local CLI…"
|
||||
? t("setup.cursorCli.probing", "Probing local CLI…")
|
||||
: !status.binary.available
|
||||
? status.binary.reason ?? "`cursor-agent` not found on PATH"
|
||||
? status.binary.reason ?? t("setup.cursorCli.binaryNotFound", "`cursor-agent` not found on PATH")
|
||||
: currentlyEnabled
|
||||
? `Connected${status.binary.version ? ` — ${status.binary.version}` : ""}`
|
||||
: "Detected. Click Enable to route calls through Cursor CLI.";
|
||||
? t("setup.cursorCli.connected", "Connected{{version}}", { version: status.binary.version ? ` — ${status.binary.version}` : "" })
|
||||
: t("setup.cursorCli.detectedPrompt", "Detected. Click Enable to route calls through Cursor CLI.");
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
@@ -106,7 +108,7 @@ export function CursorCliProviderCard({ authenticated, compact = false, onToggle
|
||||
</div>
|
||||
<div className="onboarding-provider-card__body">
|
||||
<strong className="onboarding-provider-card__name">Cursor — via Cursor CLI</strong>
|
||||
<span className="onboarding-provider-card__description">Route AI calls through your local Cursor agent runtime.</span>
|
||||
<span className="onboarding-provider-card__description">{t("setup.cursorCli.description", "Route AI calls through your local Cursor agent runtime.")}</span>
|
||||
<small className="settings-muted">{statusText}</small>
|
||||
</div>
|
||||
<div className="onboarding-provider-card__actions">{actions}</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./CustomModelDropdown.css";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
@@ -62,6 +63,7 @@ export function CustomModelDropdown({
|
||||
noChangeValue,
|
||||
noChangeLabel = "No change",
|
||||
}: CustomModelDropdownProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localFilter, setLocalFilter] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
@@ -139,7 +141,7 @@ export function CustomModelDropdown({
|
||||
if (hasNoChangeOption) {
|
||||
options.push({ type: "no-change", value: noChangeValue, label: noChangeLabel });
|
||||
}
|
||||
options.push({ type: "default", value: "", label: "Use default" });
|
||||
options.push({ type: "default", value: "", label: t("models.useDefault", "Use default") });
|
||||
return options;
|
||||
}, [hasNoChangeOption, noChangeLabel, noChangeValue]);
|
||||
|
||||
@@ -179,7 +181,7 @@ export function CustomModelDropdown({
|
||||
if (hasNoChangeOption && value === noChangeValue) {
|
||||
return noChangeLabel;
|
||||
}
|
||||
if (!value) return "Use default";
|
||||
if (!value) return t("models.useDefault", "Use default");
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (slashIdx === -1) return value;
|
||||
const provider = value.slice(0, slashIdx);
|
||||
@@ -488,7 +490,7 @@ export function CustomModelDropdown({
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="model-combobox-search"
|
||||
placeholder="Filter models…"
|
||||
placeholder={t("models.filterPlaceholder", "Filter models…")}
|
||||
value={localFilter}
|
||||
onChange={(e) => setLocalFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -498,7 +500,7 @@ export function CustomModelDropdown({
|
||||
type="button"
|
||||
className="model-combobox-clear"
|
||||
onClick={handleClearFilter}
|
||||
aria-label="Clear filter"
|
||||
aria-label={t("models.clearFilter", "Clear filter")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -506,7 +508,7 @@ export function CustomModelDropdown({
|
||||
</div>
|
||||
|
||||
<div className="model-combobox-results-count">
|
||||
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
|
||||
{t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })}
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="model-combobox-list">
|
||||
@@ -557,8 +559,8 @@ export function CustomModelDropdown({
|
||||
e.stopPropagation();
|
||||
onToggleModelFavorite(fullId);
|
||||
}}
|
||||
title="Remove from favorites"
|
||||
aria-label={`Remove ${model.name} from favorites`}
|
||||
title={t("models.removeFromFavorites", "Remove from favorites")}
|
||||
aria-label={t("models.removeFromFavoritesAriaLabel", "Remove {{name}} from favorites", { name: model.name })}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
@@ -596,8 +598,8 @@ export function CustomModelDropdown({
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(provider);
|
||||
}}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavorite ? `Remove ${provider} from favorites` : `Add ${provider} to favorites`}
|
||||
title={isFavorite ? t("models.removeFromFavorites", "Remove from favorites") : t("models.addToFavorites", "Add to favorites")}
|
||||
aria-label={isFavorite ? t("models.removeProviderFromFavoritesAriaLabel", "Remove {{provider}} from favorites", { provider }) : t("models.addProviderToFavoritesAriaLabel", "Add {{provider}} to favorites", { provider })}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
@@ -630,8 +632,8 @@ export function CustomModelDropdown({
|
||||
e.stopPropagation();
|
||||
onToggleModelFavorite(optionValue);
|
||||
}}
|
||||
title={isFavorited ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavorited ? `Remove ${m.name} from favorites` : `Add ${m.name} to favorites`}
|
||||
title={isFavorited ? t("models.removeFromFavorites", "Remove from favorites") : t("models.addToFavorites", "Add to favorites")}
|
||||
aria-label={isFavorited ? t("models.removeFromFavoritesAriaLabel", "Remove {{name}} from favorites", { name: m.name }) : t("models.addToFavoritesAriaLabel", "Add {{name}} to favorites", { name: m.name })}
|
||||
>
|
||||
{isFavorited ? "★" : "☆"}
|
||||
</button>
|
||||
@@ -644,7 +646,7 @@ export function CustomModelDropdown({
|
||||
})}
|
||||
|
||||
{filteredModels.length === 0 && hasFilter && (
|
||||
<div className="model-combobox-no-results">No models match '{localFilter}'</div>
|
||||
<div className="model-combobox-no-results">{t("models.noResults", "No models match '{{filter}}'", { filter: localFilter })}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import type { CustomProviderConfig, CustomProviderModelInput } from "../api";
|
||||
import { probeProviderModels } from "../api";
|
||||
@@ -36,6 +37,7 @@ function emptyModel(): CustomProviderModelInput {
|
||||
}
|
||||
|
||||
export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = false, error }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const editing = Boolean(initialConfig);
|
||||
const [id, setId] = useState(initialConfig?.id ?? "");
|
||||
const [name, setName] = useState(initialConfig?.name ?? "");
|
||||
@@ -72,7 +74,7 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
const handleDetectModels = useCallback(async () => {
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
setDetectError("Base URL is required to detect models.");
|
||||
setDetectError(t("providers.detectError.urlRequired", "Base URL is required to detect models."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
});
|
||||
|
||||
if (result.models.length === 0) {
|
||||
setDetectError("No models found. The provider may require an API key.");
|
||||
setDetectError(t("providers.detectError.noModels", "No models found. The provider may require an API key."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,35 +112,35 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
return [...nonEmpty, ...newModels];
|
||||
});
|
||||
} else {
|
||||
setDetectError("All discovered models are already in the list.");
|
||||
setDetectError(t("providers.detectError.allDuplicate", "All discovered models are already in the list."));
|
||||
}
|
||||
} catch (err) {
|
||||
setDetectError(
|
||||
err instanceof Error ? err.message : "Failed to detect models",
|
||||
err instanceof Error ? err.message : t("providers.detectError.failed", "Failed to detect models"),
|
||||
);
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [baseUrl, apiKey, probeApiType, models]);
|
||||
}, [baseUrl, apiKey, probeApiType, models, t]);
|
||||
|
||||
function validate(): string | null {
|
||||
if (!id.trim()) return "Provider ID is required.";
|
||||
if (!PROVIDER_ID_PATTERN.test(id.trim())) return "Provider ID must be kebab-case.";
|
||||
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return "Provider ID conflicts with a built-in provider.";
|
||||
if (!id.trim()) return t("providers.validation.idRequired", "Provider ID is required.");
|
||||
if (!PROVIDER_ID_PATTERN.test(id.trim())) return t("providers.validation.idKebabCase", "Provider ID must be kebab-case.");
|
||||
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return t("providers.validation.idConflict", "Provider ID conflicts with a built-in provider.");
|
||||
|
||||
if (!baseUrl.trim()) return "Base URL is required.";
|
||||
if (!baseUrl.trim()) return t("providers.validation.urlRequired", "Base URL is required.");
|
||||
try {
|
||||
const parsed = new URL(baseUrl.trim());
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return "Base URL must use http or https.";
|
||||
return t("providers.validation.urlProtocol", "Base URL must use http or https.");
|
||||
}
|
||||
} catch {
|
||||
return "Base URL must be a valid URL.";
|
||||
return t("providers.validation.urlValid", "Base URL must be a valid URL.");
|
||||
}
|
||||
|
||||
if (!API_TYPES.includes(api)) return "API type is required.";
|
||||
if (models.length === 0) return "At least one model is required.";
|
||||
if (models.some((model) => !model.id?.trim())) return "Each model must have a model ID.";
|
||||
if (!API_TYPES.includes(api)) return t("providers.validation.apiTypeRequired", "API type is required.");
|
||||
if (models.length === 0) return t("providers.validation.modelRequired", "At least one model is required.");
|
||||
if (models.some((model) => !model.id?.trim())) return t("providers.validation.modelId", "Each model must have a model ID.");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -167,49 +169,49 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="custom-provider-form" aria-label="custom-provider-form">
|
||||
<div className="form-group custom-provider-form__group">
|
||||
<label htmlFor="custom-provider-id">Provider ID</label>
|
||||
<label htmlFor="custom-provider-id">{t("providers.fields.id", "Provider ID")}</label>
|
||||
<input id="custom-provider-id" className="input" value={id} onChange={(e) => setId(e.target.value)} disabled={editing || saving} />
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form__group">
|
||||
<label htmlFor="custom-provider-name">Display Name</label>
|
||||
<label htmlFor="custom-provider-name">{t("providers.fields.name", "Display Name")}</label>
|
||||
<input id="custom-provider-name" className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form__group">
|
||||
<label htmlFor="custom-provider-base-url">Base URL</label>
|
||||
<label htmlFor="custom-provider-base-url">{t("providers.fields.baseUrl", "Base URL")}</label>
|
||||
<input id="custom-provider-base-url" className="input" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form__group">
|
||||
<label htmlFor="custom-provider-api">API Type</label>
|
||||
<label htmlFor="custom-provider-api">{t("providers.fields.apiType", "API Type")}</label>
|
||||
<select id="custom-provider-api" className="select" value={api} onChange={(e) => setApi(e.target.value as CustomProviderConfig["api"])} disabled={saving}>
|
||||
{API_TYPES.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form__group">
|
||||
<label htmlFor="custom-provider-api-key">API Key</label>
|
||||
<input id="custom-provider-api-key" className="input" placeholder="sk-..., MY_API_KEY, or !command" value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={saving} />
|
||||
<label htmlFor="custom-provider-api-key">{t("providers.fields.apiKey", "API Key")}</label>
|
||||
<input id="custom-provider-api-key" className="input" placeholder={t("providers.placeholders.apiKey", "sk-..., MY_API_KEY, or !command")} value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={saving} />
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form__group">
|
||||
<label>Models</label>
|
||||
<label>{t("providers.fields.models", "Models")}</label>
|
||||
<div className="custom-provider-form__models">
|
||||
{models.map((model, index) => (
|
||||
<div key={`${index}-model`} className="custom-provider-form__model-row">
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`Model ID ${index + 1}`}
|
||||
placeholder="Model ID"
|
||||
aria-label={`${t("providers.fields.modelId", "Model ID")} ${index + 1}`}
|
||||
placeholder={t("providers.fields.modelId", "Model ID")}
|
||||
value={model.id}
|
||||
onChange={(e) => updateModel(index, { id: e.target.value })}
|
||||
disabled={saving}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`Model name ${index + 1}`}
|
||||
placeholder="Display name"
|
||||
aria-label={`${t("providers.fields.modelName", "Display name")} ${index + 1}`}
|
||||
placeholder={t("providers.fields.modelName", "Display name")}
|
||||
value={model.name ?? ""}
|
||||
onChange={(e) => updateModel(index, { name: e.target.value })}
|
||||
disabled={saving}
|
||||
@@ -221,12 +223,12 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
onChange={(e) => updateModel(index, { reasoning: e.target.checked })}
|
||||
disabled={saving}
|
||||
/>
|
||||
Reasoning
|
||||
{t("providers.fields.reasoning", "Reasoning")}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`Context window ${index + 1}`}
|
||||
placeholder="Context window"
|
||||
aria-label={`${t("providers.fields.contextWindow", "Context window")} ${index + 1}`}
|
||||
placeholder={t("providers.fields.contextWindow", "Context window")}
|
||||
type="number"
|
||||
value={model.contextWindow ?? ""}
|
||||
onChange={(e) => updateModel(index, { contextWindow: e.target.value ? Number(e.target.value) : undefined })}
|
||||
@@ -234,8 +236,8 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`Max tokens ${index + 1}`}
|
||||
placeholder="Max tokens"
|
||||
aria-label={`${t("providers.fields.maxTokens", "Max tokens")} ${index + 1}`}
|
||||
placeholder={t("providers.fields.maxTokens", "Max tokens")}
|
||||
type="number"
|
||||
value={model.maxTokens ?? ""}
|
||||
onChange={(e) => updateModel(index, { maxTokens: e.target.value ? Number(e.target.value) : undefined })}
|
||||
@@ -246,7 +248,7 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => removeModel(index)}
|
||||
disabled={saving || !canRemoveModel}
|
||||
aria-label={`Remove model ${index + 1}`}
|
||||
aria-label={t("providers.actions.removeModel", "Remove model", { count: index + 1 })}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -255,22 +257,22 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
</div>
|
||||
<div className="custom-provider-form__model-actions" style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setModels((prev) => [...prev, emptyModel()])} disabled={saving}>
|
||||
+ Add model
|
||||
{t("providers.actions.addModel", "+ Add model")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleDetectModels()}
|
||||
disabled={saving || detecting || !baseUrl.trim()}
|
||||
title="Call the provider's /models endpoint to discover available models"
|
||||
title={t("providers.actions.detectModelsTitle", "Call the provider's /models endpoint to discover available models")}
|
||||
>
|
||||
{detecting ? (
|
||||
<>
|
||||
<Loader2 className="spin" size={14} /> Detecting…
|
||||
<Loader2 className="spin" size={14} /> {t("providers.actions.detecting", "Detecting…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={14} /> Detect Models
|
||||
<Search size={14} /> {t("providers.actions.detectModels", "Detect Models")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -281,8 +283,8 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f
|
||||
{mergedError ? <div className="form-error">{mergedError}</div> : null}
|
||||
|
||||
<div className="custom-provider-form__actions">
|
||||
{onCancel ? <button type="button" className="btn" onClick={onCancel} disabled={saving}>Cancel</button> : null}
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? "Saving..." : "Save Provider"}</button>
|
||||
{onCancel ? <button type="button" className="btn" onClick={onCancel} disabled={saving}>{t("actions.cancel", "Cancel")}</button> : null}
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? t("providers.actions.saving", "Saving...") : t("providers.actions.save", "Save Provider")}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
addCustomProvider,
|
||||
deleteCustomProvider,
|
||||
@@ -65,6 +66,7 @@ interface CustomProvidersSectionProps {
|
||||
}
|
||||
|
||||
export function CustomProvidersSection({ embedded = false, onProviderChange }: CustomProvidersSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [providers, setProviders] = useState<CustomProvider[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -89,11 +91,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
setProviders(normalizeProviders(response));
|
||||
setLoaded(true);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load custom providers.");
|
||||
setError(loadError instanceof Error ? loadError.message : t("providers.failedLoad", "Failed to load custom providers."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleDisclosureToggle = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
@@ -151,11 +153,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
|
||||
const validateForm = useCallback((): string | null => {
|
||||
if (!name.trim()) {
|
||||
return "Provider name is required.";
|
||||
return t("providers.nameRequired", "Provider name is required.");
|
||||
}
|
||||
|
||||
if (!baseUrl.trim()) {
|
||||
return "Base URL is required.";
|
||||
return t("providers.urlRequired", "Base URL is required.");
|
||||
}
|
||||
|
||||
let validProtocol = false;
|
||||
@@ -167,21 +169,21 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
}
|
||||
|
||||
if (!validProtocol) {
|
||||
return "Base URL must be a valid http/https URL.";
|
||||
return t("providers.urlInvalid", "Base URL must be a valid http/https URL.");
|
||||
}
|
||||
|
||||
if (!API_TYPES.includes(apiType)) {
|
||||
return "API type is invalid.";
|
||||
return t("providers.apiTypeInvalid", "API type is invalid.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [apiType, baseUrl, name]);
|
||||
}, [apiType, baseUrl, name, t]);
|
||||
|
||||
// Detect Models is available for all API types that expose a /models endpoint
|
||||
const handleDetectModels = useCallback(async () => {
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
setDetectError("Base URL is required to detect models.");
|
||||
setDetectError(t("providers.urlRequiredForDetect", "Base URL is required to detect models."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,16 +210,16 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
return newIds.join(", ") + (existing ? ", " + existing : "");
|
||||
});
|
||||
} else {
|
||||
setDetectError("No models found. The provider may require an API key.");
|
||||
setDetectError(t("providers.noModelsFound", "No models found. The provider may require an API key."));
|
||||
}
|
||||
} catch (err) {
|
||||
setDetectError(
|
||||
err instanceof Error ? err.message : "Failed to detect models",
|
||||
err instanceof Error ? err.message : t("providers.failedDetect", "Failed to detect models"),
|
||||
);
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [baseUrl, apiKey, apiType]);
|
||||
}, [baseUrl, apiKey, apiType, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const validationError = validateForm();
|
||||
@@ -246,15 +248,15 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
onProviderChange?.();
|
||||
resetForm();
|
||||
} catch (saveError) {
|
||||
setFormError(saveError instanceof Error ? saveError.message : "Failed to save provider.");
|
||||
setFormError(saveError instanceof Error ? saveError.message : t("providers.failedSave", "Failed to save provider."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [apiKey, apiType, baseUrl, editingProvider, loadProviders, models, name, resetForm, validateForm]);
|
||||
}, [apiKey, apiType, baseUrl, editingProvider, loadProviders, models, name, resetForm, validateForm, t]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (provider: CustomProvider) => {
|
||||
if (!window.confirm(`Delete custom provider "${provider.name}"?`)) return;
|
||||
if (!window.confirm(t("providers.deleteConfirm", `Delete custom provider "{{name}}"?`, { name: provider.name }))) return;
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
@@ -262,17 +264,17 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
await loadProviders();
|
||||
onProviderChange?.();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete provider.");
|
||||
setError(deleteError instanceof Error ? deleteError.message : t("providers.failedDelete", "Failed to delete provider."));
|
||||
}
|
||||
},
|
||||
[loadProviders, onProviderChange],
|
||||
[loadProviders, onProviderChange, t],
|
||||
);
|
||||
|
||||
const sectionContent = (
|
||||
<>
|
||||
{embedded ? null : loading ? (
|
||||
<div className="custom-provider-empty" role="status">
|
||||
<Loader2 aria-hidden="true" className="spin" /> Loading custom providers…
|
||||
<Loader2 aria-hidden="true" className="spin" /> {t("providers.loading", "Loading custom providers…")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -303,7 +305,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => openEditForm(provider)}
|
||||
aria-label={`Edit ${provider.name}`}
|
||||
aria-label={t("providers.editLabel", "Edit {{name}}", { name: provider.name })}
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
</button>
|
||||
@@ -311,7 +313,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => void handleDelete(provider)}
|
||||
aria-label={`Delete ${provider.name}`}
|
||||
aria-label={t("providers.deleteLabel", "Delete {{name}}", { name: provider.name })}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</button>
|
||||
@@ -321,7 +323,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
{isEditingThisProvider ? (
|
||||
<div className="custom-provider-form custom-provider-item-edit-form">
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-name">Provider name</label>
|
||||
<label htmlFor="custom-provider-name">{t("providers.nameLabel", "Provider name")}</label>
|
||||
<input
|
||||
id="custom-provider-name"
|
||||
className="input"
|
||||
@@ -332,7 +334,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-api-type">API type</label>
|
||||
<label htmlFor="custom-provider-api-type">{t("providers.apiTypeLabel", "API type")}</label>
|
||||
<select
|
||||
id="custom-provider-api-type"
|
||||
className="select"
|
||||
@@ -340,14 +342,14 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
onChange={(event) => setApiType(event.target.value as ProviderApiType)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic-compatible">Anthropic-compatible</option>
|
||||
<option value="openai-compatible">{t("providers.apiTypeOpenAi", "OpenAI-compatible")}</option>
|
||||
<option value="openai-responses">{t("providers.apiTypeOpenAiResp", "OpenAI Responses")}</option>
|
||||
<option value="anthropic-compatible">{t("providers.apiTypeAnthropic", "Anthropic-compatible")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-base-url">Base URL</label>
|
||||
<label htmlFor="custom-provider-base-url">{t("providers.baseUrlLabel", "Base URL")}</label>
|
||||
<input
|
||||
id="custom-provider-base-url"
|
||||
className="input"
|
||||
@@ -359,7 +361,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-api-key">API key</label>
|
||||
<label htmlFor="custom-provider-api-key">{t("providers.apiKeyLabel", "API key")}</label>
|
||||
<input
|
||||
id="custom-provider-api-key"
|
||||
type="password"
|
||||
@@ -371,7 +373,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-models">Available models</label>
|
||||
<label htmlFor="custom-provider-models">{t("providers.modelsLabel", "Available models")}</label>
|
||||
<input
|
||||
id="custom-provider-models"
|
||||
className="input"
|
||||
@@ -388,15 +390,15 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleDetectModels()}
|
||||
disabled={saving || detecting || !baseUrl.trim()}
|
||||
title="Auto-detect models from the provider's /models endpoint"
|
||||
title={t("providers.detectTitle", "Auto-detect models from the provider's /models endpoint")}
|
||||
>
|
||||
{detecting ? (
|
||||
<>
|
||||
<Loader2 className="spin" size={14} /> Detecting…
|
||||
<Loader2 className="spin" size={14} /> {t("providers.detecting", "Detecting…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={14} /> Detect Models
|
||||
<Search size={14} /> {t("providers.detectModels", "Detect Models")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -407,7 +409,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
|
||||
<div className="custom-provider-form-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={resetForm} disabled={saving}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -415,7 +417,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
{saving ? t("providers.saving", "Saving…") : t("providers.saveChanges", "Save Changes")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,17 +429,17 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
) : null}
|
||||
|
||||
{!loading && providers.length === 0 && !error ? (
|
||||
<div className="custom-provider-empty">No custom providers configured.</div>
|
||||
<div className="custom-provider-empty">{t("providers.noneConfigured", "No custom providers configured.")}</div>
|
||||
) : null}
|
||||
|
||||
<button type="button" className="btn btn-sm custom-provider-add-btn" onClick={openAddForm}>
|
||||
<Plus aria-hidden="true" /> Add Custom Provider
|
||||
<Plus aria-hidden="true" /> {t("providers.addCustom", "Add Custom Provider")}
|
||||
</button>
|
||||
|
||||
{isFormOpen && !editingProvider ? (
|
||||
<div className="custom-provider-form">
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-name">Provider name</label>
|
||||
<label htmlFor="custom-provider-name">{t("providers.nameLabel", "Provider name")}</label>
|
||||
<input
|
||||
id="custom-provider-name"
|
||||
className="input"
|
||||
@@ -448,7 +450,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-api-type">API type</label>
|
||||
<label htmlFor="custom-provider-api-type">{t("providers.apiTypeLabel", "API type")}</label>
|
||||
<select
|
||||
id="custom-provider-api-type"
|
||||
className="select"
|
||||
@@ -456,14 +458,14 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
onChange={(event) => setApiType(event.target.value as ProviderApiType)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic-compatible">Anthropic-compatible</option>
|
||||
<option value="openai-compatible">{t("providers.apiTypeOpenAi", "OpenAI-compatible")}</option>
|
||||
<option value="openai-responses">{t("providers.apiTypeOpenAiResp", "OpenAI Responses")}</option>
|
||||
<option value="anthropic-compatible">{t("providers.apiTypeAnthropic", "Anthropic-compatible")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-base-url">Base URL</label>
|
||||
<label htmlFor="custom-provider-base-url">{t("providers.baseUrlLabel", "Base URL")}</label>
|
||||
<input
|
||||
id="custom-provider-base-url"
|
||||
className="input"
|
||||
@@ -475,7 +477,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-api-key">API key</label>
|
||||
<label htmlFor="custom-provider-api-key">{t("providers.apiKeyLabel", "API key")}</label>
|
||||
<input
|
||||
id="custom-provider-api-key"
|
||||
type="password"
|
||||
@@ -487,7 +489,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
|
||||
<div className="form-group custom-provider-form-row">
|
||||
<label htmlFor="custom-provider-models">Available models</label>
|
||||
<label htmlFor="custom-provider-models">{t("providers.modelsLabel", "Available models")}</label>
|
||||
<input
|
||||
id="custom-provider-models"
|
||||
className="input"
|
||||
@@ -504,15 +506,15 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handleDetectModels()}
|
||||
disabled={saving || detecting || !baseUrl.trim()}
|
||||
title="Auto-detect models from the provider's /models endpoint"
|
||||
title={t("providers.detectTitle", "Auto-detect models from the provider's /models endpoint")}
|
||||
>
|
||||
{detecting ? (
|
||||
<>
|
||||
<Loader2 className="spin" size={14} /> Detecting…
|
||||
<Loader2 className="spin" size={14} /> {t("providers.detecting", "Detecting…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={14} /> Detect Models
|
||||
<Search size={14} /> {t("providers.detectModels", "Detect Models")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -523,7 +525,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
|
||||
<div className="custom-provider-form-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={resetForm} disabled={saving}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -531,7 +533,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Provider"}
|
||||
{saving ? t("providers.saving", "Saving…") : t("providers.saveProvider", "Save Provider")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { consumeVersionUpdateFlag } from "../versionCheck";
|
||||
import { SWR_CACHE_KEYS, clearCache } from "../utils/swrCache";
|
||||
import "./DashboardLoader.css";
|
||||
@@ -16,9 +17,9 @@ interface LoaderStep {
|
||||
}
|
||||
|
||||
const LOADER_STEPS: LoaderStep[] = [
|
||||
{ id: "projects", label: "Loading projects" },
|
||||
{ id: "project", label: "Selecting project" },
|
||||
{ id: "tasks", label: "Fetching tasks" },
|
||||
{ id: "projects", label: "dashboard.loaderSteps.projects" },
|
||||
{ id: "project", label: "dashboard.loaderSteps.project" },
|
||||
{ id: "tasks", label: "dashboard.loaderSteps.tasks" },
|
||||
];
|
||||
|
||||
function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "done" | "active" | "pending" {
|
||||
@@ -41,6 +42,7 @@ function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "d
|
||||
}
|
||||
|
||||
export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [isVersionUpdate] = useState(() => {
|
||||
const versionUpdated = consumeVersionUpdateFlag();
|
||||
if (versionUpdated) {
|
||||
@@ -78,7 +80,7 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||
className="dashboard-loader"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={isVersionUpdate ? "Updating Fusion dashboard" : "Loading Fusion dashboard"}
|
||||
aria-label={isVersionUpdate ? t("dashboard.updatingMessage", "Updating Fusion dashboard") : t("dashboard.loadingMessage", "Loading Fusion dashboard")}
|
||||
data-stage={stage}
|
||||
data-version-update={isVersionUpdate ? "true" : undefined}
|
||||
>
|
||||
@@ -86,13 +88,13 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||
<h1 className="dashboard-loader__logo">Fusion</h1>
|
||||
{isVersionUpdate ? (
|
||||
<p className="dashboard-loader__message dashboard-loader__message--update">
|
||||
Updating to a new frontend version...
|
||||
{t("dashboard.updatingVersion", "Updating to a new frontend version...")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="dashboard-loader__message">Initializing dashboard...</p>
|
||||
<p className="dashboard-loader__message">{t("dashboard.initializingDashboard", "Initializing dashboard...")}</p>
|
||||
)}
|
||||
|
||||
<ol className="dashboard-loader__steps" aria-label="Dashboard loading progress">
|
||||
<ol className="dashboard-loader__steps" aria-label={t("dashboard.loadingProgress", "Dashboard loading progress")}>
|
||||
{LOADER_STEPS.map((step) => {
|
||||
const stepState = getStepState(step.id, stage);
|
||||
|
||||
@@ -111,7 +113,7 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||
"•"
|
||||
)}
|
||||
</span>
|
||||
<span className="dashboard-loader__step-label">{step.label}</span>
|
||||
<span className="dashboard-loader__step-label">{t(step.label, step.label === "dashboard.loaderSteps.projects" ? "Loading projects" : step.label === "dashboard.loaderSteps.project" ? "Selecting project" : "Fetching tasks")}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { AgentEmptyState } from "./AgentEmptyState";
|
||||
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
|
||||
@@ -16,12 +17,13 @@ export interface DataBoundaryProps {
|
||||
}
|
||||
|
||||
function DefaultErrorFallback({ error }: { error: unknown }) {
|
||||
const { t } = useTranslation("app");
|
||||
return (
|
||||
<div className="agent-empty" data-testid="data-boundary-error">
|
||||
<AlertCircle className="agent-empty-state__icon" size={48} opacity={0.3} />
|
||||
<p className="agent-empty-state__title">Unable to load data</p>
|
||||
<p className="agent-empty-state__title">{t("common.unableToLoadData", "Unable to load data")}</p>
|
||||
<p className="agent-empty-state__description text-secondary">
|
||||
{getErrorMessage(error) || "Something went wrong while loading this view."}
|
||||
{getErrorMessage(error) || t("common.somethingWentWrong", "Something went wrong while loading this view.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -37,6 +39,7 @@ export function DataBoundary({
|
||||
errorFallback,
|
||||
children,
|
||||
}: DataBoundaryProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (error) {
|
||||
return <>{errorFallback ?? <DefaultErrorFallback error={error} />}</>;
|
||||
}
|
||||
@@ -51,8 +54,8 @@ export function DataBoundary({
|
||||
<>
|
||||
{emptyFallback ?? (
|
||||
<AgentEmptyState
|
||||
title="No data available"
|
||||
description="There is nothing to show yet."
|
||||
title={t("common.noDataAvailable", "No data available")}
|
||||
description={t("common.nothingToShow", "There is nothing to show yet.")}
|
||||
ctaLabel=""
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import "./DbCorruptionBanner.css";
|
||||
|
||||
@@ -17,6 +18,7 @@ export function DbCorruptionBanner({
|
||||
refreshing,
|
||||
refreshError,
|
||||
}: DbCorruptionBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -31,9 +33,9 @@ export function DbCorruptionBanner({
|
||||
<span className="status-dot status-dot--error" aria-hidden="true" />
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<div className="db-corruption-banner__headline-copy">
|
||||
<h2 className="db-corruption-banner__headline">Database corruption detected</h2>
|
||||
<h2 className="db-corruption-banner__headline">{t("dbBanner.title", "Database corruption detected")}</h2>
|
||||
{checkedAtLabel ? (
|
||||
<p className="db-corruption-banner__meta">Last checked: {checkedAtLabel}</p>
|
||||
<p className="db-corruption-banner__meta">{t("dbBanner.lastChecked", "Last checked: {{checkedAtLabel}}", { checkedAtLabel })}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,12 +48,12 @@ export function DbCorruptionBanner({
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={refreshing ? "db-corruption-banner__refresh-icon db-corruption-banner__refresh-icon--spinning" : "db-corruption-banner__refresh-icon"} aria-hidden="true" />
|
||||
{refreshing ? "Refreshing…" : "Refresh health"}
|
||||
{refreshing ? t("dbBanner.refreshing", "Refreshing…") : t("dbBanner.refreshHealth", "Refresh health")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="db-corruption-banner__body">
|
||||
Fusion's background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.
|
||||
{t("dbBanner.body", "Fusion's background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.")}
|
||||
</p>
|
||||
|
||||
<ul className="db-corruption-banner__list">
|
||||
@@ -63,10 +65,8 @@ export function DbCorruptionBanner({
|
||||
</ul>
|
||||
|
||||
<p className="db-corruption-banner__footer">
|
||||
<strong className="db-corruption-banner__footer-label">What to do:</strong>{" "}
|
||||
Back up the project, try <code className="db-corruption-banner__inline-code">fn db --vacuum</code> if the database still opens cleanly, and restore from a known-good backup if corruption persists. See{" "}
|
||||
<a href="docs/storage.md" target="_blank" rel="noreferrer" className="db-corruption-banner__link">docs/storage.md</a>
|
||||
{" "}for the storage layout and recovery guidance.
|
||||
<strong className="db-corruption-banner__footer-label">{t("dbBanner.whatToDo", "What to do:")}</strong>{" "}
|
||||
{t("dbBanner.instructions", "Back up the project, try {{cmd}} if the database still opens cleanly, and restore from a known-good backup if corruption persists. See {{link}} for the storage layout and recovery guidance.", { cmd: "fn db --vacuum", link: "docs/storage.md" })}
|
||||
</p>
|
||||
{refreshError ? <p className="db-corruption-banner__error">{refreshError}</p> : null}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type PropsWithChildren } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ShellConnectionState } from "../types/native-shell";
|
||||
import "./DesktopLaunchGate.css";
|
||||
|
||||
@@ -61,6 +62,7 @@ function applyServerBaseUrl(baseUrl: string): void {
|
||||
}
|
||||
|
||||
export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation("app");
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,7 +97,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
setPhase({ kind: "ready", serverBaseUrl: params.get("serverBaseUrl") ?? undefined });
|
||||
return;
|
||||
}
|
||||
setPhase({ kind: "starting-local", message: "Starting local Fusion runtime…" });
|
||||
setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") });
|
||||
const { baseUrl } = await waitForLocalRuntime(shell);
|
||||
if (cancelled) return;
|
||||
applyServerBaseUrl(baseUrl);
|
||||
@@ -139,7 +141,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
}, []);
|
||||
|
||||
if (phase.kind === "loading" || phase.kind === "starting-local") {
|
||||
const message = phase.kind === "loading" ? "Loading Fusion…" : phase.message;
|
||||
const message = phase.kind === "loading" ? t("desktop.loading", "Loading Fusion…") : phase.message;
|
||||
return (
|
||||
<div className="desktop-launch-gate" role="status" aria-live="polite">
|
||||
<div className="desktop-launch-gate__panel">
|
||||
@@ -153,7 +155,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className="desktop-launch-gate" role="alert">
|
||||
<div className="desktop-launch-gate__panel">
|
||||
<h2>Couldn't start local Fusion</h2>
|
||||
<h2>{t("desktop.couldNotStart", "Couldn't start local Fusion")}</h2>
|
||||
<p>{phase.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -162,7 +164,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
{t("desktop.retry", "Retry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +179,7 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
if (!shell) return;
|
||||
setPhase({
|
||||
kind: "starting-local",
|
||||
message: mode === "local" ? "Starting local Fusion runtime…" : "Setting up remote connection…",
|
||||
message: mode === "local" ? t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") : t("desktop.settingUpRemote", "Setting up remote connection…"),
|
||||
});
|
||||
try {
|
||||
await shell.setDesktopMode(mode);
|
||||
@@ -205,14 +207,14 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
}
|
||||
|
||||
function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") => void }) {
|
||||
const { t } = useTranslation("app");
|
||||
const [pending, setPending] = useState<"local" | "remote" | null>(null);
|
||||
return (
|
||||
<div className="desktop-launch-gate" role="dialog" aria-labelledby="desktop-launch-gate-title">
|
||||
<div className="desktop-launch-gate__panel">
|
||||
<h1 id="desktop-launch-gate-title">How do you want to run Fusion?</h1>
|
||||
<h1 id="desktop-launch-gate-title">{t("desktop.chooseMode", "How do you want to run Fusion?")}</h1>
|
||||
<p>
|
||||
Run Fusion locally in this app, or connect to a Fusion server you're already running
|
||||
somewhere else.
|
||||
{t("desktop.chooseModeDescription", "Run Fusion locally in this app, or connect to a Fusion server you're already running somewhere else.")}
|
||||
</p>
|
||||
<div className="desktop-launch-gate__actions">
|
||||
<button
|
||||
@@ -224,7 +226,7 @@ function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") =>
|
||||
onPick("local");
|
||||
}}
|
||||
>
|
||||
{pending === "local" ? "Starting…" : "Run Fusion Locally"}
|
||||
{pending === "local" ? t("desktop.starting", "Starting…") : t("desktop.runLocalButton", "Run Fusion Locally")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -235,7 +237,7 @@ function DesktopModeChooser({ onPick }: { onPick: (mode: "local" | "remote") =>
|
||||
onPick("remote");
|
||||
}}
|
||||
>
|
||||
{pending === "remote" ? "Opening…" : "Connect to Remote Fusion"}
|
||||
{pending === "remote" ? t("desktop.opening", "Opening…") : t("desktop.connectRemoteButton", "Connect to Remote Fusion")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react";
|
||||
import "./DevServerLogViewer.css";
|
||||
import type { DevServerLogEntry } from "../hooks/useDevServerLogs";
|
||||
@@ -93,6 +94,7 @@ export function DevServerLogViewer({
|
||||
onLoadMore,
|
||||
isRunning,
|
||||
}: DevServerLogViewerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevEntryCountRef = useRef(entries.length);
|
||||
const prevRunningRef = useRef(isRunning);
|
||||
@@ -170,7 +172,7 @@ export function DevServerLogViewer({
|
||||
<section className="devserver-log-viewer" data-testid="devserver-log-viewer">
|
||||
<div className="devserver-log-viewer__loading" data-testid="devserver-log-loading">
|
||||
<Loader2 size={16} className="devserver-log-viewer__spinner" />
|
||||
<span>Loading logs…</span>
|
||||
<span>{t("devserver.loadingLogs", "Loading logs…")}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -183,32 +185,32 @@ export function DevServerLogViewer({
|
||||
>
|
||||
<header className="devserver-log-viewer__toolbar">
|
||||
<div className="devserver-log-viewer__toolbar-meta">
|
||||
<span className="devserver-log-viewer__title">Logs</span>
|
||||
<span className="devserver-log-viewer__title">{t("devserver.logs", "Logs")}</span>
|
||||
<span className="devserver-log-viewer__count" data-testid="devserver-log-count">
|
||||
{total !== null ? `${entries.length}/${total}` : `${entries.length}`} lines
|
||||
{total !== null ? `${entries.length}/${total}` : `${entries.length}`} {t("devserver.lines", "lines")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="devserver-log-viewer__toolbar-actions">
|
||||
<label className="devserver-log-viewer__severity" htmlFor="devserver-log-severity-filter">
|
||||
<span className="visually-hidden">Filter logs by severity</span>
|
||||
<span className="visually-hidden">{t("devserver.filterBySeverity", "Filter logs by severity")}</span>
|
||||
<select
|
||||
id="devserver-log-severity-filter"
|
||||
className="select devserver-log-viewer__severity-select"
|
||||
value={severityFilter}
|
||||
onChange={(event) => setSeverityFilter(event.target.value as LogSeverityFilter)}
|
||||
data-testid="devserver-log-severity-filter"
|
||||
aria-label="Filter logs by severity"
|
||||
aria-label={t("devserver.filterBySeverity", "Filter logs by severity")}
|
||||
>
|
||||
<option value="all">All severities</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warn</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="all">{t("devserver.allSeverities", "All severities")}</option>
|
||||
<option value="info">{t("devserver.info", "Info")}</option>
|
||||
<option value="warn">{t("devserver.warn", "Warn")}</option>
|
||||
<option value="error">{t("devserver.error", "Error")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="devserver-log-viewer__search" htmlFor="devserver-log-search">
|
||||
<span className="visually-hidden">Search logs</span>
|
||||
<span className="visually-hidden">{t("devserver.searchLogs", "Search logs")}</span>
|
||||
<Search size={14} />
|
||||
<input
|
||||
id="devserver-log-search"
|
||||
@@ -216,15 +218,15 @@ export function DevServerLogViewer({
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search logs"
|
||||
placeholder={t("devserver.searchLogs", "Search logs")}
|
||||
data-testid="devserver-log-search-input"
|
||||
aria-label="Search logs"
|
||||
aria-label={t("devserver.searchLogs", "Search logs")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{searchQuery.trim().length > 0 && (
|
||||
<span className="devserver-log-viewer__matches" data-testid="devserver-log-match-count">
|
||||
{matchCount} match{matchCount === 1 ? "" : "es"}
|
||||
{t("devserver.matchCount", { count: matchCount, defaultValue_one: "{{count}} match", defaultValue_other: "{{count}} matches" })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -233,7 +235,7 @@ export function DevServerLogViewer({
|
||||
className="btn btn-sm btn-icon"
|
||||
onClick={() => setIsFullscreen((prev) => !prev)}
|
||||
data-testid="devserver-log-fullscreen-toggle"
|
||||
aria-label={isFullscreen ? "Exit fullscreen logs" : "Enter fullscreen logs"}
|
||||
aria-label={isFullscreen ? t("devserver.exitFullscreen", "Exit fullscreen logs") : t("devserver.enterFullscreen", "Enter fullscreen logs")}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
@@ -253,10 +255,10 @@ export function DevServerLogViewer({
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={14} className="devserver-log-viewer__spinner" />
|
||||
Loading older logs…
|
||||
{t("devserver.loadingOlderLogs", "Loading older logs…")}
|
||||
</>
|
||||
) : (
|
||||
"Load older logs"
|
||||
t("devserver.loadOlderLogs", "Load older logs")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -271,10 +273,10 @@ export function DevServerLogViewer({
|
||||
{!loading && filteredEntries.length === 0 && (
|
||||
<p className="devserver-log-viewer__empty" data-testid="devserver-log-empty">
|
||||
{entries.length === 0
|
||||
? "No logs yet. Start the dev server to see output."
|
||||
? t("devserver.noLogsYet", "No logs yet. Start the dev server to see output.")
|
||||
: (filteredBySeverity.length === 0
|
||||
? "No log lines match the selected severity."
|
||||
: "No log lines match your search.")}
|
||||
? t("devserver.noMatchesSeverity", "No log lines match the selected severity.")
|
||||
: t("devserver.noMatchesSearch", "No log lines match your search."))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -304,7 +306,7 @@ export function DevServerLogViewer({
|
||||
data-testid="devserver-log-jump-button"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
New logs
|
||||
{t("devserver.newLogs", "New logs")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
|
||||
import "./DevServerView.css";
|
||||
import type { DetectedDevServerCommand } from "../api";
|
||||
@@ -22,13 +24,15 @@ interface StatusBadgeConfig {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CONFIG: Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> = {
|
||||
stopped: { className: "dev-server-status-badge--stopped", label: "Stopped" },
|
||||
starting: { className: "dev-server-status-badge--starting", label: "Starting..." },
|
||||
running: { className: "dev-server-status-badge--running", label: "Running" },
|
||||
stopping: { className: "dev-server-status-badge--starting", label: "Stopping..." },
|
||||
failed: { className: "dev-server-status-badge--failed", label: "Failed" },
|
||||
};
|
||||
function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> {
|
||||
return {
|
||||
stopped: { className: "dev-server-status-badge--stopped", label: t("devserver.status.stopped", "Stopped") },
|
||||
starting: { className: "dev-server-status-badge--starting", label: t("devserver.status.starting", "Starting...") },
|
||||
running: { className: "dev-server-status-badge--running", label: t("devserver.status.running", "Running") },
|
||||
stopping: { className: "dev-server-status-badge--starting", label: t("devserver.status.stopping", "Stopping...") },
|
||||
failed: { className: "dev-server-status-badge--failed", label: t("devserver.status.failed", "Failed") },
|
||||
};
|
||||
}
|
||||
|
||||
let devServerViewWasPreviouslyInactive = false;
|
||||
|
||||
@@ -82,6 +86,8 @@ function truncateCommand(command: string): string {
|
||||
}
|
||||
|
||||
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
useEffect(() => {
|
||||
recordResumeEvent({
|
||||
view: "DevServerView",
|
||||
@@ -118,7 +124,8 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
|
||||
const status = session?.status ?? "stopped";
|
||||
const isRunning = status === "running" || status === "starting";
|
||||
const statusBadge = STATUS_BADGE_CONFIG[status] ?? STATUS_BADGE_CONFIG.stopped;
|
||||
const statusBadgeConfig = getStatusBadgeConfig(t);
|
||||
const statusBadge = statusBadgeConfig[status] ?? statusBadgeConfig.stopped;
|
||||
|
||||
const {
|
||||
entries: logEntries,
|
||||
@@ -361,7 +368,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
<section className="dev-server-header" aria-label="Dev server controls header">
|
||||
<div className="dev-server-header-title">
|
||||
<Monitor size={16} />
|
||||
<h2>Dev Server</h2>
|
||||
<h2>{t("devserver.title", "Dev Server")}</h2>
|
||||
<span
|
||||
className={`dev-server-status-badge ${statusBadge.className}`}
|
||||
data-testid="dev-server-status-badge"
|
||||
@@ -378,7 +385,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
data-testid="dev-server-start-button"
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>{actionInFlight === "start" ? "Starting..." : "Start"}</span>
|
||||
<span>{actionInFlight === "start" ? t("devserver.starting", "Starting...") : t("devserver.start", "Start")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -388,7 +395,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
data-testid="dev-server-stop-button"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>{actionInFlight === "stop" ? "Stopping..." : "Stop"}</span>
|
||||
<span>{actionInFlight === "stop" ? t("devserver.stopping", "Stopping...") : t("devserver.stop", "Stop")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -398,33 +405,33 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
data-testid="dev-server-restart-button"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
<span>{actionInFlight === "restart" ? "Restarting..." : "Restart"}</span>
|
||||
<span>{actionInFlight === "restart" ? t("devserver.restarting", "Restarting...") : t("devserver.restart", "Restart")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dev-server-panel dev-server-config" aria-label="Dev server configuration">
|
||||
<div className="dev-server-section-header">
|
||||
<h3>Configuration</h3>
|
||||
{isLoading && <span className="dev-server-muted">Loading...</span>}
|
||||
<h3>{t("devserver.configuration", "Configuration")}</h3>
|
||||
{isLoading && <span className="dev-server-muted">{t("devserver.loading", "Loading...")}</span>}
|
||||
</div>
|
||||
|
||||
{isLoading && !session && detectedCommands.length === 0 && (
|
||||
<div className="dev-server-loading-state" data-testid="dev-server-loading-state">
|
||||
<Loader2 size={16} className="dev-server-spin" />
|
||||
<span>Loading dev server configuration...</span>
|
||||
<span>{t("devserver.loadingConfig", "Loading dev server configuration...")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="dev-server-error-box" role="alert" data-testid="dev-server-error-box">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="btn btn-sm" onClick={handleRetry}>Retry</button>
|
||||
<button type="button" className="btn btn-sm" onClick={handleRetry}>{t("devserver.retry", "Retry")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dev-server-section">
|
||||
<h3>Script Selection</h3>
|
||||
<h3>{t("devserver.scriptSelection", "Script Selection")}</h3>
|
||||
|
||||
{selectedScript && (
|
||||
<div className="dev-server-selected" data-testid="dev-server-selected-summary">
|
||||
@@ -436,7 +443,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
onClick={() => setShowCandidates(true)}
|
||||
data-testid="dev-server-change-selection"
|
||||
>
|
||||
Change
|
||||
{t("devserver.change", "Change")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -444,14 +451,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
onClick={handleClearSelection}
|
||||
data-testid="dev-server-clear-selection"
|
||||
>
|
||||
Clear
|
||||
{t("devserver.clear", "Clear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCandidates && detectedCommands.length === 0 && (
|
||||
<p className="dev-server-empty-state" data-testid="dev-server-empty-candidates">
|
||||
No dev server scripts detected. Check that your project has a <code>package.json</code> with a <code>dev</code>, <code>start</code>, or similar script.
|
||||
{t("devserver.noScriptsDetected", "No dev server scripts detected. Check that your project has a package.json with a dev, start, or similar script.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -478,7 +485,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
</div>
|
||||
|
||||
<div className="dev-server-field-group">
|
||||
<label htmlFor="dev-server-command" className="dev-server-label">Command</label>
|
||||
<label htmlFor="dev-server-command" className="dev-server-label">{t("devserver.command", "Command")}</label>
|
||||
<input
|
||||
id="dev-server-command"
|
||||
className="input"
|
||||
@@ -492,13 +499,13 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
|
||||
{(status === "running" || status === "starting") && session && (
|
||||
<div className="dev-server-current-command" data-testid="dev-server-current-command">
|
||||
<span className="dev-server-label">Running command</span>
|
||||
<span className="dev-server-label">{t("devserver.runningCommand", "Running command")}</span>
|
||||
<code>{session.config?.command ?? commandInput}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dev-server-preview-override">
|
||||
<label htmlFor="dev-server-preview-input" className="dev-server-label">Preview URL Override</label>
|
||||
<label htmlFor="dev-server-preview-input" className="dev-server-label">{t("devserver.previewUrlOverride", "Preview URL Override")}</label>
|
||||
<input
|
||||
id="dev-server-preview-input"
|
||||
className="input"
|
||||
@@ -515,20 +522,20 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
disabled={actionInFlight === "preview"}
|
||||
data-testid="dev-server-set-preview"
|
||||
>
|
||||
Save
|
||||
{t("devserver.save", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{effectivePreviewUrl && (
|
||||
<p className="dev-server-preview-hint">Auto-detected: {effectivePreviewUrl}</p>
|
||||
<p className="dev-server-preview-hint">{t("devserver.autoDetected", "Auto-detected: {{url}}", { url: effectivePreviewUrl })}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="dev-server-content">
|
||||
<section className="dev-server-panel dev-server-logs-panel" data-testid="dev-server-logs-panel" aria-label="Dev server logs">
|
||||
<div className="dev-server-section-header">
|
||||
<h3>Logs</h3>
|
||||
<span className="dev-server-muted">{logsTotal ?? logEntries.length} lines</span>
|
||||
<h3>{t("devserver.logs", "Logs")}</h3>
|
||||
<span className="dev-server-muted">{t("devserver.lines", "{{count}} lines", { count: logsTotal ?? logEntries.length })}</span>
|
||||
</div>
|
||||
<div className="dev-server-logs-viewer" data-testid="dev-server-log-viewer">
|
||||
<DevServerLogViewer
|
||||
@@ -548,15 +555,15 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>Preview</span>
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
|
||||
title={effectivePreviewUrl ?? "No preview URL"}
|
||||
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
|
||||
data-testid="devserver-preview-url-badge"
|
||||
>
|
||||
{isManualPreviewOverride ? "Manual" : "Auto"}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : " · Not available"}
|
||||
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
|
||||
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
|
||||
</span>
|
||||
<div className="devserver-preview-actions">
|
||||
<button
|
||||
@@ -565,12 +572,12 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
|
||||
data-testid="devserver-preview-mode-toggle"
|
||||
>
|
||||
{previewMode === "embedded" ? "External only" : "Embedded"}
|
||||
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title="Open in new tab"
|
||||
title={t("devserver.openInNewTab", "Open in new tab")}
|
||||
onClick={handleOpenInNewTab}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-open-tab"
|
||||
@@ -580,7 +587,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-icon"
|
||||
title="Refresh preview"
|
||||
title={t("devserver.refreshPreview", "Refresh preview")}
|
||||
onClick={handleRefreshPreview}
|
||||
disabled={!effectivePreviewUrl}
|
||||
data-testid="devserver-preview-refresh"
|
||||
@@ -592,23 +599,23 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
|
||||
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
|
||||
{!effectivePreviewUrl && !isRunning && (
|
||||
<p className="devserver-preview-empty">Start a dev server to see a live preview here.</p>
|
||||
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
|
||||
)}
|
||||
|
||||
{!effectivePreviewUrl && isRunning && (
|
||||
<p className="devserver-preview-empty">No preview URL detected. Start the dev server or set a manual URL to preview your app.</p>
|
||||
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "external" && (
|
||||
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
|
||||
<p>Embedded preview is disabled. Open your app in a separate browser tab.</p>
|
||||
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
>
|
||||
Open in new tab
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -624,12 +631,12 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? "Preview failed" : "Preview blocked"}
|
||||
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
|
||||
</p>
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
Open the preview in a new tab, or retry embedded mode after checking your server settings.
|
||||
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
|
||||
</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
<button
|
||||
@@ -638,7 +645,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-fallback-open-tab"
|
||||
>
|
||||
Open preview in new tab
|
||||
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -646,7 +653,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
onClick={handleRetryEmbeddedPreview}
|
||||
data-testid="devserver-preview-fallback-retry"
|
||||
>
|
||||
Retry embedded preview
|
||||
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { browseDirectory, type BrowseDirectoryResult } from "../api";
|
||||
import { getPathBreadcrumbs } from "../utils/pathDisplay";
|
||||
@@ -27,6 +28,7 @@ interface BrowserState {
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId }: DirectoryPickerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [browser, setBrowser] = useState<BrowserState>({
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
@@ -111,21 +113,21 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder={placeholder || "/path/to/your/project"}
|
||||
placeholder={placeholder || t("dirPicker.defaultPlaceholder", "/path/to/your/project")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm directory-picker-browse-btn"
|
||||
onClick={handleToggleBrowser}
|
||||
aria-label={browser.isOpen ? "Close directory browser" : "Browse directories"}
|
||||
aria-label={browser.isOpen ? t("dirPicker.closeBrowser", "Close directory browser") : t("dirPicker.openBrowser", "Browse directories")}
|
||||
>
|
||||
{browser.isOpen ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span>Browse</span>
|
||||
<span>{t("dirPicker.browse", "Browse")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{browser.isOpen && (
|
||||
<div className="directory-picker-browser" role="tree" aria-label="Directory browser">
|
||||
<div className="directory-picker-browser" role="tree" aria-label={t("dirPicker.ariaLabel", "Directory browser")}>
|
||||
{/* Breadcrumbs */}
|
||||
<div className="directory-picker-breadcrumbs">
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
@@ -152,22 +154,22 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary directory-picker-up-btn"
|
||||
onClick={() => handleNavigate(browser.parentPath!)}
|
||||
aria-label="Go to parent directory"
|
||||
title="Parent directory"
|
||||
aria-label={t("dirPicker.parentDir", "Go to parent directory")}
|
||||
title={t("dirPicker.parentDirTitle", "Parent directory")}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
<span>Up</span>
|
||||
<span>{t("dirPicker.up", "Up")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary directory-picker-hidden-toggle"
|
||||
onClick={handleToggleHidden}
|
||||
aria-label={browser.showHidden ? "Hide hidden directories" : "Show hidden directories"}
|
||||
title={browser.showHidden ? "Hide hidden" : "Show hidden"}
|
||||
aria-label={browser.showHidden ? t("dirPicker.hideHiddenAria", "Hide hidden directories") : t("dirPicker.showHiddenAria", "Show hidden directories")}
|
||||
title={browser.showHidden ? t("dirPicker.hideHiddenTitle", "Hide hidden") : t("dirPicker.showHiddenTitle", "Show hidden")}
|
||||
>
|
||||
{browser.showHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
<span>{browser.showHidden ? "Hide hidden" : "Show hidden"}</span>
|
||||
<span>{browser.showHidden ? t("dirPicker.hideHidden", "Hide hidden") : t("dirPicker.showHidden", "Show hidden")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +177,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
{browser.loading ? (
|
||||
<div className="directory-picker-loading">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>Loading…</span>
|
||||
<span>{t("dirPicker.loading", "Loading…")}</span>
|
||||
</div>
|
||||
) : browser.error ? (
|
||||
<div className="directory-picker-error">
|
||||
@@ -185,7 +187,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
) : (
|
||||
<div className="directory-picker-entries">
|
||||
{browser.entries.length === 0 ? (
|
||||
<div className="directory-picker-empty">No subdirectories</div>
|
||||
<div className="directory-picker-empty">{t("dirPicker.noSubdirs", "No subdirectories")}</div>
|
||||
) : (
|
||||
browser.entries.map((entry) => (
|
||||
<button
|
||||
@@ -217,7 +219,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
className="btn btn-primary directory-picker-select-btn"
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Select
|
||||
{t("dirPicker.select", "Select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DockerTargetSelector } from "./DockerTargetSelector";
|
||||
@@ -33,6 +34,7 @@ interface MountRow {
|
||||
const DEFAULT_URL = "http://localhost:4040";
|
||||
|
||||
export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [name, setName] = useState("");
|
||||
const [hostConfig, setHostConfig] = useState<DockerHostConfig>({});
|
||||
const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL);
|
||||
@@ -174,16 +176,16 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
|
||||
const nextErrors: FormErrors = {};
|
||||
if (!input.name || input.name.length > 64) {
|
||||
nextErrors.name = "Name is required and must be 64 characters or fewer";
|
||||
nextErrors.name = t("docker.errors.nameRequired", "Name is required and must be 64 characters or fewer");
|
||||
}
|
||||
if (!input.reachableUrl) {
|
||||
nextErrors.reachableUrl = "URL is required";
|
||||
nextErrors.reachableUrl = t("docker.errors.urlRequired", "URL is required");
|
||||
}
|
||||
if (memoryMB < 512) {
|
||||
nextErrors.memoryMB = "Memory must be at least 512 MB";
|
||||
nextErrors.memoryMB = t("docker.errors.memoryMinimum", "Memory must be at least 512 MB");
|
||||
}
|
||||
if (cpus < 0.5) {
|
||||
nextErrors.cpus = "CPUs must be at least 0.5";
|
||||
nextErrors.cpus = t("docker.errors.cpusMinimum", "CPUs must be at least 0.5");
|
||||
}
|
||||
|
||||
setErrors(nextErrors);
|
||||
@@ -200,7 +202,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [closeModal, cpus, input, memoryMB, onSubmit, submitting]);
|
||||
}, [closeModal, cpus, input, memoryMB, onSubmit, submitting, t]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -210,28 +212,28 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
className="modal docker-onboarding"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Docker node onboarding"
|
||||
aria-label={t("docker.ariaLabels.modal", "Docker node onboarding")}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Provision Docker Node</h3>
|
||||
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label="Close onboarding modal">
|
||||
<h3>{t("docker.titles.provisionNode", "Provision Docker Node")}</h3>
|
||||
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label={t("docker.ariaLabels.closeModal", "Close onboarding modal")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body docker-onboarding__body">
|
||||
<section className="docker-onboarding__section">
|
||||
<h4 className="docker-onboarding__section-title">Required Settings</h4>
|
||||
<h4 className="docker-onboarding__section-title">{t("docker.sections.requiredSettings", "Required Settings")}</h4>
|
||||
|
||||
<label className="docker-onboarding__field">
|
||||
<span>Node Name</span>
|
||||
<span>{t("docker.labels.nodeName", "Node Name")}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="my-docker-node"
|
||||
placeholder={t("docker.placeholders.nodeName", "my-docker-node")}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
@@ -240,7 +242,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
<DockerTargetSelector value={hostConfig} onChange={setHostConfig} />
|
||||
|
||||
<label className="docker-onboarding__field">
|
||||
<span>Reachable URL</span>
|
||||
<span>{t("docker.labels.reachableUrl", "Reachable URL")}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={reachableUrl}
|
||||
@@ -260,7 +262,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
onChange={() => setApiKeyMode("auto")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
Auto-generate
|
||||
{t("docker.options.autoGenerate", "Auto-generate")}
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
@@ -269,20 +271,20 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
onChange={() => setApiKeyMode("manual")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
Provide manually
|
||||
{t("docker.options.provideManually", "Provide manually")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{apiKeyMode === "manual" && (
|
||||
<label className="docker-onboarding__field">
|
||||
<span>API Key</span>
|
||||
<span>{t("docker.labels.apiKey", "API Key")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="Enter API key"
|
||||
placeholder={t("docker.placeholders.apiKey", "Enter API key")}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
@@ -295,7 +297,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
onChange={(event) => setIncludeClaudeCli(event.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
Claude CLI
|
||||
{t("docker.options.claudeCli", "Claude CLI")}
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
@@ -304,7 +306,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
onChange={(event) => setIncludeDroidCli(event.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
Droid CLI
|
||||
{t("docker.options.droidCli", "Droid CLI")}
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
@@ -313,13 +315,13 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
onChange={(event) => setPersistentStorage(event.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
Keep data across container recreations
|
||||
{t("docker.options.persistentStorage", "Keep data across container recreations")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="docker-onboarding__inline-fields">
|
||||
<label className="docker-onboarding__field">
|
||||
<span>Memory (MB)</span>
|
||||
<span>{t("docker.labels.memory", "Memory (MB)")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -330,7 +332,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
/>
|
||||
</label>
|
||||
<label className="docker-onboarding__field">
|
||||
<span>CPUs</span>
|
||||
<span>{t("docker.labels.cpus", "CPUs")}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -353,7 +355,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
onClick={() => setShowAdvanced((value) => !value)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>Advanced</span>
|
||||
<span>{t("docker.sections.advanced", "Advanced")}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
|
||||
@@ -361,30 +363,30 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
<div>
|
||||
<div className="docker-onboarding__inline-fields">
|
||||
<label className="docker-onboarding__field">
|
||||
<span>Image</span>
|
||||
<span>{t("docker.labels.image", "Image")}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={imageName}
|
||||
onChange={(event) => setImageName(event.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="runfusion/fusion"
|
||||
placeholder={t("docker.placeholders.image", "runfusion/fusion")}
|
||||
/>
|
||||
</label>
|
||||
<label className="docker-onboarding__field">
|
||||
<span>Tag</span>
|
||||
<span>{t("docker.labels.tag", "Tag")}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={imageTag}
|
||||
onChange={(event) => setImageTag(event.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="latest"
|
||||
placeholder={t("docker.placeholders.tag", "latest")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="docker-onboarding__kv-list">
|
||||
<h5>Environment Variables</h5>
|
||||
<h5>{t("docker.sections.environmentVariables", "Environment Variables")}</h5>
|
||||
{envRows.map((row, index) => (
|
||||
<div key={`env-${index}`} className="docker-onboarding__kv-row docker-onboarding__kv-row--env">
|
||||
<input
|
||||
@@ -414,7 +416,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon"
|
||||
aria-label="Remove environment variable"
|
||||
aria-label={t("docker.ariaLabels.removeVariable", "Remove environment variable")}
|
||||
onClick={() => removeEnvRow(index)}
|
||||
disabled={submitting}
|
||||
>
|
||||
@@ -429,17 +431,17 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
disabled={submitting}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add variable
|
||||
{t("docker.actions.addVariable", "Add variable")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="docker-onboarding__kv-list">
|
||||
<h5>Volume Mounts</h5>
|
||||
<h5>{t("docker.sections.volumeMounts", "Volume Mounts")}</h5>
|
||||
{mountRows.map((row, index) => (
|
||||
<div key={`mount-${index}`} className="docker-onboarding__kv-row docker-onboarding__kv-row--mount">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Host path"
|
||||
placeholder={t("docker.placeholders.hostPath", "Host path")}
|
||||
value={row.hostPath}
|
||||
disabled={submitting}
|
||||
onChange={(event) =>
|
||||
@@ -452,7 +454,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Container path"
|
||||
placeholder={t("docker.placeholders.containerPath", "Container path")}
|
||||
value={row.containerPath}
|
||||
disabled={submitting}
|
||||
onChange={(event) =>
|
||||
@@ -481,7 +483,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon"
|
||||
aria-label="Remove volume mount"
|
||||
aria-label={t("docker.ariaLabels.removeMount", "Remove volume mount")}
|
||||
onClick={() => removeMountRow(index)}
|
||||
disabled={submitting}
|
||||
>
|
||||
@@ -496,7 +498,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
disabled={submitting}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add mount
|
||||
{t("docker.actions.addMount", "Add mount")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,10 +508,10 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={closeModal} disabled={submitting}>
|
||||
Cancel
|
||||
{t("docker.actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => void handleSubmit()} disabled={submitting}>
|
||||
{submitting ? "Creating..." : "Create Docker Node"}
|
||||
{submitting ? t("docker.states.creating", "Creating...") : t("docker.actions.createNode", "Create Docker Node")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle, AlertCircle, ExternalLink, RefreshCw, Terminal } from "lucide-react";
|
||||
import type { DockerProvisionResult } from "@fusion/core";
|
||||
import "./DockerProvisioningStatus.css";
|
||||
|
||||
const STAGES = [
|
||||
"Pulling image...",
|
||||
"Creating container...",
|
||||
"Starting container...",
|
||||
"Registering node...",
|
||||
const STAGE_KEYS = [
|
||||
"docker.stage.pullingImage",
|
||||
"docker.stage.creatingContainer",
|
||||
"docker.stage.startingContainer",
|
||||
"docker.stage.registeringNode",
|
||||
];
|
||||
|
||||
const STAGE_INTERVAL_MS = 2000;
|
||||
@@ -27,7 +28,14 @@ export function DockerProvisioningStatus({
|
||||
onRetry,
|
||||
onViewNode,
|
||||
}: DockerProvisioningStatusProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [stageIndex, setStageIndex] = useState(0);
|
||||
const STAGES = [
|
||||
t("docker.stage.pullingImage", "Pulling image..."),
|
||||
t("docker.stage.creatingContainer", "Creating container..."),
|
||||
t("docker.stage.startingContainer", "Starting container..."),
|
||||
t("docker.stage.registeringNode", "Registering node..."),
|
||||
];
|
||||
|
||||
// Animate stages during provisioning
|
||||
useEffect(() => {
|
||||
@@ -37,7 +45,7 @@ export function DockerProvisioningStatus({
|
||||
|
||||
setStageIndex(0);
|
||||
const timer = setInterval(() => {
|
||||
setStageIndex((prev) => (prev < STAGES.length - 1 ? prev + 1 : prev));
|
||||
setStageIndex((prev) => (prev < STAGE_KEYS.length - 1 ? prev + 1 : prev));
|
||||
}, STAGE_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
@@ -52,7 +60,7 @@ export function DockerProvisioningStatus({
|
||||
<span className="provisioning-status__dot" />
|
||||
<span className="provisioning-status__dot" />
|
||||
</div>
|
||||
<div className="provisioning-status__text">Creating Docker node...</div>
|
||||
<div className="provisioning-status__text">{t("docker.creatingNode", "Creating Docker node...")}</div>
|
||||
<div className="provisioning-status__stage">{STAGES[stageIndex]}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -67,15 +75,15 @@ export function DockerProvisioningStatus({
|
||||
<div className="provisioning-status__icon provisioning-status__icon--success">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="provisioning-status__text">Node created successfully!</div>
|
||||
<div className="provisioning-status__text">{t("docker.successMessage", "Node created successfully!")}</div>
|
||||
{result.containerId && (
|
||||
<div className="provisioning-status__detail">
|
||||
Container: <code>{result.containerId.slice(0, 12)}</code>
|
||||
{t("docker.container", "Container:")} <code>{result.containerId.slice(0, 12)}</code>
|
||||
</div>
|
||||
)}
|
||||
{durationSec && (
|
||||
<div className="provisioning-status__detail">
|
||||
Provisioned in {durationSec}s
|
||||
{t("docker.provisionedIn", "Provisioned in {{durationSec}}s", { durationSec })}
|
||||
</div>
|
||||
)}
|
||||
{result.nodeId && onViewNode && (
|
||||
@@ -84,7 +92,7 @@ export function DockerProvisioningStatus({
|
||||
onClick={() => onViewNode(result.nodeId!)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Node
|
||||
{t("docker.viewNode", "View Node")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -101,11 +109,11 @@ export function DockerProvisioningStatus({
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<div className="provisioning-status__text">
|
||||
{displayError ?? "Provisioning failed"}
|
||||
{displayError ?? t("docker.failedMessage", "Provisioning failed")}
|
||||
</div>
|
||||
{failedStage && (
|
||||
<div className="provisioning-status__detail">
|
||||
Failed at: {failedStage}
|
||||
{t("docker.failedAt", "Failed at: {{stage}}", { stage: failedStage })}
|
||||
</div>
|
||||
)}
|
||||
{result?.containerName && (
|
||||
@@ -117,7 +125,7 @@ export function DockerProvisioningStatus({
|
||||
{onRetry && (
|
||||
<button className="btn btn-sm" onClick={onRetry}>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
{t("docker.retry", "Retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerHostConfig } from "@fusion/core";
|
||||
import { useDockerTargets } from "../hooks/useDockerTargets";
|
||||
import { DockerTlsConfig } from "./DockerTlsConfig";
|
||||
@@ -14,6 +15,7 @@ interface DockerTargetSelectorProps {
|
||||
}
|
||||
|
||||
export function DockerTargetSelector({ value, onChange, onError }: DockerTargetSelectorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const initialMode: TargetMode = value?.context ? "context" : value?.host ? "host" : "local";
|
||||
const [mode, setMode] = useState<TargetMode>(initialMode);
|
||||
const [selectedContext, setSelectedContext] = useState(value?.context ?? "");
|
||||
@@ -56,19 +58,27 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
|
||||
|
||||
return (
|
||||
<div className="docker-target-selector">
|
||||
<div className="docker-target-selector__modes" role="group" aria-label="Docker target mode">
|
||||
<div className="docker-target-selector__modes" role="group" aria-label={t("docker.targetMode", "Docker target mode")}>
|
||||
<button type="button" className={`btn btn-sm ${mode === "local" ? "docker-target-selector__mode-active" : ""}`} onClick={() => {
|
||||
setMode("local");
|
||||
void checkLocalDocker()
|
||||
.then((result) => setLocalStatus(result.available ? `Docker is available${result.version ? ` (${result.version})` : ""}` : `Docker not found${result.error ? `: ${result.error}` : ""}`))
|
||||
.then((result) => {
|
||||
if (result.available) {
|
||||
const version = result.version ? ` (${result.version})` : "";
|
||||
setLocalStatus(t("docker.available", "Docker is available{{version}}", { version }));
|
||||
} else {
|
||||
const error = result.error ? `: ${result.error}` : "";
|
||||
setLocalStatus(t("docker.notFound", "Docker not found{{error}}", { error }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLocalStatus(`Docker not found: ${message}`);
|
||||
setLocalStatus(t("docker.notFoundError", "Docker not found: {{message}}", { message }));
|
||||
onError?.(message);
|
||||
});
|
||||
}}>Local Docker</button>
|
||||
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>Docker Context</button>
|
||||
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>Remote Host</button>
|
||||
}}>{t("docker.local", "Local Docker")}</button>
|
||||
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>{t("docker.context", "Docker Context")}</button>
|
||||
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>{t("docker.remote", "Remote Host")}</button>
|
||||
</div>
|
||||
|
||||
{mode === "local" && localStatus && <div className="docker-target-selector__status">{localStatus}</div>}
|
||||
@@ -81,12 +91,12 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
|
||||
setSelectedContext(next);
|
||||
onChange(next ? { context: next } : {});
|
||||
}}>
|
||||
<option value="">Select context</option>
|
||||
<option value="">{t("docker.selectContext", "Select context")}</option>
|
||||
{contexts.map((context) => (
|
||||
<option key={context.name} value={context.name}>{context.name}{context.isCurrentContext ? " (current)" : ""}{context.dockerHost ? ` — ${context.dockerHost}` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label="Refresh contexts"><RefreshCw size={14} /></button>
|
||||
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label={t("docker.refreshContexts", "Refresh contexts")}><RefreshCw size={14} /></button>
|
||||
</div>
|
||||
{contextsError && <div className="docker-target-selector__error">{contextsError}</div>}
|
||||
</div>
|
||||
@@ -95,11 +105,11 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
|
||||
{mode === "host" && (
|
||||
<div className="docker-target-selector__panel">
|
||||
<div className="docker-target-selector__field">
|
||||
<label htmlFor="docker-target-selector-host">Docker Host</label>
|
||||
<label htmlFor="docker-target-selector-host">{t("docker.host", "Docker Host")}</label>
|
||||
<input
|
||||
id="docker-target-selector-host"
|
||||
className="input"
|
||||
placeholder="tcp://host:2376"
|
||||
placeholder={t("docker.hostPlaceholder", "tcp://host:2376")}
|
||||
value={host}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
@@ -113,14 +123,14 @@ export function DockerTargetSelector({ value, onChange, onError }: DockerTargetS
|
||||
)}
|
||||
|
||||
<button type="button" className="btn btn-sm" onClick={() => void testConnection(mode === "local" ? undefined : mode === "context" ? { context: selectedContext } : { host, ...tlsValue })} disabled={isTestingConnection || isCheckingLocal}>
|
||||
{isTestingConnection ? "Testing..." : "Test Connection"}
|
||||
{isTestingConnection ? t("docker.testing", "Testing...") : t("docker.testConnection", "Test Connection")}
|
||||
</button>
|
||||
|
||||
{lastTestResult && (
|
||||
<div className={lastTestResult.success ? "docker-target-selector__success" : "docker-target-selector__error"}>
|
||||
{lastTestResult.success
|
||||
? `Connected${lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : ""}`
|
||||
: lastTestResult.error ?? "Connection failed"}
|
||||
? t("docker.connected", "Connected{{version}}", { version: lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : "" })
|
||||
: lastTestResult.error ?? t("docker.connectionFailed", "Connection failed")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerHostConfig } from "@fusion/core";
|
||||
import "./DockerTlsConfig.css";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface DockerTlsConfigProps {
|
||||
}
|
||||
|
||||
export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [enabled, setEnabled] = useState(Boolean(value?.tlsCaPath || value?.tlsCertPath || value?.tlsKeyPath || value?.tlsVerify));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,12 +26,12 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
|
||||
<div className="docker-tls-config">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
Use TLS
|
||||
{t("docker.useTls", "Use TLS")}
|
||||
</label>
|
||||
{enabled && (
|
||||
<div className="docker-tls-config__fields">
|
||||
<div className="docker-tls-config__field">
|
||||
<label htmlFor="docker-tls-ca-path">CA Certificate Path</label>
|
||||
<label htmlFor="docker-tls-ca-path">{t("docker.caPath", "CA Certificate Path")}</label>
|
||||
<input
|
||||
id="docker-tls-ca-path"
|
||||
className="input"
|
||||
@@ -39,7 +41,7 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="docker-tls-config__field">
|
||||
<label htmlFor="docker-tls-cert-path">Client Certificate Path</label>
|
||||
<label htmlFor="docker-tls-cert-path">{t("docker.certPath", "Client Certificate Path")}</label>
|
||||
<input
|
||||
id="docker-tls-cert-path"
|
||||
className="input"
|
||||
@@ -49,7 +51,7 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="docker-tls-config__field">
|
||||
<label htmlFor="docker-tls-key-path">Client Key Path</label>
|
||||
<label htmlFor="docker-tls-key-path">{t("docker.keyPath", "Client Key Path")}</label>
|
||||
<input
|
||||
id="docker-tls-key-path"
|
||||
className="input"
|
||||
@@ -60,7 +62,7 @@ export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
|
||||
</div>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={tls.tlsVerify} onChange={(event) => onChange({ ...tls, tlsVerify: event.target.checked })} />
|
||||
Verify TLS Certificate
|
||||
{t("docker.verifyTls", "Verify TLS Certificate")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./DocumentsView.css";
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -57,6 +58,7 @@ function getContentPreview(content: string, maxLength: number = 200): string {
|
||||
}
|
||||
|
||||
function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const preview = getContentPreview(document.content);
|
||||
@@ -74,8 +76,8 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
|
||||
<button
|
||||
className="btn btn-sm document-card-expand-btn"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
aria-label={expanded ? "Collapse content" : "Expand content"}
|
||||
title={expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")}
|
||||
aria-label={expanded ? t("documents.collapseContent", "Collapse content") : t("documents.expandContent", "Expand content")}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
@@ -95,11 +97,11 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={onToggleMarkdown}
|
||||
aria-label={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
aria-label={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
aria-pressed={renderMarkdown}
|
||||
title={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
title={renderMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
>
|
||||
{renderMarkdown ? "Markdown" : "Plain"}
|
||||
{renderMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
</button>
|
||||
</div>
|
||||
{renderMarkdown ? (
|
||||
@@ -124,6 +126,7 @@ function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCa
|
||||
}
|
||||
|
||||
function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -133,13 +136,13 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
||||
className="documents-group-toggle-btn"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} documents for task ${taskId}`}
|
||||
aria-label={`${expanded ? t("documents.collapse", "Collapse") : t("documents.expand", "Expand")} documents for task ${taskId}`}
|
||||
>
|
||||
<span className="documents-group-toggle" aria-hidden="true">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
<span className="documents-group-task-id">{taskId}</span>
|
||||
<span className="documents-group-task-title">{taskTitle || "Untitled"}</span>
|
||||
<span className="documents-group-task-title">{taskTitle || t("documents.untitled", "Untitled")}</span>
|
||||
</button>
|
||||
|
||||
<span className="documents-group-count">{documents.length} doc{documents.length !== 1 ? "s" : ""}</span>
|
||||
@@ -147,9 +150,9 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
||||
<button
|
||||
className="documents-group-task-link"
|
||||
onClick={() => onOpenTask(taskId)}
|
||||
aria-label={`Open task ${taskId}: ${taskTitle || "Untitled"}`}
|
||||
aria-label={`Open task ${taskId}: ${taskTitle || t("documents.untitled", "Untitled")}`}
|
||||
>
|
||||
Open task
|
||||
{t("documents.openTask", "Open task")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +173,7 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
||||
}
|
||||
|
||||
export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
|
||||
@@ -372,8 +376,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
const activeCount = activeTab === "project" ? filteredProjectFiles.length : documents.length;
|
||||
|
||||
const searchPlaceholder = activeTab === "project"
|
||||
? "Search project markdown files…"
|
||||
: "Search task documents…";
|
||||
? t("documents.searchProjectFiles", "Search project markdown files…")
|
||||
: t("documents.searchTaskDocuments", "Search task documents…");
|
||||
|
||||
return (
|
||||
<div className="documents-view">
|
||||
@@ -381,7 +385,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
<div className="documents-view-title-row">
|
||||
<h2 className="documents-view-title">
|
||||
<FileText size={20} />
|
||||
Documents
|
||||
{t("documents.title", "Documents")}
|
||||
</h2>
|
||||
<span className="documents-view-count">
|
||||
{activeCount} result{activeCount !== 1 ? "s" : ""}
|
||||
@@ -394,20 +398,20 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
className={`btn documents-tab${activeTab === "project" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "project"}
|
||||
aria-label="Show project markdown files"
|
||||
aria-label={t("documents.showProjectFiles", "Show project markdown files")}
|
||||
onClick={() => handleTabChange("project")}
|
||||
>
|
||||
Project Files
|
||||
{t("documents.projectFilesTab", "Project Files")}
|
||||
<span className="documents-tab-count">{projectFiles.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn documents-tab${activeTab === "tasks" ? " active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === "tasks"}
|
||||
aria-label="Show task documents"
|
||||
aria-label={t("documents.showTaskDocuments", "Show task documents")}
|
||||
onClick={() => handleTabChange("tasks")}
|
||||
>
|
||||
Task Documents
|
||||
{t("documents.taskDocumentsTab", "Task Documents")}
|
||||
<span className="documents-tab-count">{groupedDocuments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -417,11 +421,11 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
className="btn btn-sm documents-hidden-toggle"
|
||||
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
|
||||
aria-pressed={showHiddenProjectFiles}
|
||||
aria-label={showHiddenProjectFiles ? "Hide hidden project files" : "Show hidden project files"}
|
||||
title={showHiddenProjectFiles ? "Hide hidden files" : "Show hidden files"}
|
||||
aria-label={showHiddenProjectFiles ? t("documents.hideHidden", "Hide hidden project files") : t("documents.showHidden", "Show hidden project files")}
|
||||
title={showHiddenProjectFiles ? t("documents.hideHiddenFiles", "Hide hidden files") : t("documents.showHiddenFiles", "Show hidden files")}
|
||||
>
|
||||
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
{showHiddenProjectFiles ? "Hide Hidden" : "Show Hidden"}
|
||||
{showHiddenProjectFiles ? t("documents.hideHiddenLabel", "Hide Hidden") : t("documents.showHiddenLabel", "Show Hidden")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -439,7 +443,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
<button
|
||||
className="documents-search-clear"
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
aria-label={t("documents.clearSearch", "Clear search")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -451,25 +455,25 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
<div className="documents-view-content">
|
||||
{activeError ? (
|
||||
<div className="documents-view-error">
|
||||
<p>Failed to load {activeTab === "project" ? "project files" : "task documents"}: {activeError}</p>
|
||||
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label="Retry loading documents">
|
||||
<p>{t("documents.failedToLoad", "Failed to load {{type}}: {{error}}", { type: activeTab === "project" ? t("documents.projectFiles", "project files") : t("documents.taskDocuments", "task documents"), error: activeError })}</p>
|
||||
<button className="btn btn-primary" onClick={() => void handleRetry()} aria-label={t("documents.retryLoading", "Retry loading documents")}>
|
||||
<RefreshCw size={16} />
|
||||
Retry
|
||||
{t("documents.retry", "Retry")}
|
||||
</button>
|
||||
</div>
|
||||
) : activeTab === "project" ? (
|
||||
projectFilesLoading && projectFiles.length === 0 ? (
|
||||
<div className="documents-view-loading">
|
||||
<p>Loading project markdown files…</p>
|
||||
<p>{t("documents.loadingProjectFiles", "Loading project markdown files…")}</p>
|
||||
</div>
|
||||
) : filteredProjectFiles.length === 0 ? (
|
||||
<div className="documents-view-empty">
|
||||
{searchQuery.trim() ? (
|
||||
<p>No project markdown files match "{searchQuery.trim()}".</p>
|
||||
<p>{t("documents.noMatchProject", "No project markdown files match \"{{query}}\".", { query: searchQuery.trim() })}</p>
|
||||
) : (
|
||||
<>
|
||||
<FileText size={48} className="documents-view-empty-icon" />
|
||||
<p>No Markdown files found in this project.</p>
|
||||
<p>{t("documents.noMarkdownFiles", "No Markdown files found in this project.")}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -507,16 +511,16 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
<button
|
||||
className="btn btn-sm documents-mobile-back"
|
||||
onClick={handleBackToFileList}
|
||||
aria-label="Back to project files list"
|
||||
aria-label={t("documents.backToFilesList", "Back to project files list")}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to files
|
||||
{t("documents.backToFiles", "Back to files")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!selectedFile ? (
|
||||
<div className="documents-view-empty">
|
||||
<p>Select a Markdown file to view its content.</p>
|
||||
<p>{t("documents.selectFile", "Select a Markdown file to view its content.")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="documents-content-viewer">
|
||||
@@ -525,15 +529,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={() => setRenderProjectMarkdown((prev) => !prev)}
|
||||
aria-label={renderProjectMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
aria-label={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
aria-pressed={renderProjectMarkdown}
|
||||
title={renderProjectMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
title={renderProjectMarkdown ? t("documents.switchToPlainText", "Switch to plain text") : t("documents.switchToMarkdown", "Switch to markdown")}
|
||||
>
|
||||
{renderProjectMarkdown ? "Markdown" : "Plain"}
|
||||
{renderProjectMarkdown ? t("documents.markdown", "Markdown") : t("documents.plain", "Plain")}
|
||||
</button>
|
||||
</div>
|
||||
{fileLoading ? (
|
||||
<p className="documents-content-state">Loading file content…</p>
|
||||
<p className="documents-content-state">{t("documents.loadingFileContent", "Loading file content…")}</p>
|
||||
) : fileError ? (
|
||||
<p className="documents-content-state documents-content-state--error">{fileError}</p>
|
||||
) : renderProjectMarkdown ? (
|
||||
@@ -553,18 +557,18 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
)
|
||||
) : documentsLoading && documents.length === 0 ? (
|
||||
<div className="documents-view-loading">
|
||||
<p>Loading task documents…</p>
|
||||
<p>{t("documents.loadingTaskDocuments", "Loading task documents…")}</p>
|
||||
</div>
|
||||
) : groupedDocuments.length === 0 ? (
|
||||
<div className="documents-view-empty">
|
||||
{searchQuery.trim() ? (
|
||||
<p>No task documents match "{searchQuery.trim()}".</p>
|
||||
<p>{t("documents.noMatchTask", "No task documents match \"{{query}}\".", { query: searchQuery.trim() })}</p>
|
||||
) : (
|
||||
<>
|
||||
<FileText size={48} className="documents-view-empty-icon" />
|
||||
<p>No task documents yet.</p>
|
||||
<p>{t("documents.noTaskDocuments", "No task documents yet.")}</p>
|
||||
<p className="documents-view-empty-hint">
|
||||
Documents are created in task detail tabs.
|
||||
{t("documents.documentsCreatedIn", "Documents are created in task detail tabs.")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
fetchDroidCliStatus,
|
||||
@@ -19,6 +20,7 @@ export function DroidCliProviderCard({
|
||||
onToggled,
|
||||
compact = false,
|
||||
}: DroidCliProviderCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = useState<DroidCliStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(
|
||||
null,
|
||||
@@ -98,8 +100,7 @@ export function DroidCliProviderCard({
|
||||
|
||||
const description = (
|
||||
<span className="onboarding-provider-card__description">
|
||||
Route AI calls through your locally-installed <code>droid</code> CLI.
|
||||
Uses your existing Factory AI subscription / quota instead of an API key.
|
||||
{t("droidCli.description", "Route AI calls through your locally-installed droid CLI. Uses your existing Factory AI subscription / quota instead of an API key.")}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -114,10 +115,10 @@ export function DroidCliProviderCard({
|
||||
{busy === "testing" ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Testing…
|
||||
{t("droidCli.testing", "Testing…")}
|
||||
</>
|
||||
) : (
|
||||
"Test"
|
||||
t("droidCli.test", "Test")
|
||||
)}
|
||||
</button>
|
||||
{currentlyEnabled ? (
|
||||
@@ -127,7 +128,7 @@ export function DroidCliProviderCard({
|
||||
onClick={() => void handleToggle(false)}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "disabling" ? "Disabling…" : "Disable"}
|
||||
{busy === "disabling" ? t("droidCli.disabling", "Disabling…") : t("droidCli.disable", "Disable")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -137,11 +138,11 @@ export function DroidCliProviderCard({
|
||||
disabled={busy !== null || !binaryAvailable}
|
||||
title={
|
||||
!binaryAvailable
|
||||
? "`droid` binary not detected on PATH — install Droid CLI first."
|
||||
? t("droidCli.notOnPath", "droid binary not detected on PATH — install Droid CLI first.")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{busy === "enabling" ? "Enabling…" : "Enable"}
|
||||
{busy === "enabling" ? t("droidCli.enabling", "Enabling…") : t("droidCli.enable", "Enable")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -199,13 +200,14 @@ function DroidCliBadge({
|
||||
status: DroidCliStatus | null;
|
||||
authenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const enabled = status?.enabled ?? authenticated;
|
||||
const available = status?.binary.available ?? false;
|
||||
if (enabled) return <span className="auth-status-badge authenticated">✓ Active</span>;
|
||||
if (enabled) return <span className="auth-status-badge authenticated">✓ {t("droidCli.active", "Active")}</span>;
|
||||
if (!available && status) {
|
||||
return <span className="auth-status-badge not-authenticated">✗ Not installed</span>;
|
||||
return <span className="auth-status-badge not-authenticated">✗ {t("droidCli.notInstalled", "Not installed")}</span>;
|
||||
}
|
||||
return <span className="auth-status-badge not-authenticated">✗ Not connected</span>;
|
||||
return <span className="auth-status-badge not-authenticated">✗ {t("droidCli.notConnected", "Not connected")}</span>;
|
||||
}
|
||||
|
||||
function DroidCliStatusLine({
|
||||
@@ -215,10 +217,11 @@ function DroidCliStatusLine({
|
||||
status: DroidCliStatus | null;
|
||||
authenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
if (!status) {
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
<Loader2 size={10} className="animate-spin" /> Probing local CLI…
|
||||
<Loader2 size={10} className="animate-spin" /> {t("droidCli.probingCli", "Probing local CLI…")}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
@@ -227,7 +230,7 @@ function DroidCliStatusLine({
|
||||
if (!binary.available) {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--error">
|
||||
✗ {binary.reason ?? "`droid` not found on PATH"}
|
||||
✗ {binary.reason ?? t("droidCli.droidNotFound", "droid not found on PATH")}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
@@ -235,9 +238,10 @@ function DroidCliStatusLine({
|
||||
if (!enabled) {
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
<code>droid</code> {binary.version ? `(${binary.version})` : ""} detected
|
||||
{binary.binaryPath ? ` at ${binary.binaryPath}` : ""}. Click Enable to
|
||||
route AI calls through it.
|
||||
{t("droidCli.detected", "droid {{version}} detected{{path}}. Click Enable to route AI calls through it.", {
|
||||
version: binary.version ? `(${binary.version})` : "",
|
||||
path: binary.binaryPath ? ` at ${binary.binaryPath}` : ""
|
||||
})}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
@@ -245,7 +249,7 @@ function DroidCliStatusLine({
|
||||
if (extension && extension.status !== "ok") {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--warning">
|
||||
⚠ Extension load failed: {extension.reason ?? extension.status}
|
||||
⚠ {t("droidCli.extensionFailed", "Extension load failed: {{reason}}", { reason: extension.reason ?? extension.status })}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
@@ -253,12 +257,12 @@ function DroidCliStatusLine({
|
||||
if (ready || authenticated) {
|
||||
return (
|
||||
<small className="onboarding-provider-card__status onboarding-provider-card__status--connected">
|
||||
✓ Connected{binary.version ? ` — ${binary.version}` : ""}
|
||||
✓ {t("droidCli.connectedVersion", "Connected{{version}}", { version: binary.version ? ` — ${binary.version}` : "" })}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
return <small className="settings-muted">Enabled. Validating…</small>;
|
||||
return <small className="settings-muted">{t("droidCli.enabledValidating", "Enabled. Validating…")}</small>;
|
||||
}
|
||||
|
||||
function DroidCliActionToast({
|
||||
@@ -269,6 +273,7 @@ function DroidCliActionToast({
|
||||
| { kind: "disabled"; restartRequired: boolean }
|
||||
| { kind: "error"; message: string };
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
if (action.kind === "error") {
|
||||
return (
|
||||
<p className="onboarding-helper-text onboarding-helper-text--error">
|
||||
@@ -276,15 +281,15 @@ function DroidCliActionToast({
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const verb = action.kind === "enabled" ? "Enabled" : "Disabled";
|
||||
const verb = action.kind === "enabled" ? t("droidCli.toastEnabled", "Enabled") : t("droidCli.toastDisabled", "Disabled");
|
||||
return (
|
||||
<p className="onboarding-helper-text">
|
||||
{verb}.{" "}
|
||||
{action.kind === "enabled"
|
||||
? "Factory AI (via Droid CLI) models are now visible in the model picker."
|
||||
: "Factory AI (via Droid CLI) models are hidden from the model picker."}
|
||||
? t("droidCli.modelsNowVisible", "Factory AI (via Droid CLI) models are now visible in the model picker.")
|
||||
: t("droidCli.modelsHidden", "Factory AI (via Droid CLI) models are hidden from the model picker.")}
|
||||
{action.restartRequired
|
||||
? " Restart required: restart your active CLI/chat session for routing changes to take effect."
|
||||
? " " + t("droidCli.restartRequired", "Restart required: restart your active CLI/chat session for routing changes to take effect.")
|
||||
: ""}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./DuplicateWarningModal.css";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DuplicateMatch } from "../api";
|
||||
|
||||
interface DuplicateWarningModalProps {
|
||||
@@ -14,6 +15,7 @@ function toStatusClass(column: string): string {
|
||||
}
|
||||
|
||||
export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }: DuplicateWarningModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,10 +37,10 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
|
||||
<div className="modal-overlay open" role="presentation">
|
||||
<div className="modal duplicate-warning-modal" role="dialog" aria-modal="true" aria-labelledby="duplicate-warning-modal-title">
|
||||
<div className="modal-header">
|
||||
<h3 id="duplicate-warning-modal-title">Possible duplicates</h3>
|
||||
<h3 id="duplicate-warning-modal-title">{t("duplicateWarning.title", "Possible duplicates")}</h3>
|
||||
</div>
|
||||
<div className="duplicate-warning-modal-body">
|
||||
<p className="duplicate-warning-modal-copy">We found similar active tasks. Open an existing task or create this one anyway.</p>
|
||||
<p className="duplicate-warning-modal-copy">{t("duplicateWarning.message", "We found similar active tasks. Open an existing task or create this one anyway.")}</p>
|
||||
<div className="duplicate-warning-modal-list">
|
||||
{matches.map((match) => (
|
||||
<article className="card duplicate-warning-modal-item" key={match.id}>
|
||||
@@ -47,9 +49,9 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
|
||||
<span className={`card-status-badge ${toStatusClass(match.column)}`}>{match.column}</span>
|
||||
<span className="duplicate-warning-modal-score">{Math.round(match.score * 100)}%</span>
|
||||
</div>
|
||||
<div className="card-title duplicate-warning-modal-title">{match.title || "Untitled task"}</div>
|
||||
<div className="card-title duplicate-warning-modal-title">{match.title || t("duplicateWarning.untitledTask", "Untitled task")}</div>
|
||||
<div className="duplicate-warning-modal-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>Open</button>
|
||||
<button className="btn btn-sm" type="button" onClick={() => onOpen(match.id)}>{t("duplicateWarning.open", "Open")}</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -57,10 +59,10 @@ export function DuplicateWarningModal({ matches, onOpen, onProceed, onCancel }:
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
<button className="btn" type="button" ref={cancelButtonRef} onClick={onCancel}>Cancel</button>
|
||||
<button className="btn" type="button" ref={cancelButtonRef} onClick={onCancel}>{t("duplicateWarning.cancel", "Cancel")}</button>
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button className="btn btn-primary" type="button" onClick={onProceed}>Create anyway</button>
|
||||
<button className="btn btn-primary" type="button" onClick={onProceed}>{t("duplicateWarning.createAnyway", "Create anyway")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, RefreshCw, Settings } from "lucide-react";
|
||||
import { fetchSettings } from "../api";
|
||||
import { useEvals } from "../hooks/useEvals";
|
||||
@@ -12,6 +13,7 @@ interface EvalsViewProps {
|
||||
}
|
||||
|
||||
export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: EvalsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { loading, error, results, runs, filters, setFilters, selectedEvalId, setSelectedEvalId, selectedEval, refresh } = useEvals({ projectId });
|
||||
const [scheduledEnabled, setScheduledEnabled] = useState(true);
|
||||
|
||||
@@ -37,11 +39,11 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
|
||||
if (!scheduledEnabled) {
|
||||
return (
|
||||
<section className="evals-view card" data-testid="evals-disabled">
|
||||
<h2 className="evals-title">Scheduled evals are disabled</h2>
|
||||
<p className="evals-empty-copy">Enable Scheduled Evals to review scored tasks, evidence, and follow-up recommendations.</p>
|
||||
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.("scheduled-evals")}>
|
||||
<h2 className="evals-title">{t("evals.disabledTitle", "Scheduled evals are disabled")}</h2>
|
||||
<p className="evals-empty-copy">{t("evals.enablePrompt", "Enable Scheduled Evals to review scored tasks, evidence, and follow-up recommendations.")}</p>
|
||||
<button className="btn btn-primary" type="button" onClick={() => onOpenSettings?.("scheduled-evals")}>
|
||||
<Settings size={16} />
|
||||
Open Scheduled Evals Settings
|
||||
{t("evals.openSettings", "Open Scheduled Evals Settings")}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
@@ -53,7 +55,7 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
|
||||
<div className="evals-toolbar">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search task or rationale"
|
||||
placeholder={t("evals.searchPlaceholder", "Search task or rationale")}
|
||||
value={filters.q}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, q: event.target.value }))}
|
||||
/>
|
||||
@@ -62,20 +64,20 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
|
||||
value={filters.runId}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, runId: event.target.value }))}
|
||||
>
|
||||
<option value="">All runs</option>
|
||||
<option value="">{t("evals.allRuns", "All runs")}</option>
|
||||
{runs.map((run) => (
|
||||
<option key={run.id} value={run.id}>{run.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<input className="input" placeholder="Min score" value={filters.scoreMin} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMin: event.target.value }))} />
|
||||
<input className="input" placeholder="Max score" value={filters.scoreMax} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMax: event.target.value }))} />
|
||||
<button className="btn btn-icon" type="button" onClick={() => void refresh()} aria-label="Refresh evals"><RefreshCw size={16} /></button>
|
||||
<input className="input" placeholder={t("evals.minScorePlaceholder", "Min score")} value={filters.scoreMin} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMin: event.target.value }))} />
|
||||
<input className="input" placeholder={t("evals.maxScorePlaceholder", "Max score")} value={filters.scoreMax} onChange={(event) => setFilters((prev) => ({ ...prev, scoreMax: event.target.value }))} />
|
||||
<button className="btn btn-icon" type="button" onClick={() => void refresh()} aria-label={t("evals.refreshAria", "Refresh evals")}><RefreshCw size={16} /></button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="evals-state" data-testid="evals-loading">Loading evals…</p>}
|
||||
{loading && <p className="evals-state" data-testid="evals-loading">{t("evals.loading", "Loading evals…")}</p>}
|
||||
{error && <p className="evals-state evals-state--error">{error}</p>}
|
||||
{!loading && !error && !hasResults && (
|
||||
<p className="evals-state">No evals yet. Scheduled evals review tasks completed since the last run.</p>
|
||||
<p className="evals-state">{t("evals.empty", "No evals yet. Scheduled evals review tasks completed since the last run.")}</p>
|
||||
)}
|
||||
|
||||
<ul className="evals-results" data-testid="evals-results">
|
||||
@@ -91,21 +93,21 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
|
||||
</div>
|
||||
|
||||
<div className="evals-detail card" data-testid="evals-detail">
|
||||
{!selectedEval && <p className="evals-state">Select an evaluation to inspect scores, rationale, and evidence.</p>}
|
||||
{!selectedEval && <p className="evals-state">{t("evals.selectPrompt", "Select an evaluation to inspect scores, rationale, and evidence.")}</p>}
|
||||
{selectedEval && (
|
||||
<>
|
||||
<h3 className="evals-detail-title">{selectedSummary?.taskTitle ?? selectedEval.taskTitle}</h3>
|
||||
<p className="evals-result-meta">{selectedEval.taskId} · {selectedEval.runId}</p>
|
||||
<p className="evals-score">Overall score: {selectedEval.overallScore ?? "n/a"}</p>
|
||||
<p className="evals-score">{t("evals.overallScore", "Overall score: {{score}}", { score: selectedEval.overallScore ?? "n/a" })}</p>
|
||||
<ul className="evals-categories">
|
||||
{selectedEval.categoryScores.map((score) => (
|
||||
<li key={score.category}>{score.category}: {score.finalScore}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="evals-rationale">{selectedEval.rationale || "No rationale recorded."}</p>
|
||||
<p className="evals-rationale">{selectedEval.rationale || t("evals.noRationale", "No rationale recorded.")}</p>
|
||||
|
||||
<div>
|
||||
<h4>Evidence</h4>
|
||||
<h4>{t("evals.evidenceHeading", "Evidence")}</h4>
|
||||
<ul className="evals-links">
|
||||
{selectedEval.evidence.map((item, index) => {
|
||||
const taskId = typeof item.metadata?.taskId === "string" ? item.metadata.taskId : undefined;
|
||||
@@ -126,9 +128,9 @@ export function EvalsView({ projectId, onOpenSettings, onOpenTaskDetail }: Evals
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>Suggested follow-up tasks</h4>
|
||||
<h4>{t("evals.suggestedFollowupsHeading", "Suggested follow-up tasks")}</h4>
|
||||
<ul className="evals-follow-ups">
|
||||
{selectedEval.followUps.length === 0 && <li>None</li>}
|
||||
{selectedEval.followUps.length === 0 && <li>{t("evals.noFollowups", "None")}</li>}
|
||||
{selectedEval.followUps.map((followUp) => (
|
||||
<li key={followUp.suggestionId}><strong>{followUp.title}</strong><p>{followUp.rationale}</p></li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "./ExecutorStatusBar.css";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
|
||||
STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS,
|
||||
@@ -43,8 +45,8 @@ interface ExecutorStatusBarProps {
|
||||
/**
|
||||
* Format a relative time string (e.g., "2m ago", "1h ago")
|
||||
*/
|
||||
function formatRelativeTime(timestamp: string | undefined): string {
|
||||
if (!timestamp) return "no activity";
|
||||
function formatRelativeTime(timestamp: string | undefined, t: TFunction<"app">): string {
|
||||
if (!timestamp) return t("executor.noActivity", "no activity");
|
||||
|
||||
const now = Date.now();
|
||||
const then = new Date(timestamp).getTime();
|
||||
@@ -59,21 +61,21 @@ function formatRelativeTime(timestamp: string | undefined): string {
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
if (seconds > 10) return `${seconds}s ago`;
|
||||
return "just now";
|
||||
return t("executor.justNow", "just now");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display configuration for an executor state
|
||||
*/
|
||||
function getStateDisplay(state: ExecutorState): { label: string; color: string; icon: typeof Play } {
|
||||
function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: string; color: string; icon: typeof Play } {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return { label: "Running", color: "var(--color-success)", icon: Play };
|
||||
return { label: t("executor.stateRunning", "Running"), color: "var(--color-success)", icon: Play };
|
||||
case "paused":
|
||||
return { label: "Paused", color: "var(--triage)", icon: Pause };
|
||||
return { label: t("executor.statePaused", "Paused"), color: "var(--triage)", icon: Pause };
|
||||
case "idle":
|
||||
default:
|
||||
return { label: "Idle", color: "var(--text-muted)", icon: Zap };
|
||||
return { label: t("executor.stateIdle", "Idle"), color: "var(--text-muted)", icon: Zap };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +90,13 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
|
||||
* - Last activity timestamp
|
||||
*/
|
||||
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen }: ExecutorStatusBarProps) {
|
||||
if (hideWhenKeyboardOpen) return null;
|
||||
const { t } = useTranslation("app");
|
||||
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
|
||||
|
||||
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]);
|
||||
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]);
|
||||
|
||||
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]);
|
||||
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt, t), [stats.lastActivityAt, t]);
|
||||
|
||||
const highestOverlapBlocker = useMemo(() => {
|
||||
const fanoutMap = computeBlockerFanoutMap(tasks, {
|
||||
@@ -117,9 +119,13 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
|
||||
const StateIcon = stateDisplay.icon;
|
||||
|
||||
// Keyboard-open guard runs after all hooks: toggling it must not change the
|
||||
// hook count between renders (Rules of Hooks).
|
||||
if (hideWhenKeyboardOpen) return null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label="Executor status">
|
||||
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label={t("executor.status", "Executor status")}>
|
||||
<span className="executor-status-bar__error">
|
||||
<AlertTriangle size={14} />
|
||||
{error}
|
||||
@@ -130,8 +136,8 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
|
||||
if (loading && stats.runningTaskCount === 0) {
|
||||
return (
|
||||
<div className="executor-status-bar executor-status-bar--loading" role="status" aria-label="Executor status">
|
||||
<span className="executor-status-bar__loading-text">Loading...</span>
|
||||
<div className="executor-status-bar executor-status-bar--loading" role="status" aria-label={t("executor.status", "Executor status")}>
|
||||
<span className="executor-status-bar__loading-text">{t("executor.loading", "Loading...")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +146,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
<div
|
||||
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}${keyboardOpen ? " executor-status-bar--keyboard-open" : ""}`}
|
||||
role="status"
|
||||
aria-label="Executor status"
|
||||
aria-label={t("executor.status", "Executor status")}
|
||||
>
|
||||
{/* Background AI tasks indicator */}
|
||||
{backgroundSessions && backgroundSessions.length > 0 && onOpenBackgroundSession && onDismissBackgroundSession && (
|
||||
@@ -159,7 +165,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
{/* Queued tasks */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--queued" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">Queued</span>
|
||||
<span className="executor-status-bar__label">{t("executor.queued", "Queued")}</span>
|
||||
<span className="executor-status-bar__count">{stats.queuedTaskCount}</span>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +178,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
className={`executor-status-bar__indicator executor-status-bar__indicator--running ${stats.runningTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="executor-status-bar__label">Running</span>
|
||||
<span className="executor-status-bar__label">{t("executor.running", "Running")}</span>
|
||||
<span className="executor-status-bar__count">{stats.runningTaskCount}</span>
|
||||
<span className="executor-status-bar__separator" aria-hidden="true">/</span>
|
||||
<span className="executor-status-bar__max">{stats.maxConcurrent}</span>
|
||||
@@ -186,7 +192,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
<>
|
||||
<div className="executor-status-bar__segment executor-status-bar__segment--stuck">
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--stuck executor-status-bar__indicator--active" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">Stuck</span>
|
||||
<span className="executor-status-bar__label">{t("executor.stuck", "Stuck")}</span>
|
||||
<span className="executor-status-bar__count executor-status-bar__count--error">{stats.stuckTaskCount}</span>
|
||||
</div>
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
@@ -199,7 +205,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
className={`executor-status-bar__indicator executor-status-bar__indicator--blocked ${stats.blockedTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="executor-status-bar__label">Blocked</span>
|
||||
<span className="executor-status-bar__label">{t("executor.blocked", "Blocked")}</span>
|
||||
<span className={`executor-status-bar__count ${stats.blockedTaskCount > 0 ? "executor-status-bar__count--warning" : ""}`}>
|
||||
{stats.blockedTaskCount}
|
||||
</span>
|
||||
@@ -211,7 +217,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
{/* In review count */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--review" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">In Review</span>
|
||||
<span className="executor-status-bar__label">{t("executor.inReview", "In Review")}</span>
|
||||
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
|
||||
</div>
|
||||
|
||||
@@ -220,12 +226,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
<div className="executor-status-bar__segment executor-status-bar__segment--fanout">
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--fanout executor-status-bar__indicator--active" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">Overlap queue</span>
|
||||
<span className="executor-status-bar__label">{t("executor.overlapQueue", "Overlap queue")}</span>
|
||||
<span
|
||||
className="executor-status-bar__fanout-summary"
|
||||
title={`${highestOverlapBlocker.entry.escalation ? "Escalated" : "Temporary"} overlap bottleneck ${highestOverlapBlocker.blockerId}: ${highestOverlapBlocker.entry.overlapBlockedTodoCount} todo blocked via blockedBy (threshold ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})`}
|
||||
title={t("executor.overlapBottleneck", "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", {
|
||||
status: highestOverlapBlocker.entry.escalation ? t("executor.escalated", "Escalated") : t("executor.temporary", "Temporary"),
|
||||
blockerId: highestOverlapBlocker.blockerId,
|
||||
count: highestOverlapBlocker.entry.overlapBlockedTodoCount,
|
||||
threshold: HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
|
||||
})}
|
||||
>
|
||||
{highestOverlapBlocker.blockerId} · {highestOverlapBlocker.entry.overlapBlockedTodoCount} todo{highestOverlapBlocker.entry.escalation ? " (escalated)" : ""}
|
||||
{highestOverlapBlocker.blockerId} · {highestOverlapBlocker.entry.overlapBlockedTodoCount} todo{highestOverlapBlocker.entry.escalation ? t("executor.escalatedSuffix", " (escalated)") : ""}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -238,10 +249,10 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
<button
|
||||
className={`executor-status-bar__folder-toggle${isProjectPathVisible ? " executor-status-bar__folder-toggle--active" : ""}`}
|
||||
onClick={() => setIsProjectPathVisible((prev) => !prev)}
|
||||
aria-label={isProjectPathVisible ? "Hide project directory" : "Show project directory"}
|
||||
aria-label={isProjectPathVisible ? t("executor.hideProjectDir", "Hide project directory") : t("executor.showProjectDir", "Show project directory")}
|
||||
aria-expanded={isProjectPathVisible}
|
||||
data-testid="executor-project-path-toggle"
|
||||
title={isProjectPathVisible ? "Hide project directory" : "Show project directory"}
|
||||
title={isProjectPathVisible ? t("executor.hideProjectDir", "Hide project directory") : t("executor.showProjectDir", "Show project directory")}
|
||||
>
|
||||
<Folder size={12} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Agent, AgentOnboardingSummary, ConversationHistoryEntry, ExistingAgentOnboardingConfig, OnboardingMode } from "../api";
|
||||
import {
|
||||
cancelAgentOnboarding,
|
||||
@@ -31,6 +32,7 @@ export function ExperimentalAgentOnboardingModal({
|
||||
mode = "create",
|
||||
existingAgentConfig,
|
||||
}: ExperimentalAgentOnboardingModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [viewState, setViewState] = useState<ViewState>("initial");
|
||||
const [intent, setIntent] = useState("");
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
@@ -113,7 +115,7 @@ export function ExperimentalAgentOnboardingModal({
|
||||
|
||||
const renderSummaryValue = (value: string | number | null | undefined) => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return <em className="experimental-agent-onboarding-modal__summary-empty">Not set</em>;
|
||||
return <em className="experimental-agent-onboarding-modal__summary-empty">{t("agents.onboarding.notSet", "Not set")}</em>;
|
||||
}
|
||||
return <span>{value}</span>;
|
||||
};
|
||||
@@ -168,91 +170,91 @@ export function ExperimentalAgentOnboardingModal({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" role="presentation">
|
||||
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="AI Interview">
|
||||
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label={t("agents.onboarding.dialogLabel", "AI Interview")}>
|
||||
<div className="modal-header">
|
||||
<h3>AI Interview</h3>
|
||||
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
|
||||
<h3>{t("agents.onboarding.title", "AI Interview")}</h3>
|
||||
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.closeAriaLabel", "Close")}>×</button>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && <ConversationHistory entries={history} />}
|
||||
|
||||
{viewState === "initial" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-onboarding-intent">{isEditMode ? "What should this agent change or improve?" : "What should this new agent own?"}</label>
|
||||
<label htmlFor="agent-onboarding-intent">{isEditMode ? t("agents.onboarding.intentLabelEdit", "What should this agent change or improve?") : t("agents.onboarding.intentLabelCreate", "What should this new agent own?")}</label>
|
||||
<textarea id="agent-onboarding-intent" className="input experimental-agent-onboarding-modal__textarea" value={intent} onChange={(e) => setIntent(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{isEditMode ? "Start interview" : "Start onboarding"}</button>
|
||||
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{isEditMode ? t("agents.onboarding.startInterview", "Start interview") : t("agents.onboarding.startOnboarding", "Start onboarding")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(viewState === "loading" || viewState === "question") && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-onboarding-answer">{currentQuestion || "Thinking..."}</label>
|
||||
<label htmlFor="agent-onboarding-answer">{currentQuestion || t("agents.onboarding.thinking", "Thinking...")}</label>
|
||||
<textarea id="agent-onboarding-answer" className="input experimental-agent-onboarding-modal__textarea" value={answer} onChange={(e) => setAnswer(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>Continue</button>
|
||||
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
|
||||
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>{t("agents.onboarding.continue", "Continue")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewState === "summary" && summary && (
|
||||
<div className="form-group">
|
||||
<label>{isEditMode ? "Updated draft ready for review" : "Draft ready for review"}</label>
|
||||
<label>{isEditMode ? t("agents.onboarding.updatedDraftReady", "Updated draft ready for review") : t("agents.onboarding.draftReady", "Draft ready for review")}</label>
|
||||
<p className="experimental-agent-onboarding-modal__summary-intro">
|
||||
Review this generated draft. Nothing is applied until you confirm.
|
||||
{t("agents.onboarding.draftIntro", "Review this generated draft. Nothing is applied until you confirm.")}
|
||||
</p>
|
||||
<div className="experimental-agent-onboarding-modal__summary card">
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Identity</h4>
|
||||
<h4>{t("agents.onboarding.sectionIdentity", "Identity")}</h4>
|
||||
<dl className="experimental-agent-onboarding-modal__summary-list">
|
||||
<div><dt>Name</dt><dd>{renderSummaryValue(summary.name)}</dd></div>
|
||||
<div><dt>Role</dt><dd>{renderSummaryValue(summary.role)}</dd></div>
|
||||
<div><dt>Title</dt><dd>{renderSummaryValue(summary.title)}</dd></div>
|
||||
<div><dt>Icon</dt><dd>{renderSummaryValue(summary.icon)}</dd></div>
|
||||
<div><dt>Reports To</dt><dd>{renderSummaryValue(summary.reportsTo)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldName", "Name")}</dt><dd>{renderSummaryValue(summary.name)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldRole", "Role")}</dt><dd>{renderSummaryValue(summary.role)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldTitle", "Title")}</dt><dd>{renderSummaryValue(summary.title)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldIcon", "Icon")}</dt><dd>{renderSummaryValue(summary.icon)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldReportsTo", "Reports To")}</dt><dd>{renderSummaryValue(summary.reportsTo)}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Configuration</h4>
|
||||
<h4>{t("agents.onboarding.sectionConfiguration", "Configuration")}</h4>
|
||||
<dl className="experimental-agent-onboarding-modal__summary-list">
|
||||
<div><dt>Inline Instructions</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.instructionsText)}</dd></div>
|
||||
<div><dt>Soul</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.soul)}</dd></div>
|
||||
<div><dt>Agent Memory</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.memory)}</dd></div>
|
||||
<div><dt>Skills</dt><dd>{renderSummaryValue(summary.skills?.join(", "))}</dd></div>
|
||||
<div><dt>Thinking Level</dt><dd>{renderSummaryValue(summary.thinkingLevel)}</dd></div>
|
||||
<div><dt>Max Turns</dt><dd>{renderSummaryValue(summary.maxTurns)}</dd></div>
|
||||
<div><dt>Template</dt><dd>{renderSummaryValue(summary.templateId)}</dd></div>
|
||||
<div><dt>Pattern Agent</dt><dd>{renderSummaryValue(summary.patternAgentId)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldInlineInstructions", "Inline Instructions")}</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.instructionsText)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldSoul", "Soul")}</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.soul)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldAgentMemory", "Agent Memory")}</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.memory)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldSkills", "Skills")}</dt><dd>{renderSummaryValue(summary.skills?.join(", "))}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldThinkingLevel", "Thinking Level")}</dt><dd>{renderSummaryValue(summary.thinkingLevel)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldMaxTurns", "Max Turns")}</dt><dd>{renderSummaryValue(summary.maxTurns)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldTemplate", "Template")}</dt><dd>{renderSummaryValue(summary.templateId)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldPatternAgent", "Pattern Agent")}</dt><dd>{renderSummaryValue(summary.patternAgentId)}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{(summary.heartbeatProcedurePath || summary.heartbeatIntervalMs || summary.heartbeatEnabled !== undefined || summary.modelHint || summary.runtimeHint) && (
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Runtime Hints</h4>
|
||||
<h4>{t("agents.onboarding.sectionRuntimeHints", "Runtime Hints")}</h4>
|
||||
<dl className="experimental-agent-onboarding-modal__summary-list">
|
||||
<div><dt>Heartbeat Procedure Path</dt><dd>{renderSummaryValue(summary.heartbeatProcedurePath)}</dd></div>
|
||||
<div><dt>Heartbeat Interval</dt><dd>{renderSummaryValue(summary.heartbeatIntervalMs ? `${summary.heartbeatIntervalMs}ms` : undefined)}</dd></div>
|
||||
<div><dt>Heartbeat Enabled</dt><dd>{renderSummaryValue(summary.heartbeatEnabled === undefined ? undefined : summary.heartbeatEnabled ? "yes" : "no")}</dd></div>
|
||||
<div><dt>Model Hint</dt><dd>{renderSummaryValue(summary.modelHint)}</dd></div>
|
||||
<div><dt>Runtime Hint</dt><dd>{renderSummaryValue(summary.runtimeHint)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldHeartbeatPath", "Heartbeat Procedure Path")}</dt><dd>{renderSummaryValue(summary.heartbeatProcedurePath)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldHeartbeatInterval", "Heartbeat Interval")}</dt><dd>{renderSummaryValue(summary.heartbeatIntervalMs ? `${summary.heartbeatIntervalMs}ms` : undefined)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldHeartbeatEnabled", "Heartbeat Enabled")}</dt><dd>{renderSummaryValue(summary.heartbeatEnabled === undefined ? undefined : summary.heartbeatEnabled ? t("agents.onboarding.yes", "yes") : t("agents.onboarding.no", "no"))}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldModelHint", "Model Hint")}</dt><dd>{renderSummaryValue(summary.modelHint)}</dd></div>
|
||||
<div><dt>{t("agents.onboarding.fieldRuntimeHint", "Runtime Hint")}</dt><dd>{renderSummaryValue(summary.runtimeHint)}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.rationale && (
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Rationale</h4>
|
||||
<h4>{t("agents.onboarding.sectionRationale", "Rationale")}</h4>
|
||||
<p className="experimental-agent-onboarding-modal__summary-block">{summary.rationale}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => void handleConfirmDraft()}>{isEditMode ? "Apply draft to settings form" : "Apply draft to agent form"}</button>
|
||||
<button className="btn" onClick={() => void handleClose()}>{t("common.cancel", "Cancel")}</button>
|
||||
<button className="btn btn-primary" onClick={() => void handleConfirmDraft()}>{isEditMode ? t("agents.onboarding.applyDraftSettings", "Apply draft to settings form") : t("agents.onboarding.applyDraftAgent", "Apply draft to agent form")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -261,7 +263,7 @@ export function ExperimentalAgentOnboardingModal({
|
||||
<div className="form-group">
|
||||
<div className="form-error">{error}</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Close</button>
|
||||
<button className="btn" onClick={() => void handleClose()}>{t("common.close", "Close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./FileBrowser.css";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive } from "lucide-react";
|
||||
import type { FileNode } from "../api";
|
||||
import { copyFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api";
|
||||
@@ -98,6 +99,7 @@ interface FileContextMenuProps {
|
||||
}
|
||||
|
||||
function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPos, setAdjustedPos] = useState({ x, y });
|
||||
|
||||
@@ -142,15 +144,15 @@ function FileContextMenu({ x, y, entry, onAction, onClose }: FileContextMenuProp
|
||||
const isDir = entry.type === "directory";
|
||||
|
||||
const items: ContextMenuItem[] = [
|
||||
{ id: "copy", label: "Copy", icon: Copy, disabled: false },
|
||||
{ id: "move", label: "Move", icon: Move, disabled: false },
|
||||
{ id: "rename", label: "Rename", icon: Pencil, disabled: false },
|
||||
{ id: "copy", label: t("fileBrowser.contextCopy", "Copy"), icon: Copy, disabled: false },
|
||||
{ id: "move", label: t("fileBrowser.contextMove", "Move"), icon: Move, disabled: false },
|
||||
{ id: "rename", label: t("fileBrowser.contextRename", "Rename"), icon: Pencil, disabled: false },
|
||||
...(isDir
|
||||
? [{ id: "download-zip" as string, label: "Download as ZIP", icon: Archive, disabled: false }]
|
||||
: [{ id: "download" as string, label: "Download", icon: Download, disabled: false }]
|
||||
? [{ id: "download-zip" as string, label: t("fileBrowser.contextDownloadZip", "Download as ZIP"), icon: Archive, disabled: false }]
|
||||
: [{ id: "download" as string, label: t("fileBrowser.contextDownload", "Download"), icon: Download, disabled: false }]
|
||||
),
|
||||
{ id: "divider", label: "", icon: Copy, disabled: true },
|
||||
{ id: "delete", label: "Delete", icon: Trash2, disabled: false },
|
||||
{ id: "delete", label: t("fileBrowser.contextDelete", "Delete"), icon: Trash2, disabled: false },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -199,6 +201,7 @@ interface OperationDialogProps {
|
||||
}
|
||||
|
||||
function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, loading, error }: OperationDialogProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const defaultValue = type === "rename" ? entry.name : "";
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
@@ -233,22 +236,22 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
|
||||
return (
|
||||
<div className="context-menu-overlay" onClick={onCancel}>
|
||||
<div className="file-browser-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="file-browser-dialog-title">Delete {entry.type === "directory" ? "Folder" : "File"}</div>
|
||||
<div className="file-browser-dialog-title">{t("fileBrowser.deleteTitle", "Delete {{type}}", { type: entry.type === "directory" ? t("fileBrowser.typeFolder", "Folder") : t("fileBrowser.typeFile", "File") })}</div>
|
||||
<div className="file-browser-dialog-message">
|
||||
Are you sure you want to delete <strong>{entry.name}</strong>?
|
||||
{entry.type === "directory" && " This will delete all contents recursively."}
|
||||
{t("fileBrowser.deleteConfirm", "Are you sure you want to delete {{name}}?", { name: entry.name })}
|
||||
{entry.type === "directory" && ` ${t("fileBrowser.deleteRecursive", "This will delete all contents recursively.")}`}
|
||||
</div>
|
||||
{error && <div className="file-browser-dialog-error">{error}</div>}
|
||||
<div className="file-browser-dialog-actions">
|
||||
<button className="btn btn-sm" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => onConfirm("")}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Deleting..." : "Delete"}
|
||||
{loading ? t("fileBrowser.deleting", "Deleting...") : t("fileBrowser.delete", "Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,9 +260,9 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
|
||||
}
|
||||
|
||||
const labels: Record<string, { title: string; placeholder: string; confirm: string }> = {
|
||||
copy: { title: "Copy", placeholder: "Destination path", confirm: "Copy" },
|
||||
move: { title: "Move", placeholder: "Destination path", confirm: "Move" },
|
||||
rename: { title: "Rename", placeholder: "New name", confirm: "Rename" },
|
||||
copy: { title: t("fileBrowser.copyTitle", "Copy"), placeholder: t("fileBrowser.copyPlaceholder", "Destination path"), confirm: t("fileBrowser.copy", "Copy") },
|
||||
move: { title: t("fileBrowser.moveTitle", "Move"), placeholder: t("fileBrowser.movePlaceholder", "Destination path"), confirm: t("fileBrowser.move", "Move") },
|
||||
rename: { title: t("fileBrowser.renameTitle", "Rename"), placeholder: t("fileBrowser.renamePlaceholder", "New name"), confirm: t("fileBrowser.rename", "Rename") },
|
||||
};
|
||||
|
||||
const config = labels[type!];
|
||||
@@ -284,14 +287,14 @@ function OperationDialog({ type, entry, entryFullPath, onConfirm, onCancel, load
|
||||
{error && <div className="file-browser-dialog-error">{error}</div>}
|
||||
<div className="file-browser-dialog-actions">
|
||||
<button className="btn btn-sm" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => onConfirm(value.trim())}
|
||||
disabled={loading || !value.trim()}
|
||||
>
|
||||
{loading ? `${config.confirm}ing...` : config.confirm}
|
||||
{loading ? `${config.confirm}${t("fileBrowser.operationSuffix", "ing...")}` : config.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,6 +316,7 @@ export function FileBrowser({
|
||||
onRefresh,
|
||||
projectId,
|
||||
}: FileBrowserProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(INITIAL_CONTEXT_MENU);
|
||||
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
@@ -526,7 +530,7 @@ export function FileBrowser({
|
||||
return (
|
||||
<div className="file-browser-loading">
|
||||
<Loader2 className="spin" size={24} />
|
||||
<span>Loading files...</span>
|
||||
<span>{t("fileBrowser.loadingFiles", "Loading files...")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -534,10 +538,10 @@ export function FileBrowser({
|
||||
if (error) {
|
||||
return (
|
||||
<div className="file-browser-error">
|
||||
<p>Error: {error}</p>
|
||||
<p>{t("fileBrowser.error", "Error: {{message}}", { message: error })}</p>
|
||||
{onRetry && (
|
||||
<button className="btn btn-sm" onClick={onRetry}>
|
||||
Retry
|
||||
{t("common.retry", "Retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -555,15 +559,15 @@ export function FileBrowser({
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
|
||||
Up one level
|
||||
{t("fileBrowser.upOneLevel", "Up one level")}
|
||||
</button>
|
||||
)}
|
||||
<span className="file-browser-path">{currentPath === "." ? "Root" : normalizeDisplayPath(currentPath)}</span>
|
||||
<span className="file-browser-path">{currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)}</span>
|
||||
</div>
|
||||
|
||||
<div className="file-browser-list">
|
||||
{entries.length === 0 ? (
|
||||
<div className="file-browser-empty">(empty directory)</div>
|
||||
<div className="file-browser-empty">{t("fileBrowser.emptyDirectory", "(empty directory)")}</div>
|
||||
) : (
|
||||
entries.map((entry) => {
|
||||
const fullPath = entryPath(currentPath, entry.name);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./FileBrowser.css";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
|
||||
@@ -75,6 +76,7 @@ export function FileBrowserModal({
|
||||
onWorkspaceChange,
|
||||
projectId,
|
||||
}: FileBrowserModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { projectName, workspaces } = useWorkspaces(projectId);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:files-modal-size");
|
||||
@@ -278,11 +280,11 @@ export function FileBrowserModal({
|
||||
|
||||
const workspaceLabel = useMemo(() => {
|
||||
if (currentWorkspace === "project") {
|
||||
return "Project";
|
||||
return t("fileBrowser.workspaceProject", "Project");
|
||||
}
|
||||
|
||||
return workspaces.find((workspace) => workspace.id === currentWorkspace)?.id ?? currentWorkspace;
|
||||
}, [currentWorkspace, workspaces]);
|
||||
}, [currentWorkspace, workspaces, t]);
|
||||
|
||||
const modalTitle = `Files — ${workspaceLabel}`;
|
||||
|
||||
@@ -318,7 +320,7 @@ export function FileBrowserModal({
|
||||
workspaces={workspaces}
|
||||
onSelect={handleWorkspaceSelect}
|
||||
/>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -367,10 +369,10 @@ export function FileBrowserModal({
|
||||
<button
|
||||
className="file-browser-back-button"
|
||||
onClick={handleBackToList}
|
||||
aria-label="Back to file list"
|
||||
aria-label={t("fileBrowser.back", "Back to file list")}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
<span>{t("actions.back", "Back")}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isBinaryFile(selectedFile) && (
|
||||
@@ -389,7 +391,7 @@ export function FileBrowserModal({
|
||||
{isBinaryFile(selectedFile) && (
|
||||
<span className="file-browser-binary-indicator">
|
||||
<FileType size={12} />
|
||||
Binary file — read only
|
||||
{t("fileBrowser.binaryReadOnly", "Binary file — read only")}
|
||||
</span>
|
||||
)}
|
||||
{mtime && (
|
||||
@@ -398,7 +400,7 @@ export function FileBrowserModal({
|
||||
</span>
|
||||
)}
|
||||
{editorLoading && (
|
||||
<span className="file-browser-loading">Loading...</span>
|
||||
<span className="file-browser-loading">{t("fileBrowser.loading", "Loading…")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="file-browser-actions">
|
||||
@@ -410,7 +412,7 @@ export function FileBrowserModal({
|
||||
disabled={saving}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Discard
|
||||
{t("actions.discard", "Discard")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
@@ -418,7 +420,7 @@ export function FileBrowserModal({
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={14} />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? t("fileBrowser.saving", "Saving…") : t("actions.save", "Save")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -456,14 +458,14 @@ export function FileBrowserModal({
|
||||
{!imageSrc && (
|
||||
<div className="file-browser-footer">
|
||||
<span>{formatFileSize(content)}</span>
|
||||
{hasChanges && <span className="file-browser-unsaved">Unsaved changes</span>}
|
||||
{hasChanges && <span className="file-browser-unsaved">{t("fileBrowser.unsavedChanges", "Unsaved changes")}</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="file-browser-placeholder">
|
||||
<Folder size={48} opacity={0.3} />
|
||||
<p>Select a file to edit</p>
|
||||
<p>{t("fileBrowser.selectFileToEdit", "Select a file to edit")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useMemo, useRef, useId, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react";
|
||||
@@ -45,6 +46,7 @@ export function FileEditor({
|
||||
toolbarExpanded,
|
||||
toolbarActionsId: externalToolbarActionsId,
|
||||
}: FileEditorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [wordWrap, setWordWrap] = useState(true);
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -186,7 +188,7 @@ export function FileEditor({
|
||||
{hasToolbarActions && (expanded || !isControlled) ? (
|
||||
<div className={`file-editor-toolbar ${expanded ? "file-editor-toolbar--expanded" : ""}`}>
|
||||
{!isControlled && (
|
||||
<button className="btn btn-sm btn-icon file-editor-toolbar-button" onClick={handleToolbarActionsToggle} aria-label="Toggle editor options" title="Toggle editor options" aria-expanded={expanded} aria-controls={toolbarActionsId}>
|
||||
<button className="btn btn-sm btn-icon file-editor-toolbar-button" onClick={handleToolbarActionsToggle} aria-label={t("fileEditor.toggleOptions", "Toggle editor options")} title={t("fileEditor.toggleOptions", "Toggle editor options")} aria-expanded={expanded} aria-controls={toolbarActionsId}>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
)}
|
||||
@@ -194,27 +196,27 @@ export function FileEditor({
|
||||
{isMarkdown ? (
|
||||
<>
|
||||
{!readOnly && (
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${!effectiveShowPreview ? "btn-primary" : ""}`} onClick={handleEditClick} disabled={!effectiveShowPreview} aria-label="Edit mode">
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${!effectiveShowPreview ? "btn-primary" : ""}`} onClick={handleEditClick} disabled={!effectiveShowPreview} aria-label={t("fileEditor.editMode", "Edit mode")}>
|
||||
<FileEdit size={14} />
|
||||
Edit
|
||||
{t("fileEditor.edit", "Edit")}
|
||||
</button>
|
||||
)}
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${effectiveShowPreview ? "btn-primary" : ""}`} onClick={handlePreviewClick} disabled={effectiveShowPreview} aria-label="Preview mode">
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${effectiveShowPreview ? "btn-primary" : ""}`} onClick={handlePreviewClick} disabled={effectiveShowPreview} aria-label={t("fileEditor.previewMode", "Preview mode")}>
|
||||
<Eye size={14} />
|
||||
Preview
|
||||
{t("fileEditor.preview", "Preview")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{shouldShowLineNumbersToggle && (
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${showLineNumbers ? "btn-primary" : ""}`} onClick={onToggleLineNumbers} aria-label="Toggle line numbers" aria-pressed={showLineNumbers} title="Toggle line numbers">
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${showLineNumbers ? "btn-primary" : ""}`} onClick={onToggleLineNumbers} aria-label={t("fileEditor.toggleLineNumbers", "Toggle line numbers")} aria-pressed={showLineNumbers} title={t("fileEditor.toggleLineNumbers", "Toggle line numbers")}>
|
||||
<ListOrdered size={14} />
|
||||
<span>Line #</span>
|
||||
<span>{t("fileEditor.lineNumber", "Line #")}</span>
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${wordWrap ? "btn-primary" : ""}`} onClick={handleWordWrapToggle} aria-label="Toggle word wrap" title="Toggle word wrap">
|
||||
<button className={`btn btn-sm file-editor-toolbar-button ${wordWrap ? "btn-primary" : ""}`} onClick={handleWordWrapToggle} aria-label={t("fileEditor.toggleWordWrap", "Toggle word wrap")} title={t("fileEditor.toggleWordWrap", "Toggle word wrap")}>
|
||||
<WrapText size={14} />
|
||||
<span>Wrap</span>
|
||||
<span>{t("fileEditor.wrap", "Wrap")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,7 +228,7 @@ export function FileEditor({
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="file-editor-codemirror" ref={editorHostRef} aria-label={filePath ? `Editor for ${filePath}` : "File editor"} />
|
||||
<div className="file-editor-codemirror" ref={editorHostRef} aria-label={filePath ? t("fileEditor.editorFor", `Editor for ${filePath}`) : t("fileEditor.fileEditor", "File editor")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { File, Hash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileSearchItem, TaskSearchItem } from "../hooks/useFileMention";
|
||||
import { getDisplayDirname } from "../utils/pathDisplay";
|
||||
import "./FileMentionPopup.css";
|
||||
@@ -38,6 +39,8 @@ export function FileMentionPopup({
|
||||
onSelectFile,
|
||||
loading,
|
||||
}: FileMentionPopupProps): ReactNode | null {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
@@ -62,7 +65,7 @@ export function FileMentionPopup({
|
||||
|
||||
{!loading && !hasTasks && !hasFiles && (
|
||||
<div className="file-mention-popup-empty" data-testid="file-mention-empty">
|
||||
No tasks or files found
|
||||
{t("fileMention.empty", "No tasks or files found")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -70,8 +73,8 @@ export function FileMentionPopup({
|
||||
<div className="file-mention-popup-groups">
|
||||
{hasTasks && (
|
||||
<div className="file-mention-popup-group">
|
||||
<div className="file-mention-popup-group-header">Tasks</div>
|
||||
<ul className="file-mention-popup-list" role="listbox" aria-label="Task matches">
|
||||
<div className="file-mention-popup-group-header">{t("fileMention.taskHeader", "Tasks")}</div>
|
||||
<ul className="file-mention-popup-list" role="listbox" aria-label={t("fileMention.taskMatches", "Task matches")}>
|
||||
{tasks.map((task, index) => {
|
||||
const rowIndex = getTaskRowIndex(index);
|
||||
return (
|
||||
@@ -106,8 +109,8 @@ export function FileMentionPopup({
|
||||
|
||||
{hasFiles && (
|
||||
<div className="file-mention-popup-group">
|
||||
<div className="file-mention-popup-group-header">Files</div>
|
||||
<ul className="file-mention-popup-list" role="listbox" aria-label="File matches">
|
||||
<div className="file-mention-popup-group-header">{t("fileMention.fileHeader", "Files")}</div>
|
||||
<ul className="file-mention-popup-list" role="listbox" aria-label={t("fileMention.fileMatches", "File matches")}>
|
||||
{files.map((file, index) => {
|
||||
const rowIndex = getFileRowIndex(tasks.length, index);
|
||||
const dirPath = getDisplayDirname(file.path);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./GitHubImportModal.css";
|
||||
import { useState, useEffect, useCallback, useRef, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ function clampListPaneWidth(width: number) {
|
||||
|
||||
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId }: GitHubImportModalProps) {
|
||||
useMobileScrollLock(isOpen);
|
||||
const { t } = useTranslation("app");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
const [labels, setLabels] = useState("");
|
||||
@@ -57,6 +59,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const [selectedPullNumber, setSelectedPullNumber] = useState<number | null>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isIssuesEmptyState, setIsIssuesEmptyState] = useState(false);
|
||||
const [isPullsEmptyState, setIsPullsEmptyState] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
// Git remotes state
|
||||
@@ -121,6 +125,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
setSelectedPullNumber(null);
|
||||
setActiveTab("issues");
|
||||
setError(null);
|
||||
setIsIssuesEmptyState(false);
|
||||
setIsPullsEmptyState(false);
|
||||
setImporting(false);
|
||||
setRemotes([]);
|
||||
setLoadingRemotes(true);
|
||||
@@ -181,12 +187,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
// Handle load issues - defined BEFORE the auto-load useEffect
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (!owner.trim() || !repo.trim()) {
|
||||
setError("Repository must be selected");
|
||||
setError(t("git.repoMustBeSelected", "Repository must be selected"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setIsIssuesEmptyState(false);
|
||||
setIssues([]);
|
||||
setSelectedIssueNumber(null);
|
||||
|
||||
@@ -198,10 +205,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const fetchedIssues = await apiFetchGitHubIssues(owner.trim(), repo.trim(), 30, labelArray.length > 0 ? labelArray : undefined);
|
||||
setIssues(fetchedIssues);
|
||||
if (fetchedIssues.length === 0) {
|
||||
setError("No open issues found");
|
||||
setIsIssuesEmptyState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Failed to fetch issues");
|
||||
setError(getErrorMessage(err) || t("git.failedToFetchIssues", "Failed to fetch issues"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -210,12 +217,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
// Handle load pull requests
|
||||
const handleLoadPulls = useCallback(async () => {
|
||||
if (!owner.trim() || !repo.trim()) {
|
||||
setError("Repository must be selected");
|
||||
setError(t("git.repoMustBeSelected", "Repository must be selected"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setIsPullsEmptyState(false);
|
||||
setPulls([]);
|
||||
setSelectedPullNumber(null);
|
||||
|
||||
@@ -223,10 +231,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const fetchedPulls = await apiFetchGitHubPulls(owner.trim(), repo.trim(), 30);
|
||||
setPulls(fetchedPulls);
|
||||
if (fetchedPulls.length === 0) {
|
||||
setError("No open pull requests found");
|
||||
setIsPullsEmptyState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Failed to fetch pull requests");
|
||||
setError(getErrorMessage(err) || t("git.failedToFetchPulls", "Failed to fetch pull requests"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -393,7 +401,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
if (msg?.includes("already imported")) {
|
||||
setError(msg);
|
||||
} else {
|
||||
setError(msg || "Failed to import issue");
|
||||
setError(msg || t("git.failedToImportIssue", "Failed to import issue"));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
@@ -416,7 +424,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
if (msg?.includes("already imported")) {
|
||||
setError(msg);
|
||||
} else {
|
||||
setError(msg || "Failed to import pull request");
|
||||
setError(msg || t("git.failedToImportPull", "Failed to import pull request"));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
@@ -438,8 +446,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
const importedPullCount = pulls.filter((pull) => importedUrls.has(pull.html_url)).length;
|
||||
|
||||
// Empty states
|
||||
const isIssuesEmpty = error === "No open issues found";
|
||||
const isPullsEmpty = error === "No open pull requests found";
|
||||
const isIssuesEmpty = isIssuesEmptyState;
|
||||
const isPullsEmpty = isPullsEmptyState;
|
||||
const isEmptyState = activeTab === "issues" ? isIssuesEmpty : isPullsEmpty;
|
||||
|
||||
// Results error state
|
||||
@@ -462,19 +470,19 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
<div className="modal modal-lg github-import-modal" ref={modalRef}>
|
||||
<div className="modal-header github-import-modal__header">
|
||||
<div>
|
||||
<h3>Import from GitHub</h3>
|
||||
<h3>{t("git.importFromGitHub", "Import from GitHub")}</h3>
|
||||
<p className="github-import-modal__subtitle">
|
||||
Choose a detected remote, load open issues or pull requests, and import one into the board.
|
||||
{t("git.importSubtitle", "Choose a detected remote, load open issues or pull requests, and import one into the board.")}
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close import modal">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("git.closeModalAriaLabel", "Close import modal")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body github-import-modal__body">
|
||||
{/* Tab Navigation */}
|
||||
<div className="github-import-tabs" role="tablist" aria-label="Import type">
|
||||
<div className="github-import-tabs" role="tablist" aria-label={t("git.importTypeAriaLabel", "Import type")}>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={activeTab === "issues"}
|
||||
@@ -487,7 +495,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
disabled={loading || importing}
|
||||
>
|
||||
<CircleDot size={16} />
|
||||
<span>Issues</span>
|
||||
<span>{t("git.tabIssues", "Issues")}</span>
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
@@ -501,21 +509,21 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
disabled={loading || importing}
|
||||
>
|
||||
<GitPullRequest size={16} />
|
||||
<span>Pull Requests</span>
|
||||
<span>{t("git.tabPullRequests", "Pull Requests")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compact Toolbar */}
|
||||
<div className="github-import-toolbar" data-testid="github-import-toolbar" role="toolbar" aria-label="GitHub import controls">
|
||||
<div className="github-import-toolbar" data-testid="github-import-toolbar" role="toolbar" aria-label={t("git.toolbarAriaLabel", "GitHub import controls")}>
|
||||
{/* Left: Remote selector */}
|
||||
<div className="github-import-toolbar__zone github-import-toolbar__zone--remote">
|
||||
{loadingRemotes ? (
|
||||
<div className="github-import-toolbar__loading" role="status" aria-live="polite">
|
||||
<Loader2 size={16} className="spin" />
|
||||
<span>Detecting…</span>
|
||||
<span>{t("git.detectingRemotes", "Detecting…")}</span>
|
||||
</div>
|
||||
) : !hasRemotes ? (
|
||||
<span className="github-import-toolbar__no-remote">No remotes</span>
|
||||
<span className="github-import-toolbar__no-remote">{t("git.noRemotes", "No remotes")}</span>
|
||||
) : singleRemote ? (
|
||||
<div className="github-import-remote-pill" data-testid="github-import-single-remote">
|
||||
<span className="github-import-remote-pill__name">{remotes[0].name}</span>
|
||||
@@ -523,15 +531,15 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
</div>
|
||||
) : (
|
||||
<div className="github-import-remote-select">
|
||||
<label htmlFor="gh-remote" className="visually-hidden">Repository</label>
|
||||
<label htmlFor="gh-remote" className="visually-hidden">{t("git.repositoryLabel", "Repository")}</label>
|
||||
<select
|
||||
id="gh-remote"
|
||||
value={selectedRemoteName}
|
||||
onChange={(e) => handleRemoteChange(e.target.value)}
|
||||
disabled={loading || importing}
|
||||
aria-label="Select Git remote"
|
||||
aria-label={t("git.selectRemoteAriaLabel", "Select Git remote")}
|
||||
>
|
||||
<option value="">Select remote…</option>
|
||||
<option value="">{t("git.selectRemotePlaceholder", "Select remote…")}</option>
|
||||
{remotes.map((remote) => (
|
||||
<option key={remote.name} value={remote.name}>
|
||||
{remote.name} ({remote.owner}/{remote.repo})
|
||||
@@ -546,21 +554,21 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
<div className="github-import-toolbar__zone github-import-toolbar__zone--filter">
|
||||
{activeTab === "issues" ? (
|
||||
<>
|
||||
<label htmlFor="gh-labels" className="visually-hidden">Filter by labels</label>
|
||||
<label htmlFor="gh-labels" className="visually-hidden">{t("git.filterByLabelsLabel", "Filter by labels")}</label>
|
||||
<input
|
||||
id="gh-labels"
|
||||
type="text"
|
||||
placeholder="Filter: bug,enhancement…"
|
||||
placeholder={t("git.filterByLabelsPlaceholder", "Filter: bug,enhancement…")}
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
|
||||
disabled={loading || importing || !hasRemotes}
|
||||
aria-label="Filter issues by labels"
|
||||
aria-label={t("git.filterIssuesByLabels", "Filter issues by labels")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="github-import-filter-hint">
|
||||
Open pull requests from {owner || "selected remote"}
|
||||
{t("git.openPullsFrom", "Open pull requests from {{remote}}", { remote: owner || t("git.selectedRemote", "selected remote") })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -572,11 +580,11 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
className="btn btn-primary github-import-load-button"
|
||||
onClick={activeTab === "issues" ? handleLoad : handleLoadPulls}
|
||||
disabled={loading || importing || !owner.trim() || !repo.trim()}
|
||||
aria-label={loading ? `Loading ${activeTab}` : `Load ${activeTab} from repository`}
|
||||
title={loading ? "Loading…" : `Load ${activeTab}`}
|
||||
aria-label={loading ? t("git.loadingAriaLabel", "Loading {{tab}}", { tab: activeTab }) : t("git.loadFromRepoAriaLabel", "Load {{tab}} from repository", { tab: activeTab })}
|
||||
title={loading ? t("git.loadingTitle", "Loading…") : t("git.loadTabTitle", "Load {{tab}}", { tab: activeTab })}
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
<span>{loading ? "Loading…" : "Load"}</span>
|
||||
<span>{loading ? t("git.loading", "Loading…") : t("git.load", "Load")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -585,8 +593,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{!loadingRemotes && !hasRemotes && (
|
||||
<div className="github-import-state github-import-state--warning" role="alert">
|
||||
<div>
|
||||
<strong>No GitHub remotes detected</strong>
|
||||
<span>Add a GitHub remote to this repository, then reopen the modal.</span>
|
||||
<strong>{t("git.noRemotesDetected", "No GitHub remotes detected")}</strong>
|
||||
<span>{t("git.noRemotesInstructions", "Add a GitHub remote to this repository, then reopen the modal.")}</span>
|
||||
</div>
|
||||
<code className="github-import-command">
|
||||
git remote add origin https://github.com/owner/repo.git
|
||||
@@ -611,18 +619,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
>
|
||||
<div className="github-import-pane-header">
|
||||
<h4 id="github-import-results-heading">
|
||||
{activeTab === "issues" ? "Issues" : "Pull Requests"}
|
||||
{activeTab === "issues" ? t("git.tabIssues", "Issues") : t("git.tabPullRequests", "Pull Requests")}
|
||||
</h4>
|
||||
{activeTab === "issues" && issues.length > 0 && (
|
||||
<div className="github-import-results-meta" aria-live="polite">
|
||||
<span>{issues.length} issue{issues.length === 1 ? "" : "s"}</span>
|
||||
<span>{importedIssueCount} imported</span>
|
||||
<span>{t("git.issueCount", { count: issues.length, defaultValue_one: "{{count}} issue", defaultValue_other: "{{count}} issues" })}</span>
|
||||
<span>{t("git.importedCount", "{{count}} imported", { count: importedIssueCount })}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "pulls" && pulls.length > 0 && (
|
||||
<div className="github-import-results-meta" aria-live="polite">
|
||||
<span>{pulls.length} pull request{pulls.length === 1 ? "" : "s"}</span>
|
||||
<span>{importedPullCount} imported</span>
|
||||
<span>{t("git.pullCount", { count: pulls.length, defaultValue_one: "{{count}} pull request", defaultValue_other: "{{count}} pull requests" })}</span>
|
||||
<span>{t("git.importedCount", "{{count}} imported", { count: importedPullCount })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -631,8 +639,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{!hasResultsContent && (
|
||||
<div className="github-import-state github-import-state--idle" data-testid="github-import-results-idle">
|
||||
<div>
|
||||
<strong>Nothing loaded yet</strong>
|
||||
<span>Select a repository and click Load to start reviewing import candidates.</span>
|
||||
<strong>{t("git.nothingLoadedYet", "Nothing loaded yet")}</strong>
|
||||
<span>{t("git.nothingLoadedInstructions", "Select a repository and click Load to start reviewing import candidates.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -641,8 +649,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
<div className="github-import-state github-import-state--loading" role="status" aria-live="polite">
|
||||
<Loader2 size={16} className="spin" />
|
||||
<div>
|
||||
<strong>Loading open {activeTab === "issues" ? "issues" : "pull requests"}…</strong>
|
||||
<span>Fetching the latest list from GitHub.</span>
|
||||
<strong>{activeTab === "issues" ? t("git.loadingIssues", "Loading open issues…") : t("git.loadingPulls", "Loading open pull requests…")}</strong>
|
||||
<span>{t("git.fetchingFromGitHub", "Fetching the latest list from GitHub.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -650,7 +658,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{isResultsError && (
|
||||
<div className="github-import-state github-import-state--error" role="alert">
|
||||
<div>
|
||||
<strong>Could not load {activeTab === "issues" ? "issues" : "pull requests"}</strong>
|
||||
<strong>{activeTab === "issues" ? t("git.couldNotLoadIssues", "Could not load issues") : t("git.couldNotLoadPulls", "Could not load pull requests")}</strong>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,8 +667,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{isEmptyState && (
|
||||
<div className="github-import-state github-import-state--empty" role="status">
|
||||
<div>
|
||||
<strong>No open {activeTab === "issues" ? "issues" : "pull requests"} found</strong>
|
||||
<span>{activeTab === "issues" ? "Try a different label filter or choose another repository." : "Choose another repository."}</span>
|
||||
<strong>{activeTab === "issues" ? t("git.noOpenIssues", "No open issues found") : t("git.noOpenPulls", "No open pull requests found")}</strong>
|
||||
<span>{activeTab === "issues" ? t("git.tryDifferentFilter", "Try a different label filter or choose another repository.") : t("git.chooseAnotherRepo", "Choose another repository.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -682,7 +690,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
checked={selectedIssueNumber === issue.number}
|
||||
onChange={() => handleIssueSelect(issue.number)}
|
||||
disabled={isImported}
|
||||
aria-label={`Select issue #${issue.number}`}
|
||||
aria-label={t("git.selectIssueAriaLabel", "Select issue #{{number}}", { number: issue.number })}
|
||||
/>
|
||||
<div className="issue-main">
|
||||
<div className="issue-heading-row">
|
||||
@@ -699,7 +707,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isImported && <span className="imported-badge">Imported</span>}
|
||||
{isImported && <span className="imported-badge">{t("git.imported", "Imported")}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -723,7 +731,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
checked={selectedPullNumber === pull.number}
|
||||
onChange={() => handlePullSelect(pull.number)}
|
||||
disabled={isImported}
|
||||
aria-label={`Select pull request #${pull.number}`}
|
||||
aria-label={t("git.selectPullAriaLabel", "Select pull request #{{number}}", { number: pull.number })}
|
||||
/>
|
||||
<div className="issue-main">
|
||||
<div className="issue-heading-row">
|
||||
@@ -734,7 +742,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{pull.headBranch} → {pull.baseBranch}
|
||||
</span>
|
||||
</div>
|
||||
{isImported && <span className="imported-badge">Imported</span>}
|
||||
{isImported && <span className="imported-badge">{t("git.imported", "Imported")}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -748,7 +756,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
className="github-import-workspace__resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize issues list"
|
||||
aria-label={t("git.resizeIssuesList", "Resize issues list")}
|
||||
aria-valuemin={GITHUB_IMPORT_LIST_PANE_MIN_WIDTH}
|
||||
aria-valuemax={GITHUB_IMPORT_LIST_PANE_MAX_WIDTH}
|
||||
aria-valuenow={listPaneWidth}
|
||||
@@ -771,32 +779,32 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
className="github-import-back-button"
|
||||
onClick={handleBackToList}
|
||||
data-testid="github-import-back-button"
|
||||
aria-label={`Back to ${activeTab === "issues" ? "issues" : "pull requests"} list`}
|
||||
aria-label={activeTab === "issues" ? t("git.backToIssuesList", "Back to issues list") : t("git.backToPullsList", "Back to pull requests list")}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
<span>{t("common.back", "Back")}</span>
|
||||
</button>
|
||||
)}
|
||||
<h4 id="github-import-preview-heading">Preview</h4>
|
||||
<h4 id="github-import-preview-heading">{t("git.previewHeading", "Preview")}</h4>
|
||||
</div>
|
||||
|
||||
<div className="github-import-pane-content">
|
||||
{/* Issue preview */}
|
||||
{activeTab === "issues" && selectedIssue ? (
|
||||
<div className="issue-preview" data-testid="github-import-preview-card">
|
||||
<div className="preview-meta">Issue #{selectedIssue.number}</div>
|
||||
<div className="preview-meta">{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}</div>
|
||||
<div className="preview-title">{selectedIssue.title}</div>
|
||||
<div className="preview-body">
|
||||
{selectedIssue.body
|
||||
? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "")
|
||||
: "(no description)"}
|
||||
: t("git.noDescription", "(no description)")}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "issues" ? (
|
||||
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">
|
||||
<div>
|
||||
<strong>No issue selected</strong>
|
||||
<span>Choose an issue from the list to inspect its title and description.</span>
|
||||
<strong>{t("git.noIssueSelected", "No issue selected")}</strong>
|
||||
<span>{t("git.noIssueSelectedHint", "Choose an issue from the list to inspect its title and description.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -804,22 +812,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
{/* Pull request preview */}
|
||||
{activeTab === "pulls" && selectedPull ? (
|
||||
<div className="issue-preview" data-testid="github-import-preview-card">
|
||||
<div className="preview-meta">Pull Request #{selectedPull.number}</div>
|
||||
<div className="preview-meta">{t("git.previewPullMeta", "Pull Request #{{number}}", { number: selectedPull.number })}</div>
|
||||
<div className="preview-title">{selectedPull.title}</div>
|
||||
<div className="preview-branch">
|
||||
<strong>Branch:</strong> {selectedPull.headBranch} → {selectedPull.baseBranch}
|
||||
<strong>{t("git.branchLabel", "Branch:")}</strong> {selectedPull.headBranch} → {selectedPull.baseBranch}
|
||||
</div>
|
||||
<div className="preview-body">
|
||||
{selectedPull.body
|
||||
? selectedPull.body.slice(0, 200) + (selectedPull.body.length > 200 ? "…" : "")
|
||||
: "(no description)"}
|
||||
: t("git.noDescription", "(no description)")}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "pulls" ? (
|
||||
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">
|
||||
<div>
|
||||
<strong>No pull request selected</strong>
|
||||
<span>Choose a pull request from the list to inspect its details.</span>
|
||||
<strong>{t("git.noPullSelected", "No pull request selected")}</strong>
|
||||
<span>{t("git.noPullSelectedHint", "Choose a pull request from the list to inspect its details.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -830,7 +838,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
|
||||
<div className="modal-actions github-import-modal__actions">
|
||||
<button className="btn" onClick={onClose} disabled={importing}>
|
||||
Cancel
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -839,7 +847,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId
|
||||
(activeTab === "issues" ? selectedIssueNumber === null : selectedPullNumber === null) || importing
|
||||
}
|
||||
>
|
||||
{importing ? <Loader2 size={14} className="spin" /> : "Import"}
|
||||
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Goal } from "@fusion/core";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -14,7 +15,6 @@ export interface GoalsViewProps {
|
||||
const MAX_ACTIVE_GOALS = 5;
|
||||
const WARNING_THRESHOLD = 3;
|
||||
|
||||
const CAP_ERROR_MESSAGE = "Cannot activate more than 5 goals. Resolve an active goal before activating another.";
|
||||
const GOAL_DESCRIPTION_TOGGLE_LENGTH = 280;
|
||||
|
||||
function isCapError(payload: unknown): boolean {
|
||||
@@ -22,6 +22,7 @@ function isCapError(payload: unknown): boolean {
|
||||
}
|
||||
|
||||
export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [goals, setGoals] = useState<Goal[]>(() => initialGoals ?? []);
|
||||
const [highlightedGoalId, setHighlightedGoalId] = useState<string | null>(null);
|
||||
const anchorTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -66,7 +67,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage("Unable to load goals right now. Please try again.");
|
||||
setErrorMessage(t("goals.loadError", "Unable to load goals right now. Please try again."));
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
@@ -147,7 +148,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
async function draftAddGoalDescription() {
|
||||
const title = addTitle.trim();
|
||||
if (!title) {
|
||||
setAddError("Title is required.");
|
||||
setAddError(t("goals.titleRequired", "Title is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,7 +167,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
async function submitAddGoal() {
|
||||
const title = addTitle.trim();
|
||||
if (!title) {
|
||||
setAddError("Title is required.");
|
||||
setAddError(t("goals.titleRequired", "Title is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,13 +201,13 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
}
|
||||
|
||||
if (response.status === 409 && isCapError(payload)) {
|
||||
setErrorMessage(CAP_ERROR_MESSAGE);
|
||||
setErrorMessage(t("goals.capError", "Cannot activate more than 5 goals. Resolve an active goal before activating another."));
|
||||
return;
|
||||
}
|
||||
|
||||
setAddError("Unable to create goal right now. Please try again.");
|
||||
setAddError(t("goals.createError", "Unable to create goal right now. Please try again."));
|
||||
} catch {
|
||||
setAddError("Unable to create goal right now. Please try again.");
|
||||
setAddError(t("goals.createError", "Unable to create goal right now. Please try again."));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -219,7 +220,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
|
||||
const title = editTitle.trim();
|
||||
if (!title) {
|
||||
setEditError("Title is required.");
|
||||
setEditError(t("goals.titleRequired", "Title is required."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,7 +243,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
setGoals((current) => current.map((goal) => (goal.id === updatedGoal.id ? updatedGoal : goal)));
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setEditError("Unable to save goal right now. Please try again.");
|
||||
setEditError(t("goals.saveError", "Unable to save goal right now. Please try again."));
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
@@ -287,13 +288,13 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
}
|
||||
|
||||
if (response.status === 409 && isCapError(payload)) {
|
||||
setErrorMessage(CAP_ERROR_MESSAGE);
|
||||
setErrorMessage(t("goals.capError", "Cannot activate more than 5 goals. Resolve an active goal before activating another."));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("Unable to update goal status right now. Please try again.");
|
||||
setErrorMessage(t("goals.updateError", "Unable to update goal status right now. Please try again."));
|
||||
} catch {
|
||||
setErrorMessage("Unable to update goal status right now. Please try again.");
|
||||
setErrorMessage(t("goals.updateError", "Unable to update goal status right now. Please try again."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,21 +302,21 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
<section className="goals-view" data-testid="goals-view">
|
||||
<header className="goals-header">
|
||||
<div>
|
||||
<h2 className="goals-title">Goals</h2>
|
||||
<h2 className="goals-title">{t("goals.title", "Goals")}</h2>
|
||||
<p className="goals-count" data-testid="goals-active-count">
|
||||
{activeCount} active goals
|
||||
{t("goals.activeCount", "{{count}} active goals", { count: activeCount })}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary goals-add-button" onClick={openAddForm} data-testid="goals-add-button">
|
||||
<Plus aria-hidden="true" />
|
||||
Add Goal
|
||||
{t("goals.addGoal", "Add Goal")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isAddFormOpen ? (
|
||||
<div className="card goals-form" data-testid="goals-form">
|
||||
<label className="goals-form-label" htmlFor="goals-form-title">
|
||||
Title
|
||||
{t("goals.labelTitle", "Title")}
|
||||
</label>
|
||||
<input
|
||||
id="goals-form-title"
|
||||
@@ -328,7 +329,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
/>
|
||||
<div className="goals-form-label-row">
|
||||
<label className="goals-form-label" htmlFor="goals-form-description">
|
||||
Description
|
||||
{t("goals.labelDescription", "Description")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@@ -338,7 +339,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
data-testid="goals-form-draft-ai"
|
||||
>
|
||||
<Sparkles aria-hidden="true" />
|
||||
{isDraftingDescription ? "Drafting…" : "Draft with AI"}
|
||||
{isDraftingDescription ? t("goals.drafting", "Drafting…") : t("goals.draftWithAi", "Draft with AI")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -356,10 +357,10 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
) : null}
|
||||
<div className="goals-form-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void submitAddGoal()} disabled={isCreating || isDraftingDescription} data-testid="goals-form-submit">
|
||||
Save
|
||||
{t("actions.save", "Save")}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={closeAddForm} disabled={isCreating || isDraftingDescription} data-testid="goals-form-cancel">
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,7 +368,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
|
||||
{showWarning ? (
|
||||
<p className="goals-warning" role="status">
|
||||
Approaching the 5-active goal cap. Keep active goals focused.
|
||||
{t("goals.capWarning", "Approaching the 5-active goal cap. Keep active goals focused.")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -379,13 +380,13 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
|
||||
{loading ? (
|
||||
<p className="goals-loading" role="status" data-testid="goals-loading">
|
||||
Loading goals…
|
||||
{t("goals.loading", "Loading goals…")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && goals.length === 0 ? (
|
||||
<div className="goals-empty card" data-testid="goals-empty-state">
|
||||
No goals yet. Add one to begin tracking strategic outcomes.
|
||||
{t("goals.emptyState", "No goals yet. Add one to begin tracking strategic outcomes.")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -401,7 +402,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
{editGoalId === goal.id ? (
|
||||
<div className="goals-card-main goals-card-edit">
|
||||
<label className="goals-form-label" htmlFor={`goal-edit-title-${goal.id}`}>
|
||||
Title
|
||||
{t("goals.labelTitle", "Title")}
|
||||
</label>
|
||||
<input
|
||||
id={`goal-edit-title-${goal.id}`}
|
||||
@@ -413,7 +414,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
data-testid={`goal-edit-title-${goal.id}`}
|
||||
/>
|
||||
<label className="goals-form-label" htmlFor={`goal-edit-description-${goal.id}`}>
|
||||
Description
|
||||
{t("goals.labelDescription", "Description")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`goal-edit-description-${goal.id}`}
|
||||
@@ -436,10 +437,10 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
disabled={isSavingEdit}
|
||||
data-testid={`goal-edit-save-${goal.id}`}
|
||||
>
|
||||
Save
|
||||
{t("actions.save", "Save")}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelEdit} disabled={isSavingEdit} data-testid={`goal-edit-cancel-${goal.id}`}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,18 +466,18 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
data-testid={`goal-description-toggle-${goal.id}`}
|
||||
onClick={() => toggleGoalDescription(goal.id)}
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
{isExpanded ? t("actions.showLess", "Show less") : t("actions.showMore", "Show more")}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
<p className="goals-card-status">Status: {goal.status}</p>
|
||||
<p className="goals-card-status">{t("goals.status", "Status")}: {goal.status}</p>
|
||||
</div>
|
||||
<div className="goals-card-actions">
|
||||
<button type="button" className="btn" onClick={() => openEdit(goal)} data-testid={`goal-edit-${goal.id}`}>
|
||||
Edit
|
||||
{t("actions.edit", "Edit")}
|
||||
</button>
|
||||
{goal.status === "active" ? (
|
||||
<button
|
||||
@@ -485,7 +486,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
onClick={() => void updateGoalArchiveStatus(goal)}
|
||||
data-testid={`goal-archive-${goal.id}`}
|
||||
>
|
||||
Archive
|
||||
{t("goals.archive", "Archive")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -494,7 +495,7 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
|
||||
onClick={() => void updateGoalArchiveStatus(goal)}
|
||||
data-testid={`goal-unarchive-${goal.id}`}
|
||||
>
|
||||
Unarchive
|
||||
{t("goals.unarchive", "Unarchive")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./GroupTaskModal.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, CircleDashed, ExternalLink, Loader2, X } from "lucide-react";
|
||||
import { apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
@@ -13,6 +14,7 @@ interface GroupTaskModalProps {
|
||||
}
|
||||
|
||||
export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemberTask }: GroupTaskModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [promoting, setPromoting] = useState(false);
|
||||
@@ -26,7 +28,7 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
setGroup(response.group);
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load branch group");
|
||||
setError(loadError instanceof Error ? loadError.message : t("groupTask.errorLoading", "Failed to load branch group"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -62,8 +64,8 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
|
||||
const completionText = useMemo(() => {
|
||||
if (!group) return "";
|
||||
return `${group.completion.landed} of ${group.completion.total} members finished`;
|
||||
}, [group]);
|
||||
return t("groupTask.completionText", "{{landed}} of {{total}} members finished", { landed: group.completion.landed, total: group.completion.total });
|
||||
}, [group, t]);
|
||||
|
||||
const completionPercent = useMemo(() => {
|
||||
if (!group || group.completion.total <= 0) return 0;
|
||||
@@ -85,28 +87,28 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
<div className="modal modal-lg group-task-modal" role="dialog" aria-modal="true" aria-label="Branch group details" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal modal-lg group-task-modal" role="dialog" aria-modal="true" aria-label={t("groupTask.ariaLabel", "Branch group details")} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Branch Group {groupId}</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close group modal">
|
||||
<h2>{t("groupTask.title", "Branch Group {{id}}", { id: groupId })}</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.closeModal", "Close modal")}>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body group-task-modal-body">
|
||||
{loading && (
|
||||
<div className="card group-task-modal-state"><Loader2 className="spin" /> Loading branch group…</div>
|
||||
<div className="card group-task-modal-state"><Loader2 className="spin" /> {t("groupTask.loading", "Loading branch group…")}</div>
|
||||
)}
|
||||
{!loading && error && <div className="card group-task-modal-state group-task-modal-error">{error}</div>}
|
||||
{!loading && !error && !group && <div className="card group-task-modal-state">Branch group unavailable</div>}
|
||||
{!loading && !error && !group && <div className="card group-task-modal-state">{t("groupTask.unavailable", "Branch group unavailable")}</div>}
|
||||
{!loading && !error && group && (
|
||||
<>
|
||||
<section className="card group-task-modal-summary">
|
||||
<div className="group-task-modal-summary-row">
|
||||
<span className="group-task-modal-label">Shared branch</span>
|
||||
<span className="group-task-modal-label">{t("groupTask.sharedBranch", "Shared branch")}</span>
|
||||
<strong>{group.branchName}</strong>
|
||||
</div>
|
||||
<div className="group-task-modal-summary-row">
|
||||
<span className="group-task-modal-label">Status</span>
|
||||
<span className="group-task-modal-label">{t("groupTask.status", "Status")}</span>
|
||||
<span className="badge">{group.status}</span>
|
||||
</div>
|
||||
<div className="group-task-modal-progress-text">{completionText}</div>
|
||||
@@ -116,7 +118,7 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
</section>
|
||||
|
||||
<section className="card group-task-modal-members-card">
|
||||
<h3>Members</h3>
|
||||
<h3>{t("groupTask.members", "Members")}</h3>
|
||||
<ul className="group-task-modal-members">
|
||||
{group.members.map((member) => (
|
||||
<li key={member.taskId} className="group-task-modal-member">
|
||||
@@ -124,7 +126,7 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
<span className="group-task-modal-member-main">{member.taskId} · {member.title}</span>
|
||||
<span className="badge">{member.column}</span>
|
||||
<span className="group-task-modal-member-status">{member.landed ? <CheckCircle2 /> : <CircleDashed />}</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onOpenMemberTask(member.taskId)}>Open task</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => onOpenMemberTask(member.taskId)}>{t("groupTask.openTask", "Open task")}</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -141,11 +143,11 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
{group.completion.complete && (
|
||||
<section className="card group-task-modal-actions">
|
||||
{group.autoMerge ? (
|
||||
<span className="badge">Auto-merge enabled</span>
|
||||
<span className="badge">{t("groupTask.autoMergeEnabled", "Auto-merge enabled")}</span>
|
||||
) : (
|
||||
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
|
||||
{promoting ? <Loader2 className="spin" /> : null}
|
||||
{group.prState === "none" ? "Open PR" : "Merge group into main"}
|
||||
{group.prState === "none" ? t("groupTask.openPR", "Open PR") : t("groupTask.mergeIntoMain", "Merge group into main")}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react";
|
||||
import "./Header.css";
|
||||
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
|
||||
@@ -44,6 +45,7 @@ function ProjectSelector({
|
||||
onViewAll: () => void;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -86,14 +88,14 @@ function ProjectSelector({
|
||||
<button
|
||||
className={`project-selector-trigger${isOpen ? " project-selector-trigger--open" : ""}`}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
title={currentProject?.name ? `Switch project (current: ${currentProject.name})` : "Switch project"}
|
||||
aria-label="Switch project"
|
||||
title={currentProject?.name ? t("header.switchProjectCurrent", "Switch project (current: {{name}})", { name: currentProject.name }) : t("header.switchProject", "Switch project")}
|
||||
aria-label={t("header.switchProject", "Switch project")}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="project-selector-trigger"
|
||||
>
|
||||
<span className="project-selector-trigger-label">
|
||||
{currentProject?.name ?? "Projects"}
|
||||
{currentProject?.name ?? t("header.projects", "Projects")}
|
||||
</span>
|
||||
<ChevronDown size={12} className={`project-selector-chevron${isOpen ? " project-selector-chevron--open" : ""}`} />
|
||||
</button>
|
||||
@@ -101,7 +103,7 @@ function ProjectSelector({
|
||||
<div
|
||||
className="project-selector-dropdown"
|
||||
role="listbox"
|
||||
aria-label="Select project"
|
||||
aria-label={t("header.selectProject", "Select project")}
|
||||
data-testid="project-selector-dropdown"
|
||||
>
|
||||
{projects.map((project) => {
|
||||
@@ -138,7 +140,7 @@ function ProjectSelector({
|
||||
}}
|
||||
data-testid="manage-projects-action"
|
||||
>
|
||||
Manage Projects
|
||||
{t("header.manageProjects", "Manage Projects")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -304,6 +306,7 @@ export function Header({
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
}: HeaderProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const mode: ViewportMode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
const isTablet = mode === "tablet";
|
||||
@@ -883,8 +886,8 @@ export function Header({
|
||||
<button
|
||||
className={`mobile-project-switch-trigger${isMobileProjectSwitchOpen ? " mobile-project-switch-trigger--open" : ""}`}
|
||||
onClick={() => setIsMobileProjectSwitchOpen((prev) => !prev)}
|
||||
title="Switch project"
|
||||
aria-label="Switch project"
|
||||
title={t("header.switchProject", "Switch project")}
|
||||
aria-label={t("header.switchProject", "Switch project")}
|
||||
aria-expanded={isMobileProjectSwitchOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="mobile-project-switch-trigger"
|
||||
@@ -895,7 +898,7 @@ export function Header({
|
||||
<div
|
||||
className="mobile-project-switch-dropdown"
|
||||
role="listbox"
|
||||
aria-label="Select project"
|
||||
aria-label={t("header.selectProject", "Select project")}
|
||||
data-testid="mobile-project-switch-dropdown"
|
||||
>
|
||||
{projects.map((project) => {
|
||||
@@ -939,7 +942,7 @@ export function Header({
|
||||
data-testid="mobile-project-switch-view-all"
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
<span>View Projects</span>
|
||||
<span>{t("header.viewProjects", "View Projects")}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -976,8 +979,8 @@ export function Header({
|
||||
<button
|
||||
className={`btn-icon node-selector-trigger${isNodeSelectorOpen ? " node-selector-trigger--open" : ""}`}
|
||||
onClick={() => setIsNodeSelectorOpen((prev) => !prev)}
|
||||
title="Switch node"
|
||||
aria-label="Switch node"
|
||||
title={t("header.switchNode", "Switch node")}
|
||||
aria-label={t("header.switchNode", "Switch node")}
|
||||
aria-expanded={isNodeSelectorOpen}
|
||||
aria-haspopup="listbox"
|
||||
data-testid="node-selector-trigger"
|
||||
@@ -990,7 +993,7 @@ export function Header({
|
||||
|
||||
{/* Node selector dropdown menu */}
|
||||
{isNodeSelectorOpen && (
|
||||
<div className="node-selector-dropdown" role="listbox" aria-label="Select node">
|
||||
<div className="node-selector-dropdown" role="listbox" aria-label={t("header.selectNode", "Select node")}>
|
||||
{/* Local option */}
|
||||
<button
|
||||
className={`node-selector-option${!isRemote ? " node-selector-option--active" : ""}`}
|
||||
@@ -1003,7 +1006,7 @@ export function Header({
|
||||
data-testid="node-option-local"
|
||||
>
|
||||
<NodeHealthDot status="online" compact />
|
||||
<span className="node-selector-option-label">Local</span>
|
||||
<span className="node-selector-option-label">{t("header.localNode", "Local")}</span>
|
||||
</button>
|
||||
|
||||
{/* Remote nodes */}
|
||||
@@ -1040,8 +1043,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
title={t("header.boardView", "Board view")}
|
||||
aria-label={t("header.boardView", "Board view")}
|
||||
aria-pressed={view === "board"}
|
||||
data-testid="mobile-view-toggle-board"
|
||||
>
|
||||
@@ -1050,8 +1053,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("list")}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
title={t("header.listView", "List view")}
|
||||
aria-label={t("header.listView", "List view")}
|
||||
aria-pressed={view === "list"}
|
||||
data-testid="mobile-view-toggle-list"
|
||||
>
|
||||
@@ -1065,8 +1068,8 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon mobile-search-trigger"
|
||||
onClick={handleMobileSearchToggle}
|
||||
title="Open search"
|
||||
aria-label="Open search"
|
||||
title={t("header.openSearch", "Open search")}
|
||||
aria-label={t("header.openSearch", "Open search")}
|
||||
aria-expanded={false}
|
||||
data-testid="mobile-header-search-btn"
|
||||
>
|
||||
@@ -1079,8 +1082,8 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleNonMobileSearchToggle}
|
||||
title="Open search"
|
||||
aria-label="Open search"
|
||||
title={t("header.openSearch", "Open search")}
|
||||
aria-label={t("header.openSearch", "Open search")}
|
||||
data-testid="desktop-header-search-btn"
|
||||
>
|
||||
<Search size={16} />
|
||||
@@ -1092,7 +1095,7 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
||||
title="View usage"
|
||||
title={t("header.viewUsage", "View usage")}
|
||||
data-testid="mobile-header-usage-btn"
|
||||
>
|
||||
<Activity size={16} />
|
||||
@@ -1105,8 +1108,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
title={t("header.boardView", "Board view")}
|
||||
aria-label={t("header.boardView", "Board view")}
|
||||
aria-pressed={view === "board"}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
@@ -1114,8 +1117,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("list")}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
title={t("header.listView", "List view")}
|
||||
aria-label={t("header.listView", "List view")}
|
||||
aria-pressed={view === "list"}
|
||||
>
|
||||
<List size={16} />
|
||||
@@ -1124,8 +1127,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("agents")}
|
||||
title="Agents view"
|
||||
aria-label="Agents view"
|
||||
title={t("header.agentsView", "Agents view")}
|
||||
aria-label={t("header.agentsView", "Agents view")}
|
||||
aria-pressed={view === "agents"}
|
||||
>
|
||||
<Bot size={16} />
|
||||
@@ -1134,8 +1137,8 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "missions" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("missions")}
|
||||
title="Missions view"
|
||||
aria-label="Missions view"
|
||||
title={t("header.missionsView", "Missions view")}
|
||||
aria-label={t("header.missionsView", "Missions view")}
|
||||
aria-pressed={view === "missions"}
|
||||
>
|
||||
<Target size={16} />
|
||||
@@ -1143,21 +1146,21 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "chat" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("chat")}
|
||||
title="Chat view"
|
||||
aria-label="Chat view"
|
||||
title={t("header.chatView", "Chat view")}
|
||||
aria-label={t("header.chatView", "Chat view")}
|
||||
aria-pressed={view === "chat"}
|
||||
data-testid="header-chat-view-btn"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
{chatHasUnreadResponse && view !== "chat" && (
|
||||
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label="Unread chat response" />
|
||||
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label={t("header.unreadChatResponse", "Unread chat response")} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "documents" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("documents")}
|
||||
title="Documents view"
|
||||
aria-label="Documents view"
|
||||
title={t("header.documentsView", "Documents view")}
|
||||
aria-label={t("header.documentsView", "Documents view")}
|
||||
aria-pressed={view === "documents"}
|
||||
>
|
||||
<FileText size={16} />
|
||||
@@ -1165,17 +1168,17 @@ export function Header({
|
||||
<button
|
||||
className={`view-toggle-btn${view === "mailbox" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("mailbox")}
|
||||
title="Mailbox view"
|
||||
aria-label="Mailbox view"
|
||||
title={t("header.mailboxView", "Mailbox view")}
|
||||
aria-label={t("header.mailboxView", "Mailbox view")}
|
||||
aria-pressed={view === "mailbox"}
|
||||
>
|
||||
<Mail size={16} />
|
||||
{view !== "mailbox" && mailboxPendingApprovalCount > 0 ? (
|
||||
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label="Pending approvals" />
|
||||
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label={t("header.pendingApprovals", "Pending approvals")} />
|
||||
) : view !== "mailbox" && mailboxUnreadCount > 0 ? (
|
||||
<span
|
||||
className="status-dot status-dot--online header-chat-unread-dot"
|
||||
aria-label={`${mailboxUnreadCount} unread messages`}
|
||||
aria-label={t("header.unreadMessages", "{{count}} unread messages", { count: mailboxUnreadCount })}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
@@ -1205,8 +1208,8 @@ export function Header({
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "reliability", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
title={t("header.moreViews", "More views")}
|
||||
aria-label={t("header.moreViews", "More views")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isViewOverflowOpen}
|
||||
data-testid="view-toggle-overflow-trigger"
|
||||
@@ -1218,7 +1221,7 @@ export function Header({
|
||||
ref={viewOverflowRef}
|
||||
className="view-toggle-overflow-menu"
|
||||
role="menu"
|
||||
aria-label="More views"
|
||||
aria-label={t("header.moreViews", "More views")}
|
||||
>
|
||||
{experimentalFeatures?.evalsView && (
|
||||
<button
|
||||
@@ -1231,7 +1234,7 @@ export function Header({
|
||||
data-testid="view-overflow-evals"
|
||||
>
|
||||
<Target size={14} />
|
||||
<span>Evals</span>
|
||||
<span>{t("header.evalsView", "Evals")}</span>
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.goalsView && (
|
||||
@@ -1245,7 +1248,7 @@ export function Header({
|
||||
data-testid="view-overflow-goals"
|
||||
>
|
||||
<Target size={14} />
|
||||
<span>Goals</span>
|
||||
<span>{t("header.goalsView", "Goals")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1258,7 +1261,7 @@ export function Header({
|
||||
data-testid="view-overflow-stash-recovery"
|
||||
>
|
||||
<History size={14} />
|
||||
<span>Stash Recovery</span>
|
||||
<span>{t("header.stashRecoveryView", "Stash Recovery")}</span>
|
||||
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -1273,7 +1276,7 @@ export function Header({
|
||||
data-testid="view-overflow-research"
|
||||
>
|
||||
<Search size={14} />
|
||||
<span>Research</span>
|
||||
<span>{t("header.researchView", "Research")}</span>
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.insights && (
|
||||
@@ -1287,7 +1290,7 @@ export function Header({
|
||||
data-testid="view-overflow-insights"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Insights</span>
|
||||
<span>{t("header.insightsView", "Insights")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1302,7 +1305,7 @@ export function Header({
|
||||
data-testid="view-overflow-skills"
|
||||
>
|
||||
<Zap size={14} />
|
||||
<span>Skills</span>
|
||||
<span>{t("header.skillsView", "Skills")}</span>
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.memoryView && (
|
||||
@@ -1316,7 +1319,7 @@ export function Header({
|
||||
data-testid="view-toggle-memory"
|
||||
>
|
||||
<Brain size={14} />
|
||||
<span>Memory</span>
|
||||
<span>{t("header.memoryView", "Memory")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1329,7 +1332,7 @@ export function Header({
|
||||
data-testid="view-overflow-secrets"
|
||||
>
|
||||
<Lock size={14} />
|
||||
<span>Secrets</span>
|
||||
<span>{t("header.secretsView", "Secrets")}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "reliability" ? " active" : ""}`}
|
||||
@@ -1341,7 +1344,7 @@ export function Header({
|
||||
data-testid="view-overflow-reliability"
|
||||
>
|
||||
<Activity size={14} />
|
||||
<span>Reliability</span>
|
||||
<span>{t("header.reliabilityView", "Reliability")}</span>
|
||||
</button>
|
||||
{experimentalFeatures?.devServerView && (
|
||||
<button
|
||||
@@ -1354,7 +1357,7 @@ export function Header({
|
||||
data-testid="view-toggle-devserver"
|
||||
>
|
||||
<Monitor size={14} />
|
||||
<span>Dev Server</span>
|
||||
<span>{t("header.devServerView", "Dev Server")}</span>
|
||||
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
||||
</button>
|
||||
)}
|
||||
@@ -1369,7 +1372,7 @@ export function Header({
|
||||
data-testid="view-overflow-todos"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>Todos</span>
|
||||
<span>{t("header.todosView", "Todos")}</span>
|
||||
</button>
|
||||
)}
|
||||
{pluginDashboardViews
|
||||
@@ -1406,7 +1409,7 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
|
||||
title="View usage"
|
||||
title={t("header.viewUsage", "View usage")}
|
||||
data-testid="desktop-header-usage-btn"
|
||||
>
|
||||
<Activity size={16} />
|
||||
@@ -1415,21 +1418,21 @@ export function Header({
|
||||
|
||||
{/* System Stats button - desktop only */}
|
||||
{!isCompact && onOpenSystemStats && (
|
||||
<button className="btn-icon" onClick={onOpenSystemStats} title="System Stats" data-testid="desktop-header-system-stats-btn">
|
||||
<button className="btn-icon" onClick={onOpenSystemStats} title={t("header.systemStats", "System Stats")} data-testid="desktop-header-system-stats-btn">
|
||||
<Monitor size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenActivityLog && (
|
||||
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">
|
||||
<button className="btn-icon" onClick={onOpenActivityLog} title={t("header.viewActivityLog", "View Activity Log")}>
|
||||
<History size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop actions */}
|
||||
{!isCompact && !isDesktopShell && (
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title={t("header.importFromGitHub", "Import from GitHub")}>
|
||||
<GitHubLogo size={16} />
|
||||
</button>
|
||||
)}
|
||||
@@ -1438,7 +1441,7 @@ export function Header({
|
||||
<button
|
||||
className={`btn-icon${activePlanningSessionCount > 0 ? " btn-icon--has-indicator" : ""}`}
|
||||
onClick={activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning}
|
||||
title={activePlanningSessionCount > 0 ? "Resume planning session" : "Create a task with AI planning"}
|
||||
title={activePlanningSessionCount > 0 ? t("header.resumePlanningSession", "Resume planning session") : t("header.createTaskWithPlanning", "Create a task with AI planning")}
|
||||
data-testid="planning-btn"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
@@ -1447,7 +1450,7 @@ export function Header({
|
||||
<span
|
||||
className="header-badge header-badge--pulse"
|
||||
data-testid="planning-badge"
|
||||
aria-label={`${activePlanningSessionCount} active planning session${activePlanningSessionCount !== 1 ? "s" : ""}`}
|
||||
aria-label={t("header.activePlanningSessions", { count: activePlanningSessionCount, defaultValue_one: "{{count}} active planning session", defaultValue_other: "{{count}} active planning sessions" })}
|
||||
>
|
||||
{activePlanningSessionCount}
|
||||
</span>
|
||||
@@ -1461,7 +1464,7 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon btn-icon--terminal terminal-split-btn__main"
|
||||
onClick={onToggleTerminal}
|
||||
title="Open Terminal"
|
||||
title={t("header.openTerminal", "Open Terminal")}
|
||||
data-testid="terminal-toggle-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
@@ -1473,10 +1476,10 @@ export function Header({
|
||||
ref={scriptsChevronButtonRef}
|
||||
className={`btn-icon terminal-split-btn__chevron${isScriptsOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={() => setIsScriptsOpen((prev) => !prev)}
|
||||
title="Scripts"
|
||||
title={t("header.scripts", "Scripts")}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isScriptsOpen}
|
||||
aria-label="Quick scripts"
|
||||
aria-label={t("header.quickScripts", "Quick scripts")}
|
||||
data-testid="scripts-btn"
|
||||
>
|
||||
<ChevronDown size={12} className={`quick-scripts-dropdown__trigger-chevron${isScriptsOpen ? " rotate" : ""}`} />
|
||||
@@ -1487,7 +1490,7 @@ export function Header({
|
||||
tabIndex={-1}
|
||||
className="quick-scripts-dropdown__menu"
|
||||
role="listbox"
|
||||
aria-label="Scripts"
|
||||
aria-label={t("header.scripts", "Scripts")}
|
||||
onKeyDown={handleScriptsDropdownKeyDown}
|
||||
data-testid="quick-scripts-dropdown"
|
||||
style={
|
||||
@@ -1505,19 +1508,19 @@ export function Header({
|
||||
{scriptsLoading ? (
|
||||
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Loading scripts...</span>
|
||||
<span>{t("header.loadingScripts", "Loading scripts...")}</span>
|
||||
</div>
|
||||
) : scriptEntries.length === 0 ? (
|
||||
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
|
||||
<div className="quick-scripts-dropdown__empty-icon">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<p>No scripts configured</p>
|
||||
<p>{t("header.noScriptsConfigured", "No scripts configured")}</p>
|
||||
<button
|
||||
className="quick-scripts-dropdown__empty-action btn"
|
||||
onClick={handleManageScripts}
|
||||
>
|
||||
Add your first script
|
||||
{t("header.addFirstScript", "Add your first script")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1554,7 +1557,7 @@ export function Header({
|
||||
data-testid="quick-scripts-manage"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>Manage Scripts...</span>
|
||||
<span>{t("header.manageScripts", "Manage Scripts...")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -1571,7 +1574,7 @@ export function Header({
|
||||
<button
|
||||
className={`btn-icon${filesOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={() => onOpenFiles()}
|
||||
title="Browse files"
|
||||
title={t("header.browseFiles", "Browse files")}
|
||||
data-testid="files-toggle-btn"
|
||||
>
|
||||
<Folder size={16} />
|
||||
@@ -1583,7 +1586,7 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenGitManager}
|
||||
title="Git Manager"
|
||||
title={t("header.gitManager", "Git Manager")}
|
||||
data-testid="git-manager-btn"
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
@@ -1595,7 +1598,7 @@ export function Header({
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenWorkflowSteps}
|
||||
title="Workflow Steps"
|
||||
title={t("header.workflowSteps", "Workflow Steps")}
|
||||
data-testid="workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
@@ -1609,8 +1612,8 @@ export function Header({
|
||||
ref={desktopOverflowTriggerRef}
|
||||
className="btn-icon"
|
||||
onClick={() => setIsDesktopOverflowOpen((prev) => !prev)}
|
||||
title="More actions"
|
||||
aria-label="More actions"
|
||||
title={t("header.moreActions", "More actions")}
|
||||
aria-label={t("header.moreActions", "More actions")}
|
||||
aria-expanded={isDesktopOverflowOpen}
|
||||
aria-haspopup="menu"
|
||||
data-testid="desktop-overflow-trigger"
|
||||
@@ -1622,7 +1625,7 @@ export function Header({
|
||||
ref={desktopOverflowRef}
|
||||
className="desktop-overflow-menu"
|
||||
role="menu"
|
||||
aria-label="More actions"
|
||||
aria-label={t("header.moreActions", "More actions")}
|
||||
>
|
||||
{onOpenNodes && showNodesButton !== false && (
|
||||
<button
|
||||
@@ -1635,7 +1638,7 @@ export function Header({
|
||||
data-testid="desktop-overflow-nodes-btn"
|
||||
>
|
||||
<Server size={14} />
|
||||
<span>Nodes</span>
|
||||
<span>{t("header.nodes", "Nodes")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1648,7 +1651,7 @@ export function Header({
|
||||
data-testid="desktop-overflow-schedules-btn"
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span>Automation</span>
|
||||
<span>{t("header.automation", "Automation")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1660,7 +1663,7 @@ export function Header({
|
||||
<button
|
||||
className={`btn-icon engine-control-split-btn__main${globalPaused ? " btn-icon--stopped" : ""}`}
|
||||
onClick={onToggleGlobalPause}
|
||||
title={globalPaused ? "Start AI engine" : "Stop AI engine"}
|
||||
title={globalPaused ? t("header.startAiEngine", "Start AI engine") : t("header.stopAiEngine", "Stop AI engine")}
|
||||
data-testid="engine-control-main-btn"
|
||||
>
|
||||
{globalPaused ? <Play size={16} /> : <Square size={16} />}
|
||||
@@ -1669,7 +1672,7 @@ export function Header({
|
||||
<button
|
||||
className={`btn-icon engine-control-split-btn__chevron${isEngineMenuOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={() => setIsEngineMenuOpen((prev) => !prev)}
|
||||
title="Engine options"
|
||||
title={t("header.engineOptions", "Engine options")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isEngineMenuOpen}
|
||||
data-testid="engine-control-chevron-btn"
|
||||
@@ -1685,12 +1688,12 @@ export function Header({
|
||||
setIsEngineMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
title={enginePaused ? "Resume scheduling" : "Pause triage"}
|
||||
title={enginePaused ? t("header.resumeScheduling", "Resume scheduling") : t("header.pauseTriage", "Pause triage")}
|
||||
disabled={!!globalPaused}
|
||||
data-testid="engine-control-pause-triage-btn"
|
||||
>
|
||||
{enginePaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
<span>{enginePaused ? "Resume scheduling" : "Pause triage"}</span>
|
||||
<span>{enginePaused ? t("header.resumeScheduling", "Resume scheduling") : t("header.pauseTriage", "Pause triage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1698,7 +1701,7 @@ export function Header({
|
||||
|
||||
{/* Settings - always inline on desktop, placed after engine controls */}
|
||||
{!isCompact && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
<button className="btn-icon" onClick={onOpenSettings} title={t("header.settings", "Settings")}>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
)}
|
||||
@@ -1712,8 +1715,8 @@ export function Header({
|
||||
ref={overflowButtonRef}
|
||||
className="btn-icon compact-overflow-trigger"
|
||||
onClick={handleOverflowToggle}
|
||||
title="More header actions"
|
||||
aria-label="More header actions"
|
||||
title={t("header.moreHeaderActions", "More header actions")}
|
||||
aria-label={t("header.moreHeaderActions", "More header actions")}
|
||||
aria-expanded={isOverflowMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
@@ -1727,7 +1730,7 @@ export function Header({
|
||||
ref={overflowMenuRef}
|
||||
className="mobile-overflow-menu"
|
||||
role="menu"
|
||||
aria-label="Additional header actions"
|
||||
aria-label={t("header.additionalHeaderActions", "Additional header actions")}
|
||||
>
|
||||
{/* Projects - in overflow on mobile */}
|
||||
{isMobile && projects.length >= 1 && onViewAllProjects && (
|
||||
@@ -1738,7 +1741,7 @@ export function Header({
|
||||
data-testid="overflow-project-selector-btn"
|
||||
>
|
||||
<Grid3X3 size={16} />
|
||||
<span>Projects</span>
|
||||
<span>{t("header.projects", "Projects")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Files - in overflow on mobile */}
|
||||
@@ -1750,7 +1753,7 @@ export function Header({
|
||||
data-testid="overflow-files-btn"
|
||||
>
|
||||
<Folder size={16} />
|
||||
<span>Browse Files</span>
|
||||
<span>{t("header.browseFiles", "Browse Files")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1767,7 +1770,7 @@ export function Header({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{activePlanningSessionCount > 0 ? `Resume planning session (${activePlanningSessionCount})` : "Create a task with AI planning"}</span>
|
||||
<span>{activePlanningSessionCount > 0 ? t("header.resumePlanningSessionCount", "Resume planning session ({{count}})", { count: activePlanningSessionCount }) : t("header.createTaskWithPlanning", "Create a task with AI planning")}</span>
|
||||
</button>
|
||||
{/* Git Manager - in overflow on mobile */}
|
||||
{onOpenGitManager && (
|
||||
@@ -1778,7 +1781,7 @@ export function Header({
|
||||
data-testid="overflow-git-btn"
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
<span>Git Manager</span>
|
||||
<span>{t("header.gitManager", "Git Manager")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Nodes - in overflow on mobile */}
|
||||
@@ -1790,7 +1793,7 @@ export function Header({
|
||||
data-testid="overflow-nodes-btn"
|
||||
>
|
||||
<Server size={16} />
|
||||
<span>Nodes</span>
|
||||
<span>{t("header.nodes", "Nodes")}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isDesktopShell && (
|
||||
@@ -1800,7 +1803,7 @@ export function Header({
|
||||
role="menuitem"
|
||||
>
|
||||
<GitHubLogo size={16} />
|
||||
<span>Import from GitHub</span>
|
||||
<span>{t("header.importFromGitHub", "Import from GitHub")}</span>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
@@ -1815,7 +1818,7 @@ export function Header({
|
||||
data-testid="overflow-terminal-primary-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<span>Terminal</span>
|
||||
<span>{t("header.terminal", "Terminal")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-overflow-split-toggle"
|
||||
@@ -1823,7 +1826,7 @@ export function Header({
|
||||
role="menuitem"
|
||||
aria-expanded={isTerminalSubmenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Show scripts"
|
||||
aria-label={t("header.showScripts", "Show scripts")}
|
||||
data-testid="overflow-terminal-submenu-toggle"
|
||||
>
|
||||
<ChevronRight
|
||||
@@ -1833,11 +1836,11 @@ export function Header({
|
||||
</button>
|
||||
</div>
|
||||
{isTerminalSubmenuOpen && (
|
||||
<div className="mobile-overflow-submenu" role="menu" aria-label="Scripts submenu">
|
||||
<div className="mobile-overflow-submenu" role="menu" aria-label={t("header.scriptsSubmenu", "Scripts submenu")}>
|
||||
{overflowScriptsLoading ? (
|
||||
<div className="mobile-overflow-submenu-loading" data-testid="overflow-scripts-loading">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Loading scripts…</span>
|
||||
<span>{t("header.loadingScripts", "Loading scripts...")}</span>
|
||||
</div>
|
||||
) : overflowScriptEntries.length > 0 ? (
|
||||
<>
|
||||
@@ -1865,7 +1868,7 @@ export function Header({
|
||||
data-testid="overflow-scripts-manage"
|
||||
>
|
||||
<FileCode size={14} />
|
||||
<span>Manage Scripts…</span>
|
||||
<span>{t("header.manageScripts", "Manage Scripts...")}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -1878,7 +1881,7 @@ export function Header({
|
||||
data-testid="overflow-scripts-manage"
|
||||
>
|
||||
<FileCode size={14} />
|
||||
<span>No scripts — add one…</span>
|
||||
<span>{t("header.noScriptsAddOne", "No scripts — add one…")}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
@@ -1892,7 +1895,7 @@ export function Header({
|
||||
data-testid="overflow-schedules-btn"
|
||||
>
|
||||
<Clock size={16} />
|
||||
<span>Automation</span>
|
||||
<span>{t("header.automation", "Automation")}</span>
|
||||
</button>
|
||||
{/* Activity Log - in overflow on mobile */}
|
||||
{onOpenActivityLog && (
|
||||
@@ -1903,7 +1906,7 @@ export function Header({
|
||||
data-testid="overflow-activity-log-btn"
|
||||
>
|
||||
<History size={16} />
|
||||
<span>View Activity Log</span>
|
||||
<span>{t("header.viewActivityLog", "View Activity Log")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Mailbox - in overflow on mobile */}
|
||||
@@ -1915,7 +1918,7 @@ export function Header({
|
||||
data-testid="overflow-mailbox-btn"
|
||||
>
|
||||
<Mail size={16} />
|
||||
<span>Mailbox{mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount})` : ""}</span>
|
||||
<span>{mailboxUnreadCount > 0 ? t("header.mailboxWithCount", "Mailbox ({{count}})", { count: mailboxUnreadCount }) : t("header.mailbox", "Mailbox")}</span>
|
||||
{mailboxPendingApprovalCount > 0 && (
|
||||
<span className="header-badge" data-testid="overflow-mailbox-approval-badge">{mailboxPendingApprovalCount}</span>
|
||||
)}
|
||||
@@ -1932,7 +1935,7 @@ export function Header({
|
||||
data-testid="overflow-usage-btn"
|
||||
>
|
||||
<Activity size={16} />
|
||||
<span>View Usage</span>
|
||||
<span>{t("header.viewUsage", "View Usage")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Workflow Steps - in overflow on mobile */}
|
||||
@@ -1944,7 +1947,7 @@ export function Header({
|
||||
data-testid="overflow-workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
<span>Workflow Steps</span>
|
||||
<span>{t("header.workflowSteps", "Workflow Steps")}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Settings - always last in overflow menu */}
|
||||
@@ -1954,7 +1957,7 @@ export function Header({
|
||||
role="menuitem"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>Settings</span>
|
||||
<span>{t("header.settings", "Settings")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1969,7 +1972,7 @@ export function Header({
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
placeholder={t("header.searchTasks", "Search tasks...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="header-search-input"
|
||||
@@ -1977,7 +1980,7 @@ export function Header({
|
||||
<button
|
||||
className="header-search-clear"
|
||||
onClick={handleNonMobileSearchClose}
|
||||
aria-label="Close search"
|
||||
aria-label={t("header.closeSearch", "Close search")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -1985,15 +1988,15 @@ export function Header({
|
||||
{showBoardBranchFilters && (
|
||||
<div className="header-branch-filters" data-testid="header-branch-filters-desktop">
|
||||
<label className="header-branch-filter-label">
|
||||
<span>Working branch</span>
|
||||
<span>{t("header.workingBranch", "Working branch")}</span>
|
||||
<select
|
||||
className="header-branch-filter-select"
|
||||
value={branchFilter}
|
||||
onChange={(event) => onBranchFilterChange?.(event.target.value)}
|
||||
data-testid="working-branch-filter"
|
||||
>
|
||||
<option value="">All working branches</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>No working branch</option>
|
||||
<option value="">{t("header.allWorkingBranches", "All working branches")}</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noWorkingBranch", "No working branch")}</option>
|
||||
{branchOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
@@ -2002,15 +2005,15 @@ export function Header({
|
||||
</select>
|
||||
</label>
|
||||
<label className="header-branch-filter-label">
|
||||
<span>Base branch</span>
|
||||
<span>{t("header.baseBranch", "Base branch")}</span>
|
||||
<select
|
||||
className="header-branch-filter-select"
|
||||
value={baseBranchFilter}
|
||||
onChange={(event) => onBaseBranchFilterChange?.(event.target.value)}
|
||||
data-testid="target-branch-filter"
|
||||
>
|
||||
<option value="">All base branches</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>No base branch</option>
|
||||
<option value="">{t("header.allBaseBranches", "All base branches")}</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noBaseBranch", "No base branch")}</option>
|
||||
{baseBranchOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
@@ -2035,7 +2038,7 @@ export function Header({
|
||||
ref={mobileSearchInputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
placeholder={t("header.searchTasks", "Search tasks...")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="header-search-input"
|
||||
@@ -2043,7 +2046,7 @@ export function Header({
|
||||
<button
|
||||
className="header-search-clear"
|
||||
onClick={handleMobileSearchClose}
|
||||
aria-label="Close search"
|
||||
aria-label={t("header.closeSearch", "Close search")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -2051,15 +2054,15 @@ export function Header({
|
||||
{showBoardBranchFilters && (
|
||||
<div className="header-branch-filters" data-testid="header-branch-filters-mobile">
|
||||
<label className="header-branch-filter-label">
|
||||
<span>Working branch</span>
|
||||
<span>{t("header.workingBranch", "Working branch")}</span>
|
||||
<select
|
||||
className="header-branch-filter-select"
|
||||
value={branchFilter}
|
||||
onChange={(event) => onBranchFilterChange?.(event.target.value)}
|
||||
data-testid="working-branch-filter-mobile"
|
||||
>
|
||||
<option value="">All working branches</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>No working branch</option>
|
||||
<option value="">{t("header.allWorkingBranches", "All working branches")}</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noWorkingBranch", "No working branch")}</option>
|
||||
{branchOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
@@ -2068,15 +2071,15 @@ export function Header({
|
||||
</select>
|
||||
</label>
|
||||
<label className="header-branch-filter-label">
|
||||
<span>Base branch</span>
|
||||
<span>{t("header.baseBranch", "Base branch")}</span>
|
||||
<select
|
||||
className="header-branch-filter-select"
|
||||
value={baseBranchFilter}
|
||||
onChange={(event) => onBaseBranchFilterChange?.(event.target.value)}
|
||||
data-testid="target-branch-filter-mobile"
|
||||
>
|
||||
<option value="">All base branches</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>No base branch</option>
|
||||
<option value="">{t("header.allBaseBranches", "All base branches")}</option>
|
||||
<option value={NO_BRANCH_FILTER_VALUE}>{t("header.noBaseBranch", "No base branch")}</option>
|
||||
{baseBranchOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchHermesProfiles,
|
||||
fetchHermesStatus,
|
||||
@@ -65,6 +66,7 @@ function settingsFromRecord(raw: Record<string, unknown>): HermesSettings {
|
||||
}
|
||||
|
||||
export function HermesRuntimeCard() {
|
||||
const { t } = useTranslation("app");
|
||||
const [settings, setSettings] = useState<HermesSettings>(DEFAULT_SETTINGS);
|
||||
const [status, setStatus] = useState<HermesProviderStatus | null>(null);
|
||||
const [profiles, setProfiles] = useState<HermesProfileSummary[]>([]);
|
||||
@@ -139,33 +141,33 @@ export function HermesRuntimeCard() {
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(null);
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Test failed — see status above." });
|
||||
setToast({ kind: "err", message: t("hermes.testFailed", "Test failed — see status above.") });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `✓ hermes detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
|
||||
message: t("hermes.detected", "✓ hermes detected{{version}}{{path}}.", { version: next.binary.version ? ` (${next.binary.version})` : "", path: next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : "" }),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ ${next.binary.reason ?? "hermes not found"}`,
|
||||
message: t("hermes.notFound", "✗ {{reason}}", { reason: next.binary.reason ?? "hermes not found" }),
|
||||
});
|
||||
}
|
||||
}, [probe]);
|
||||
}, [probe, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setBusy("saving");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) setToast({ kind: "ok", message: "Settings saved." });
|
||||
if (mountedRef.current) setToast({ kind: "ok", message: t("hermes.settingsSaved", "Settings saved.") });
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload]);
|
||||
}, [buildPayload, t]);
|
||||
|
||||
const handleSaveAndTest = useCallback(async () => {
|
||||
setBusy("save-test");
|
||||
@@ -175,16 +177,16 @@ export function HermesRuntimeCard() {
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Saved, but probe failed." });
|
||||
setToast({ kind: "err", message: t("hermes.savedProbeFailed", "Saved, but probe failed.") });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `Saved · ✓ hermes detected${next.binary.version ? ` (${next.binary.version})` : ""}.`,
|
||||
message: t("hermes.savedDetected", "Saved · ✓ hermes detected{{version}}.", { version: next.binary.version ? ` (${next.binary.version})` : "" }),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ ${next.binary.reason ?? "hermes not found"}`,
|
||||
message: t("hermes.savedNotFound", "Saved · ✗ {{reason}}", { reason: next.binary.reason ?? "hermes not found" }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -193,7 +195,7 @@ export function HermesRuntimeCard() {
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload, probe]);
|
||||
}, [buildPayload, probe, t]);
|
||||
|
||||
const binary = status?.binary;
|
||||
const statusKind = status === null
|
||||
@@ -203,10 +205,10 @@ export function HermesRuntimeCard() {
|
||||
: "err";
|
||||
const statusText =
|
||||
status === null
|
||||
? "Probing local hermes binary…"
|
||||
? t("hermes.probing", "Probing local hermes binary…")
|
||||
: binary?.available
|
||||
? `✓ Detected${binary.version ? ` ${binary.version}` : ""}${binary.binaryPath ? ` · ${binary.binaryPath}` : ""}`
|
||||
: `✗ ${binary?.reason ?? "not detected on PATH"}`;
|
||||
? t("hermes.statusDetected", "✓ Detected{{version}}{{path}}", { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" })
|
||||
: t("hermes.statusNotDetected", "✗ {{reason}}", { reason: binary?.reason ?? "not detected on PATH" });
|
||||
|
||||
return (
|
||||
<RuntimeCardShell
|
||||
@@ -242,11 +244,7 @@ export function HermesRuntimeCard() {
|
||||
statusText={statusText}
|
||||
description={
|
||||
<>
|
||||
Drives the local <code>hermes</code> CLI as a subprocess. Each Fusion
|
||||
prompt is sent as <code>hermes chat -q …</code>; subsequent prompts
|
||||
resume the same hermes session via <code>--resume</code>. Provider,
|
||||
model, and skills are configured inside hermes itself; this card
|
||||
only chooses overrides.
|
||||
{t("hermes.description", "Drives the local {{cmd}} CLI as a subprocess. Each Fusion prompt is sent as {{chatCmd}}; subsequent prompts resume the same hermes session via {{resumeFlag}}. Provider, model, and skills are configured inside hermes itself; this card only chooses overrides.", { cmd: "hermes", chatCmd: "hermes chat -q …", resumeFlag: "--resume" })}
|
||||
</>
|
||||
}
|
||||
busy={busy}
|
||||
@@ -258,14 +256,14 @@ export function HermesRuntimeCard() {
|
||||
binary?.available === false ? (
|
||||
<div className="onboarding-helper-text">
|
||||
<p>
|
||||
<code>hermes</code> not detected. Install the upstream agent:
|
||||
{t("hermes.notDetected", "{{cmd}} not detected. Install the upstream agent:", { cmd: "hermes" })}
|
||||
</p>
|
||||
<pre>
|
||||
<code>pipx install hermes-agent</code>
|
||||
</pre>
|
||||
<p>
|
||||
<a href={HERMES_LEARN_MORE} target="_blank" rel="noreferrer">
|
||||
Hermes on GitHub
|
||||
{t("hermes.gitHubLink", "Hermes on GitHub")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -273,61 +271,59 @@ export function HermesRuntimeCard() {
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-profile">Profile (optional)</label>
|
||||
<label htmlFor="hermes-profile">{t("hermes.profileLabel", "Profile (optional)")}</label>
|
||||
<select
|
||||
id="hermes-profile"
|
||||
value={settings.profile}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, profile: e.target.value }))}
|
||||
>
|
||||
<option value="">Auto / use Hermes default</option>
|
||||
<option value="">{t("hermes.autoProfile", "Auto / use Hermes default")}</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.model ? ` — ${p.model}` : ""}
|
||||
{p.isDefault ? " (default)" : ""}
|
||||
{p.isDefault ? t("hermes.defaultProfile", " (default)") : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
Select a Hermes profile to use. Activates the profile by setting{" "}
|
||||
<code>HERMES_HOME</code> to the profile directory when invoking{" "}
|
||||
<code>hermes chat</code>.
|
||||
{t("hermes.profileHelp", "Select a Hermes profile to use. Activates the profile by setting {{env}} to the profile directory when invoking {{cmd}}.", { env: "HERMES_HOME", cmd: "hermes chat" })}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-binary">Binary path</label>
|
||||
<label htmlFor="hermes-binary">{t("hermes.binaryPathLabel", "Binary path")}</label>
|
||||
<input
|
||||
id="hermes-binary"
|
||||
type="text"
|
||||
placeholder="hermes (defaults to PATH)"
|
||||
placeholder={t("hermes.binaryPathPlaceholder", "hermes (defaults to PATH)")}
|
||||
value={settings.binaryPath}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, binaryPath: e.target.value }))}
|
||||
/>
|
||||
<small>
|
||||
Leave blank to resolve <code>hermes</code> from your PATH.
|
||||
{t("hermes.binaryPathHelp", "Leave blank to resolve {{cmd}} from your PATH.", { cmd: "hermes" })}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-model">Model override</label>
|
||||
<label htmlFor="hermes-model">{t("hermes.modelLabel", "Model override")}</label>
|
||||
<input
|
||||
id="hermes-model"
|
||||
type="text"
|
||||
placeholder="e.g. claude-sonnet-4-5, MiniMax-M3"
|
||||
placeholder={t("hermes.modelPlaceholder", "e.g. claude-sonnet-4-5, MiniMax-M3")}
|
||||
value={settings.model}
|
||||
disabled={!!settings.profile}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
|
||||
/>
|
||||
{settings.profile ? (
|
||||
<small>Controlled by profile: <strong>{settings.profile}</strong></small>
|
||||
<small>{t("hermes.modelControlled", "Controlled by profile: {{profile}}", { profile: settings.profile })}</small>
|
||||
) : (
|
||||
<small>Optional — overrides Hermes's configured default model.</small>
|
||||
<small>{t("hermes.modelHelp", "Optional — overrides Hermes's configured default model.")}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-provider">Provider</label>
|
||||
<label htmlFor="hermes-provider">{t("hermes.providerLabel", "Provider")}</label>
|
||||
<select
|
||||
id="hermes-provider"
|
||||
value={settings.provider}
|
||||
@@ -341,14 +337,14 @@ export function HermesRuntimeCard() {
|
||||
))}
|
||||
</select>
|
||||
{settings.profile ? (
|
||||
<small>Controlled by profile: <strong>{settings.profile}</strong></small>
|
||||
<small>{t("hermes.providerControlled", "Controlled by profile: {{profile}}", { profile: settings.profile })}</small>
|
||||
) : (
|
||||
<small>Inference provider Hermes routes calls through (default: <code>auto</code>)).</small>
|
||||
<small>{t("hermes.providerHelp", "Inference provider Hermes routes calls through (default: {{default}}).", { default: "auto" })}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-maxTurns">Max turns</label>
|
||||
<label htmlFor="hermes-maxTurns">{t("hermes.maxTurnsLabel", "Max turns")}</label>
|
||||
<input
|
||||
id="hermes-maxTurns"
|
||||
type="number"
|
||||
@@ -362,7 +358,7 @@ export function HermesRuntimeCard() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>Cap per Hermes turn. Hermes's own default is 90; we cap lower.</small>
|
||||
<small>{t("hermes.maxTurnsHelp", "Cap per Hermes turn. Hermes's own default is 90; we cap lower.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -376,13 +372,13 @@ export function HermesRuntimeCard() {
|
||||
checked={settings.yolo}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, yolo: e.target.checked }))}
|
||||
/>
|
||||
Auto-approve dangerous tool calls (<code>--yolo</code>)
|
||||
{t("hermes.yoloLabel", "Auto-approve dangerous tool calls ({{flag}})", { flag: "--yolo" })}
|
||||
</label>
|
||||
<small>Required for non-interactive sessions that trigger shell-style tools.</small>
|
||||
<small>{t("hermes.yoloHelp", "Required for non-interactive sessions that trigger shell-style tools.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hermes-timeoutMs">CLI hard-kill timeout (ms)</label>
|
||||
<label htmlFor="hermes-timeoutMs">{t("hermes.timeoutLabel", "CLI hard-kill timeout (ms)")}</label>
|
||||
<input
|
||||
id="hermes-timeoutMs"
|
||||
type="number"
|
||||
@@ -396,7 +392,7 @@ export function HermesRuntimeCard() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>Fusion-side hard cap. Default 5 min.</small>
|
||||
<small>{t("hermes.timeoutHelp", "Fusion-side hard cap. Default 5 min.")}</small>
|
||||
</div>
|
||||
</RuntimeCardShell>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./InlineCreateCard.css";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -44,11 +45,11 @@ interface InlineCreateCardProps {
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
}
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "Online";
|
||||
if (status === "connecting") return "Connecting";
|
||||
if (status === "error") return "Error";
|
||||
return "Offline";
|
||||
function getNodeStatusLabel(status: NodeInfo["status"], t?: (key: string, defaultValue: string) => string): string {
|
||||
if (status === "online") return t ? t("inline.online", "Online") : "Online";
|
||||
if (status === "connecting") return t ? t("inline.connecting", "Connecting") : "Connecting";
|
||||
if (status === "error") return t ? t("inline.error", "Error") : "Error";
|
||||
return t ? t("inline.offline", "Offline") : "Offline";
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
@@ -81,6 +82,7 @@ export function InlineCreateCard({
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
}: InlineCreateCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return getScopedItem(STORAGE_KEY, projectId) || "";
|
||||
@@ -624,7 +626,7 @@ export function InlineCreateCard({
|
||||
const handlePlanClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
addToast("Enter a description first", "error");
|
||||
addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onPlanningMode?.(trimmed);
|
||||
@@ -649,7 +651,7 @@ export function InlineCreateCard({
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
addToast("Enter a description first", "error");
|
||||
addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
@@ -704,13 +706,13 @@ export function InlineCreateCard({
|
||||
>
|
||||
{isDescriptionExpanded && (
|
||||
<div className="description-fullscreen-header">
|
||||
<span>Editing Description</span>
|
||||
<span>{t("inline.editingDescription", "Editing Description")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm description-expand-btn"
|
||||
onClick={handleToggleDescriptionExpand}
|
||||
aria-label="Collapse description"
|
||||
title="Collapse description"
|
||||
aria-label={t("inline.collapseDescription", "Collapse description")}
|
||||
title={t("inline.collapseDescription", "Collapse description")}
|
||||
data-testid="inline-create-collapse"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
@@ -724,7 +726,7 @@ export function InlineCreateCard({
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
className="inline-create-input"
|
||||
placeholder="What needs to be done?"
|
||||
placeholder={t("inline.whatNeedsToBeDone", "What needs to be done?")}
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
@@ -745,8 +747,8 @@ export function InlineCreateCard({
|
||||
className="btn btn-sm inline-create-expand-btn"
|
||||
onClick={handleToggleDescriptionExpand}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
aria-label="Expand description"
|
||||
title="Expand description"
|
||||
aria-label={t("inline.expandDescription", "Expand description")}
|
||||
title={t("inline.expandDescription", "Expand description")}
|
||||
data-testid="inline-create-expand"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
@@ -759,9 +761,9 @@ export function InlineCreateCard({
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={isExpanded ? "inline-create-controls" : undefined}
|
||||
aria-label={isExpanded ? "Collapse advanced task options" : "Expand advanced task options"}
|
||||
aria-label={isExpanded ? t("inline.collapseTaskOptions", "Collapse advanced task options") : t("inline.expandTaskOptions", "Expand advanced task options")}
|
||||
data-testid="inline-create-toggle"
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
title={isExpanded ? t("inline.collapse", "Collapse") : t("inline.expand", "Expand")}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
@@ -799,7 +801,7 @@ export function InlineCreateCard({
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(i)}
|
||||
disabled={submitting}
|
||||
title="Remove image"
|
||||
title={t("inline.removeImage", "Remove image")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -817,10 +819,10 @@ export function InlineCreateCard({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim()}
|
||||
data-testid="plan-button"
|
||||
title="Open planning mode with current description"
|
||||
title={t("inline.openPlanningMode", "Open planning mode with current description")}
|
||||
>
|
||||
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
||||
Plan
|
||||
{t("inline.plan", "Plan")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -829,10 +831,10 @@ export function InlineCreateCard({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim()}
|
||||
data-testid="subtask-button"
|
||||
title="Break down into AI-generated subtasks"
|
||||
title={t("inline.breakDownSubtasks", "Break down into AI-generated subtasks")}
|
||||
>
|
||||
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
||||
Subtask
|
||||
{t("inline.subtask", "Subtask")}
|
||||
</button>
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
@@ -841,7 +843,7 @@ export function InlineCreateCard({
|
||||
onClick={toggleDepsDropdown}
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
{dependencies.length > 0 ? ` ${dependencies.length} ${t("inline.deps", "deps")}` : ` ${t("inline.deps", "Deps")}`}
|
||||
</button>
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
@@ -863,14 +865,14 @@ export function InlineCreateCard({
|
||||
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
placeholder={t("inline.searchTasks", "Search tasks…")}
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
<div className="dep-dropdown-empty">{t("inline.noExistingTasks", "No existing tasks")}</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
@@ -908,12 +910,12 @@ export function InlineCreateCard({
|
||||
}}
|
||||
>
|
||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedNode ? ` ${selectedNode.name}` : " Node"}
|
||||
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
|
||||
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
|
||||
</button>
|
||||
{showNodePicker && (
|
||||
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">Select execution node</div>
|
||||
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
|
||||
@@ -922,7 +924,7 @@ export function InlineCreateCard({
|
||||
setShowNodePicker(false);
|
||||
}}
|
||||
>
|
||||
<span className="dep-dropdown-title">Project default / local</span>
|
||||
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
|
||||
</button>
|
||||
{nodes.map((node) => (
|
||||
<button
|
||||
@@ -936,7 +938,7 @@ export function InlineCreateCard({
|
||||
>
|
||||
<NodeHealthDot status={node.status} />
|
||||
<span className="dep-dropdown-title">{node.name}</span>
|
||||
<span className="node-picker-status-label">{getNodeStatusLabel(node.status)}</span>
|
||||
<span className="node-picker-status-label">{getNodeStatusLabel(node.status, t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -957,12 +959,12 @@ export function InlineCreateCard({
|
||||
data-testid="inline-create-agent-button"
|
||||
>
|
||||
<Bot size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Agent"}
|
||||
{selectedAgentLabel ? ` ${selectedAgentLabel}` : ` ${t("inline.agent", "Agent")}`}
|
||||
</button>
|
||||
{showAgentPicker && (
|
||||
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">Select agent</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
|
||||
<div className="dep-dropdown-search-header">{t("inline.selectAgent", "Select agent")}</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">{t("inline.loadingAgents", "Loading agents...")}</div>}
|
||||
{!agentsLoading && agents.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
@@ -979,7 +981,7 @@ export function InlineCreateCard({
|
||||
</div>
|
||||
))}
|
||||
{!agentsLoading && agents.length === 0 && (
|
||||
<div className="dep-dropdown-empty">No agents available</div>
|
||||
<div className="dep-dropdown-empty">{t("inline.noAgentsAvailable", "No agents available")}</div>
|
||||
)}
|
||||
{selectedAgentId && (
|
||||
<div
|
||||
@@ -990,7 +992,7 @@ export function InlineCreateCard({
|
||||
setShowAgentPicker(false);
|
||||
}}
|
||||
>
|
||||
<span className="dep-dropdown-title">Clear selection</span>
|
||||
<span className="dep-dropdown-title">{t("inline.clearSelection", "Clear selection")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1003,13 +1005,13 @@ export function InlineCreateCard({
|
||||
data-testid="inline-create-browser-verification-toggle"
|
||||
aria-pressed={browserVerification}
|
||||
onClick={() => setBrowserVerification((prev) => !prev)}
|
||||
title="Enable browser verification workflow step"
|
||||
title={t("inline.enableBrowserVerification", "Enable browser verification workflow step")}
|
||||
>
|
||||
{browserVerification ? "Browser Verify ✓" : "Browser Verify"}
|
||||
{browserVerification ? t("inline.browserVerifyChecked", "Browser Verify ✓") : t("inline.browserVerify", "Browser Verify")}
|
||||
</button>
|
||||
|
||||
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
|
||||
<span className="visually-hidden">Priority</span>
|
||||
<span className="visually-hidden">{t("inline.priority", "Priority")}</span>
|
||||
<select
|
||||
id="inline-create-priority-select"
|
||||
className="select inline-create-priority-select"
|
||||
@@ -1019,7 +1021,7 @@ export function InlineCreateCard({
|
||||
>
|
||||
{TASK_PRIORITIES.map((taskPriority) => (
|
||||
<option key={taskPriority} value={taskPriority}>
|
||||
{`Priority: ${taskPriority[0].toUpperCase()}${taskPriority.slice(1)}`}
|
||||
{t("inline.priorityLabel", "Priority: {{level}}", { level: `${taskPriority[0].toUpperCase()}${taskPriority.slice(1)}` })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1045,7 +1047,7 @@ export function InlineCreateCard({
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Zap size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset ? ` ${selectedPreset.name}` : " Preset"}
|
||||
{selectedPreset ? ` ${selectedPreset.name}` : ` ${t("inline.preset", "Preset")}`}
|
||||
</button>
|
||||
{showPresets && (
|
||||
<div className="inline-create-model-dropdown" onMouseDown={handleModelDropdownMouseDown}>
|
||||
@@ -1061,7 +1063,7 @@ export function InlineCreateCard({
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
Use default
|
||||
{t("inline.useDefault", "Use default")}
|
||||
</button>
|
||||
{availablePresets.map((preset) => (
|
||||
<button
|
||||
@@ -1088,7 +1090,7 @@ export function InlineCreateCard({
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowPresets(false)}
|
||||
>
|
||||
Custom
|
||||
{t("inline.custom", "Custom")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1101,17 +1103,17 @@ export function InlineCreateCard({
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset
|
||||
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
? ` ${selectedPreset.name} · ${selectedModelCount} ${t("inline.model", "model", { count: selectedModelCount })}`
|
||||
: selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
? ` ${selectedModelCount} ${t("inline.model", "model", { count: selectedModelCount })}`
|
||||
: ` ${t("inline.models", "Models")}`}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="inline-create-actions">
|
||||
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
|
||||
<span className="inline-create-hint">{t("inline.hintEnterEsc", "Enter to create · Esc to cancel")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-task-create btn-sm"
|
||||
@@ -1119,7 +1121,7 @@ export function InlineCreateCard({
|
||||
disabled={!description.trim() || submitting}
|
||||
data-testid="save-button"
|
||||
>
|
||||
{submitting ? "Creating..." : "Save"}
|
||||
{submitting ? t("inline.creating", "Creating...") : t("inline.save", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import "./InsightsView.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
@@ -59,6 +60,7 @@ const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: numbe
|
||||
};
|
||||
|
||||
export function InsightsView({ projectId, addToast, onClose, onCreateTask, models: modelsProp }: InsightsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const {
|
||||
sections,
|
||||
loading,
|
||||
@@ -214,7 +216,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
try {
|
||||
setStatusMessage("Generating insights...");
|
||||
setStatusMessage(t("insights.generatingInsights", "Generating insights..."));
|
||||
setStatusType("info");
|
||||
|
||||
let modelProvider: string | undefined;
|
||||
@@ -232,93 +234,93 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
}
|
||||
|
||||
await runInsights(modelProvider, modelId);
|
||||
setStatusMessage("Insight generation started");
|
||||
setStatusMessage(t("insights.generationStarted", "Insight generation started"));
|
||||
setStatusType("success");
|
||||
addToast("Insight generation started", "success");
|
||||
addToast(t("insights.generationStarted", "Insight generation started"), "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to start generation";
|
||||
const message = err instanceof Error ? err.message : t("insights.failedToStart", "Failed to start generation");
|
||||
if (message === "Insight generation is already running") {
|
||||
setStatusMessage("Insight generation is already running. Showing the active run.");
|
||||
setStatusMessage(t("insights.alreadyRunning", "Insight generation is already running. Showing the active run."));
|
||||
setStatusType("info");
|
||||
addToast("Insight generation is already running", "info");
|
||||
addToast(t("insights.alreadyRunningShort", "Insight generation is already running"), "info");
|
||||
return;
|
||||
}
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [runInsights, addToast, selectedModel]);
|
||||
}, [runInsights, addToast, selectedModel, t]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
setStatusMessage(`Dismissing "${title}"...`);
|
||||
setStatusMessage(t("insights.dismissing", "Dismissing \"{{title}}\"...", { title }));
|
||||
setStatusType("info");
|
||||
await dismiss(id);
|
||||
setStatusMessage(`Dismissed "${title}"`);
|
||||
setStatusMessage(t("insights.dismissed", "Dismissed \"{{title}}\"", { title }));
|
||||
setStatusType("success");
|
||||
addToast(`Insight dismissed: ${title}`, "success");
|
||||
addToast(t("insights.dismissedMsg", "Insight dismissed: {{title}}", { title }), "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to dismiss insight";
|
||||
const message = err instanceof Error ? err.message : t("insights.failedToDismiss", "Failed to dismiss insight");
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
},
|
||||
[dismiss, addToast],
|
||||
[dismiss, addToast, t],
|
||||
);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
setStatusMessage(`Archiving "${title}"...`);
|
||||
setStatusMessage(t("insights.archiving", "Archiving \"{{title}}\"...", { title }));
|
||||
setStatusType("info");
|
||||
await archive(id);
|
||||
setStatusMessage(`Archived "${title}"`);
|
||||
setStatusMessage(t("insights.archived", "Archived \"{{title}}\"", { title }));
|
||||
setStatusType("success");
|
||||
addToast(`Insight archived: ${title}`, "success");
|
||||
addToast(t("insights.archivedMsg", "Insight archived: {{title}}", { title }), "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive insight";
|
||||
const message = err instanceof Error ? err.message : t("insights.failedToArchive", "Failed to archive insight");
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
},
|
||||
[archive, addToast],
|
||||
[archive, addToast, t],
|
||||
);
|
||||
|
||||
const handleUnarchive = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
setStatusMessage(`Unarchiving "${title}"...`);
|
||||
setStatusMessage(t("insights.unarchiving", "Unarchiving \"{{title}}\"...", { title }));
|
||||
setStatusType("info");
|
||||
await unarchive(id);
|
||||
setStatusMessage(`Unarchived "${title}"`);
|
||||
setStatusMessage(t("insights.unarchived", "Unarchived \"{{title}}\"", { title }));
|
||||
setStatusType("success");
|
||||
addToast(`Insight unarchived: ${title}`, "success");
|
||||
addToast(t("insights.unarchivedMsg", "Insight unarchived: {{title}}", { title }), "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to unarchive insight";
|
||||
const message = err instanceof Error ? err.message : t("insights.failedToUnarchive", "Failed to unarchive insight");
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
},
|
||||
[unarchive, addToast],
|
||||
[unarchive, addToast, t],
|
||||
);
|
||||
|
||||
const handleCreateTask = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
setStatusMessage(`Creating task from "${title}"...`);
|
||||
setStatusMessage(t("insights.creatingTask", "Creating task from \"{{title}}\"...", { title }));
|
||||
setStatusType("info");
|
||||
|
||||
if (!onCreateTask) {
|
||||
throw new Error("Task creation is unavailable in this view");
|
||||
throw new Error(t("insights.taskCreationUnavailable", "Task creation is unavailable in this view"));
|
||||
}
|
||||
|
||||
const taskData = await createTaskFromInsight(id);
|
||||
if (!taskData) {
|
||||
throw new Error("Failed to prepare task payload from insight");
|
||||
throw new Error(t("insights.failedToPreparePayload", "Failed to prepare task payload from insight"));
|
||||
}
|
||||
|
||||
await onCreateTask({
|
||||
@@ -327,17 +329,17 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
description: taskData.description,
|
||||
});
|
||||
|
||||
setStatusMessage(`Task created from "${title}"`);
|
||||
setStatusMessage(t("insights.taskCreated", "Task created from \"{{title}}\"", { title }));
|
||||
setStatusType("success");
|
||||
addToast(`Task created: ${taskData.title}`, "success");
|
||||
addToast(t("insights.taskCreatedMsg", "Task created: {{title}}", { title: taskData.title }), "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create task";
|
||||
const message = err instanceof Error ? err.message : t("insights.failedToCreateTask", "Failed to create task");
|
||||
setStatusMessage(message);
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
},
|
||||
[createTaskFromInsight, onCreateTask, addToast],
|
||||
[createTaskFromInsight, onCreateTask, addToast, t],
|
||||
);
|
||||
|
||||
const renderCategoryItem = (section: InsightSection) => {
|
||||
@@ -407,8 +409,8 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleUnarchive(insight.id, insight.title)}
|
||||
disabled={isUnarchiveInFlight || isAnyActionInFlight}
|
||||
title="Unarchive this insight"
|
||||
aria-label="Unarchive this insight"
|
||||
title={t("insights.unarchiveTitle", "Unarchive this insight")}
|
||||
aria-label={t("insights.unarchiveLabel", "Unarchive this insight")}
|
||||
data-testid={`unarchive-${insight.id}`}
|
||||
>
|
||||
{isUnarchiveInFlight ? <RefreshCw size={20} className="spin" /> : <ArchiveRestore size={20} />}
|
||||
@@ -419,8 +421,8 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleCreateTask(insight.id, insight.title)}
|
||||
disabled={isCreateInFlight || isAnyActionInFlight}
|
||||
title="Create task from this insight"
|
||||
aria-label="Create task from this insight"
|
||||
title={t("insights.createTaskTitle", "Create task from this insight")}
|
||||
aria-label={t("insights.createTaskLabel", "Create task from this insight")}
|
||||
data-testid={`create-task-${insight.id}`}
|
||||
>
|
||||
{isCreateInFlight ? <RefreshCw size={20} className="spin" /> : <Plus size={20} />}
|
||||
@@ -429,8 +431,8 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleArchive(insight.id, insight.title)}
|
||||
disabled={isArchiveInFlight || isAnyActionInFlight}
|
||||
title="Archive this insight"
|
||||
aria-label="Archive this insight"
|
||||
title={t("insights.archiveTitle", "Archive this insight")}
|
||||
aria-label={t("insights.archiveLabel", "Archive this insight")}
|
||||
data-testid={`archive-${insight.id}`}
|
||||
>
|
||||
{isArchiveInFlight ? <RefreshCw size={20} className="spin" /> : <Archive size={20} />}
|
||||
@@ -441,8 +443,8 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
className="insight-item-action-btn"
|
||||
onClick={() => void handleDismiss(insight.id, insight.title)}
|
||||
disabled={isDismissInFlight || isAnyActionInFlight}
|
||||
title="Dismiss this insight"
|
||||
aria-label="Dismiss this insight"
|
||||
title={t("insights.dismissTitle", "Dismiss this insight")}
|
||||
aria-label={t("insights.dismissLabel", "Dismiss this insight")}
|
||||
data-testid={`dismiss-${insight.id}`}
|
||||
>
|
||||
{isDismissInFlight ? (
|
||||
@@ -482,9 +484,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
<div className="insights-view-title">
|
||||
<h2>
|
||||
<Sparkles size={20} />
|
||||
Insights
|
||||
{t("insights.title", "Insights")}
|
||||
</h2>
|
||||
<span className="insights-view-count">{totalCount} total</span>
|
||||
<span className="insights-view-count">{totalCount} {t("common.total", "total")}</span>
|
||||
</div>
|
||||
|
||||
<div className="insights-view-actions">
|
||||
@@ -493,20 +495,20 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
className={`btn btn-sm insights-backlog-health-toggle${backlogHealthOnly ? " btn-icon--active" : ""}`}
|
||||
onClick={() => setBacklogHealthOnly((prev) => !prev)}
|
||||
aria-pressed={backlogHealthOnly}
|
||||
aria-label={backlogHealthOnly ? "Show all insights" : "Show only backlog health insights"}
|
||||
aria-label={backlogHealthOnly ? t("insights.showAllInsights", "Show all insights") : t("insights.showBacklogHealth", "Show only backlog health insights")}
|
||||
data-testid="toggle-backlog-health"
|
||||
title={BACKLOG_HEALTH_TITLE_PREFIXES.join(", ")}
|
||||
>
|
||||
<Activity size={14} />
|
||||
{backlogHealthOnly ? "All Insights" : "Backlog Health"} <span>({backlogHealthCount})</span>
|
||||
{backlogHealthOnly ? t("insights.allInsights", "All Insights") : t("insights.backlogHealth", "Backlog Health")} <span>({backlogHealthCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
className="btn btn-sm insights-view-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close insights view"
|
||||
title="Close"
|
||||
aria-label={t("actions.closeInsightsView", "Close insights view")}
|
||||
title={t("actions.close", "Close")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -515,30 +517,30 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
<button
|
||||
className="btn btn-sm insights-show-archived-toggle"
|
||||
onClick={toggleShowArchived}
|
||||
aria-label={showArchived ? "Hide archived insights" : "Show archived insights"}
|
||||
aria-label={showArchived ? t("insights.hideArchived", "Hide archived insights") : t("insights.showArchived", "Show archived insights")}
|
||||
data-testid="toggle-archived-insights"
|
||||
>
|
||||
<Archive size={14} />
|
||||
{showArchived ? "Hide Archived" : `Show Archived (${archivedCount})`}
|
||||
{showArchived ? t("insights.hideArchivedLabel", "Hide Archived") : t("insights.showArchivedLabel", "Show Archived ({{count}})", { count: archivedCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
aria-label="Refresh insights"
|
||||
aria-label={t("actions.refreshInsights", "Refresh insights")}
|
||||
data-testid="refresh-insights"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||||
Refresh
|
||||
{t("actions.refresh", "Refresh")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm insights-model-toggle"
|
||||
onClick={() => setShowModelConfig((prev) => !prev)}
|
||||
aria-label="Configure insight generation model"
|
||||
aria-label={t("insights.configureModel", "Configure insight generation model")}
|
||||
aria-expanded={showModelConfig}
|
||||
data-testid="toggle-model-config"
|
||||
title={selectedModel ? `Model: ${selectedModel}` : "Configure model"}
|
||||
title={selectedModel ? t("insights.modelConfigured", "Model: {{model}}", { model: selectedModel }) : t("insights.configureModelTitle", "Configure model")}
|
||||
>
|
||||
<Settings size={14} />
|
||||
{selectedModel && <span className="insights-model-indicator" />}
|
||||
@@ -547,18 +549,18 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleRun()}
|
||||
disabled={isRunInFlight}
|
||||
aria-label="Generate new insights"
|
||||
aria-label={t("insights.generateInsights", "Generate new insights")}
|
||||
data-testid="run-insights"
|
||||
>
|
||||
{isRunInFlight ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="spin" />
|
||||
Generating...
|
||||
{t("insights.generating", "Generating...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} />
|
||||
Generate Insights
|
||||
{t("insights.generateInsightsBtn", "Generate Insights")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -568,14 +570,14 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
{showModelConfig && (
|
||||
<div className="insights-model-config" data-testid="model-config">
|
||||
<label htmlFor="insight-model-select" className="insights-model-label">
|
||||
Model
|
||||
{t("insights.model", "Model")}
|
||||
</label>
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Use planning default"
|
||||
label="Insight generation model"
|
||||
placeholder={t("insights.usePlanningDefault", "Use planning default")}
|
||||
label={t("insights.generationModel", "Insight generation model")}
|
||||
disabled={isRunInFlight}
|
||||
id="insight-model-select"
|
||||
favoriteProviders={effectiveFavoriteProviders}
|
||||
@@ -628,24 +630,24 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
{loading ? (
|
||||
<div className="insights-loading" data-testid="insights-loading">
|
||||
<RefreshCw size={24} className="spin" />
|
||||
<p>Loading insights...</p>
|
||||
<p>{t("insights.loading", "Loading insights...")}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="insights-error" data-testid="insights-error">
|
||||
<AlertCircle size={24} />
|
||||
<p>{error}</p>
|
||||
<button className="btn btn-sm" onClick={() => void refresh()}>
|
||||
Retry
|
||||
{t("actions.retry", "Retry")}
|
||||
</button>
|
||||
</div>
|
||||
) : totalCount === 0 ? (
|
||||
<div className="insights-empty" data-testid="insights-empty">
|
||||
<Sparkles size={48} />
|
||||
<h3>No insights yet</h3>
|
||||
<p>Generate insights to get AI-powered recommendations for your project.</p>
|
||||
<h3>{t("insights.noInsightsYet", "No insights yet")}</h3>
|
||||
<p>{t("insights.generateDescription", "Generate insights to get AI-powered recommendations for your project.")}</p>
|
||||
<button className="btn btn-primary" onClick={() => void handleRun()}>
|
||||
<Sparkles size={14} />
|
||||
Generate First Insights
|
||||
{t("insights.generateFirst", "Generate First Insights")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import "./ListView.css";
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
||||
import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { TaskDetailContent } from "./TaskDetailModal";
|
||||
@@ -275,6 +277,8 @@ export function ListView({
|
||||
lastFetchTimeMs,
|
||||
prAuthAvailable,
|
||||
}: ListViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
@@ -480,12 +484,12 @@ export function ListView({
|
||||
|
||||
// Column display labels
|
||||
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
|
||||
title: "Title",
|
||||
status: "Status",
|
||||
column: "Column",
|
||||
dependencies: "Dependencies",
|
||||
progress: "Progress",
|
||||
retries: "Retries",
|
||||
title: t("listView.colTitle", "Title"),
|
||||
status: t("listView.colStatus", "Status"),
|
||||
column: t("listView.colColumn", "Column"),
|
||||
dependencies: t("listView.colDependencies", "Dependencies"),
|
||||
progress: t("listView.colProgress", "Progress"),
|
||||
retries: t("listView.colRetries", "Retries"),
|
||||
};
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
@@ -700,7 +704,7 @@ export function ListView({
|
||||
const deletableTasks = selectedTasks.filter((task) => task.column !== "archived");
|
||||
|
||||
if (deletableTasks.length === 0) {
|
||||
addToast("No selected tasks can be deleted (archived tasks are excluded)", "error");
|
||||
addToast(t("listView.bulkDeleteNoTasks", "No selected tasks can be deleted (archived tasks are excluded)"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -712,11 +716,11 @@ export function ListView({
|
||||
|
||||
if (doneTasks.length > 0 && onArchiveTask) {
|
||||
const choice = await confirmWithChoice({
|
||||
title: "Delete Selected Tasks",
|
||||
message: `Delete ${deletableTasks.length} task${deletableTasks.length === 1 ? "" : "s"}, or archive the ${doneTasks.length} done task${doneTasks.length === 1 ? "" : "s"} and delete the rest?`,
|
||||
confirmLabel: "Delete All",
|
||||
cancelLabel: "Cancel",
|
||||
tertiaryLabel: `Archive ${doneTasks.length} Done`,
|
||||
title: t("listView.bulkDeleteTitle", "Delete Selected Tasks"),
|
||||
message: t("listView.bulkDeleteWithDoneMessage", "Delete {{deletable}} task(s), or archive the {{done}} done task(s) and delete the rest?", { deletable: deletableTasks.length, done: doneTasks.length }),
|
||||
confirmLabel: t("listView.bulkDeleteAll", "Delete All"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
tertiaryLabel: t("listView.bulkArchiveDone", "Archive {{count}} Done", { count: doneTasks.length }),
|
||||
danger: true,
|
||||
});
|
||||
if (choice === "cancel") return;
|
||||
@@ -724,10 +728,10 @@ export function ListView({
|
||||
shouldArchiveDoneInstead = choice === "tertiary";
|
||||
} else {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Selected Tasks",
|
||||
message: `Delete ${deletableTasks.length} selected task${deletableTasks.length === 1 ? "" : "s"}?`,
|
||||
confirmLabel: "Delete",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("listView.bulkDeleteTitle", "Delete Selected Tasks"),
|
||||
message: t("listView.bulkDeleteMessage", "Delete {{count}} selected task(s)?", { count: deletableTasks.length }),
|
||||
confirmLabel: t("common.delete", "Delete"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -757,12 +761,11 @@ export function ListView({
|
||||
}
|
||||
|
||||
const confirmedArchive = await confirm({
|
||||
title: "Force Delete Task",
|
||||
title: t("listView.forceDeleteTitle", "Force Delete Task"),
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Archive anyway by unlinking these references first?",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Skip",
|
||||
t("listView.lineageArchiveMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }),
|
||||
confirmLabel: t("common.archive", "Archive"),
|
||||
cancelLabel: t("common.skip", "Skip"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -789,10 +792,10 @@ export function ListView({
|
||||
const dependencyConflict = extractDependencyDeleteConflict(err);
|
||||
if (dependencyConflict) {
|
||||
const forceDelete = await confirm({
|
||||
title: "Force Delete Task",
|
||||
message: `Task ${task.id} has dependents: ${dependencyConflict.dependentIds.join(", ")}. Remove dependency references and force delete?`,
|
||||
confirmLabel: "Force Delete",
|
||||
cancelLabel: "Skip",
|
||||
title: t("listView.forceDeleteTitle", "Force Delete Task"),
|
||||
message: t("listView.dependentsDeleteMessage", "Task {{taskId}} has dependents: {{dependents}}. Remove dependency references and force delete?", { taskId: task.id, dependents: dependencyConflict.dependentIds.join(", ") }),
|
||||
confirmLabel: t("listView.forceDelete", "Force Delete"),
|
||||
cancelLabel: t("common.skip", "Skip"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -815,12 +818,11 @@ export function ListView({
|
||||
}
|
||||
|
||||
const forceLineageDelete = await confirm({
|
||||
title: "Force Delete Task",
|
||||
title: t("listView.forceDeleteTitle", "Force Delete Task"),
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
confirmLabel: "Force Delete",
|
||||
cancelLabel: "Skip",
|
||||
t("listView.lineageDeleteMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }),
|
||||
confirmLabel: t("listView.forceDelete", "Force Delete"),
|
||||
cancelLabel: t("common.skip", "Skip"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -849,12 +851,11 @@ export function ListView({
|
||||
}
|
||||
|
||||
const forceDelete = await confirm({
|
||||
title: "Force Delete Task",
|
||||
title: t("listView.forceDeleteTitle", "Force Delete Task"),
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Delete anyway by unlinking these references first?",
|
||||
confirmLabel: "Force Delete",
|
||||
cancelLabel: "Skip",
|
||||
t("listView.lineageDeleteMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }),
|
||||
confirmLabel: t("listView.forceDelete", "Force Delete"),
|
||||
cancelLabel: t("common.skip", "Skip"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -892,8 +893,8 @@ export function ListView({
|
||||
}
|
||||
|
||||
const summaryMessage = shouldArchiveDoneInstead
|
||||
? `Archived ${archivedIds.length}, deleted ${deletedIds.length}, failed ${failedIds.length}`
|
||||
: `Deleted ${deletedIds.length} task${deletedIds.length === 1 ? "" : "s"} · ${skippedIds.length} archived skipped · ${failedIds.length} failed`;
|
||||
? t("listView.bulkDeleteArchiveSummary", "Archived {{archived}}, deleted {{deleted}}, failed {{failed}}", { archived: archivedIds.length, deleted: deletedIds.length, failed: failedIds.length })
|
||||
: t("listView.bulkDeleteSummary", "Deleted {{deleted}} task(s) · {{skipped}} archived skipped · {{failed}} failed", { deleted: deletedIds.length, skipped: skippedIds.length, failed: failedIds.length });
|
||||
|
||||
addToast(summaryMessage, failedIds.length > 0 ? "error" : "success");
|
||||
}, [addToast, confirm, confirmWithChoice, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
|
||||
@@ -901,7 +902,7 @@ export function ListView({
|
||||
const handleBulkPause = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
if (!onPauseTask) {
|
||||
addToast("Pause action is unavailable", "error");
|
||||
addToast(t("listView.pauseUnavailable", "Pause action is unavailable"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -912,7 +913,7 @@ export function ListView({
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
addToast("No selected tasks can be paused", "error");
|
||||
addToast(t("listView.bulkPauseNoTasks", "No selected tasks can be paused"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -944,7 +945,7 @@ export function ListView({
|
||||
}
|
||||
|
||||
addToast(
|
||||
`Paused ${pausedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`,
|
||||
t("listView.bulkPauseSummary", "Paused {{paused}} · {{skipped}} skipped · {{failed}} failed", { paused: pausedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, onPauseTask, selectedTaskIds, tasks]);
|
||||
@@ -952,7 +953,7 @@ export function ListView({
|
||||
const handleBulkUnpause = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
if (!onUnpauseTask) {
|
||||
addToast("Unpause action is unavailable", "error");
|
||||
addToast(t("listView.unpauseUnavailable", "Unpause action is unavailable"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -963,7 +964,7 @@ export function ListView({
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
addToast("No selected tasks can be unpaused", "error");
|
||||
addToast(t("listView.bulkUnpauseNoTasks", "No selected tasks can be unpaused"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -995,7 +996,7 @@ export function ListView({
|
||||
}
|
||||
|
||||
addToast(
|
||||
`Unpaused ${unpausedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`,
|
||||
t("listView.bulkUnpauseSummary", "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", { unpaused: unpausedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, onUnpauseTask, selectedTaskIds, tasks]);
|
||||
@@ -1003,7 +1004,7 @@ export function ListView({
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
if (!onArchiveTask) {
|
||||
addToast("Archive action is unavailable", "error");
|
||||
addToast(t("listView.archiveUnavailable", "Archive action is unavailable"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1014,15 +1015,15 @@ export function ListView({
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
addToast("No selected tasks can be archived (only done tasks)", "error");
|
||||
addToast(t("listView.bulkArchiveNoTasks", "No selected tasks can be archived (only done tasks)"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Archive Selected Tasks",
|
||||
message: `Archive ${actionableTasks.length} selected task${actionableTasks.length === 1 ? "" : "s"}?`,
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
title: t("listView.bulkArchiveTitle", "Archive Selected Tasks"),
|
||||
message: t("listView.bulkArchiveMessage", "Archive {{count}} selected task(s)?", { count: actionableTasks.length }),
|
||||
confirmLabel: t("common.archive", "Archive"),
|
||||
cancelLabel: t("common.cancel", "Cancel"),
|
||||
danger: false,
|
||||
});
|
||||
|
||||
@@ -1045,12 +1046,11 @@ export function ListView({
|
||||
}
|
||||
|
||||
const confirmedArchive = await confirm({
|
||||
title: "Force Delete Task",
|
||||
title: t("listView.forceDeleteTitle", "Force Delete Task"),
|
||||
message:
|
||||
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
|
||||
"Archive anyway by unlinking these references first?",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Skip",
|
||||
t("listView.lineageArchiveMessage", "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", { taskId: task.id, children: lineageConflict.lineageChildIds.join(", ") }),
|
||||
confirmLabel: t("common.archive", "Archive"),
|
||||
cancelLabel: t("common.skip", "Skip"),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -1082,7 +1082,7 @@ export function ListView({
|
||||
}
|
||||
|
||||
addToast(
|
||||
`Archived ${archivedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`,
|
||||
t("listView.bulkArchiveSummary", "Archived {{archived}} · {{skipped}} skipped · {{failed}} failed", { archived: archivedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, confirm, onArchiveTask, selectedTaskIds, tasks]);
|
||||
@@ -1096,7 +1096,7 @@ export function ListView({
|
||||
});
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
addToast("No valid tasks to update (archived tasks cannot be modified)", "error");
|
||||
addToast(t("listView.bulkUpdateNoTasks", "No valid tasks to update (archived tasks cannot be modified)"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1148,7 +1148,7 @@ export function ListView({
|
||||
|
||||
// Check if any changes were made
|
||||
if (Object.keys(payload).length === 1) {
|
||||
addToast("No changes to apply", "info");
|
||||
addToast(t("listView.bulkNoChanges", "No changes to apply"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1170,7 +1170,7 @@ export function ListView({
|
||||
onTasksUpdated(result.updated);
|
||||
}
|
||||
|
||||
addToast(`Updated ${taskIds.length} task${taskIds.length === 1 ? "" : "s"}`, "success");
|
||||
addToast(t("listView.bulkUpdateSuccess", "Updated {{count}} task(s)", { count: taskIds.length }), "success");
|
||||
|
||||
// Reset state
|
||||
clearSelection();
|
||||
@@ -1178,7 +1178,7 @@ export function ListView({
|
||||
setValidatorModel("__no_change__");
|
||||
setNodeOverride("__no_change__");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to update models", "error");
|
||||
addToast(getErrorMessage(err) || t("listView.bulkUpdateFailed", "Failed to update models"), "error");
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
@@ -1340,7 +1340,7 @@ export function ListView({
|
||||
|
||||
// Prevent dropping into archived column
|
||||
if (column === "archived") {
|
||||
addToast("Tasks can only be archived via the archive button", "error");
|
||||
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1352,20 +1352,20 @@ export function ListView({
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
if (shouldPrompt) {
|
||||
const keepProgress = await confirm({
|
||||
title: "Preserve Progress?",
|
||||
message: "This task has completed steps. Keep progress before moving?",
|
||||
confirmLabel: "Keep Progress",
|
||||
cancelLabel: "Reset Progress",
|
||||
title: t("listView.preserveProgressTitle", "Preserve Progress?"),
|
||||
message: t("listView.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"),
|
||||
confirmLabel: t("listView.keepProgress", "Keep Progress"),
|
||||
cancelLabel: t("listView.resetProgress", "Reset Progress"),
|
||||
});
|
||||
|
||||
if (keepProgress) {
|
||||
moveOptions = { preserveProgress: true };
|
||||
} else {
|
||||
const resetProgress = await confirm({
|
||||
title: "Reset Progress?",
|
||||
message: "Reset all step progress before moving this task?",
|
||||
confirmLabel: "Reset Progress",
|
||||
cancelLabel: "Cancel Move",
|
||||
title: t("listView.resetProgressTitle", "Reset Progress?"),
|
||||
message: t("listView.resetProgressMessage", "Reset all step progress before moving this task?"),
|
||||
confirmLabel: t("listView.resetProgress", "Reset Progress"),
|
||||
cancelLabel: t("listView.cancelMove", "Cancel Move"),
|
||||
danger: true,
|
||||
});
|
||||
if (!resetProgress) {
|
||||
@@ -1401,7 +1401,7 @@ export function ListView({
|
||||
<label
|
||||
key={column}
|
||||
className={`list-column-dropdown-item${isLastVisible ? " disabled" : ""}`}
|
||||
title={isLastVisible ? "At least one column must be visible" : ""}
|
||||
title={isLastVisible ? t("listView.lastColumnWarning", "At least one column must be visible") : ""}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1418,26 +1418,26 @@ export function ListView({
|
||||
className="btn btn-sm list-hide-done-toggle"
|
||||
onClick={() => setHideDoneTasks((prev) => !prev)}
|
||||
aria-pressed={hideDoneTasks}
|
||||
title={hideDoneTasks ? "Show done tasks" : "Hide done tasks"}
|
||||
title={hideDoneTasks ? t("listView.showDoneTitle", "Show done tasks") : t("listView.hideDoneTitle", "Hide done tasks")}
|
||||
>
|
||||
{hideDoneTasks ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
{hideDoneTasks ? "Show Done" : "Hide Done"}
|
||||
{hideDoneTasks ? t("listView.showDone", "Show Done") : t("listView.hideDone", "Hide Done")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm list-hide-done-toggle"
|
||||
onClick={() => setStaleOnlyFilter((prev) => !prev)}
|
||||
aria-pressed={staleOnlyFilter}
|
||||
title={staleOnlyFilter ? "Show all tasks" : "Show stale tasks only"}
|
||||
title={staleOnlyFilter ? t("listView.showAllTitle", "Show all tasks") : t("listView.staleOnlyTitle", "Show stale tasks only")}
|
||||
>
|
||||
{staleOnlyFilter ? "Show all" : "Stale only"}
|
||||
{staleOnlyFilter ? t("listView.showAll", "Show all") : t("listView.staleOnly", "Stale only")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm list-hide-done-toggle"
|
||||
onClick={() => setStalePausedReviewOnlyFilter((prev) => !prev)}
|
||||
aria-pressed={stalePausedReviewOnlyFilter}
|
||||
title={stalePausedReviewOnlyFilter ? "Show all tasks" : "Show stale paused review tasks only"}
|
||||
title={stalePausedReviewOnlyFilter ? t("listView.showAllTitle", "Show all tasks") : t("listView.stalePausedReviewTitle", "Show stale paused review tasks only")}
|
||||
>
|
||||
{stalePausedReviewOnlyFilter ? "Show all" : "Stale paused review"}
|
||||
{stalePausedReviewOnlyFilter ? t("listView.showAll", "Show all") : t("listView.stalePausedReview", "Stale paused review")}
|
||||
</button>
|
||||
<div className="list-drop-zones list-drop-zones--sidebar">
|
||||
{COLUMNS.map((column) => {
|
||||
@@ -1457,7 +1457,7 @@ export function ListView({
|
||||
data-column={column}
|
||||
>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
||||
<span className="drop-zone-label">{columnLabel(column)}</span>
|
||||
<span className="drop-zone-count">
|
||||
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
|
||||
</span>
|
||||
@@ -1471,34 +1471,34 @@ export function ListView({
|
||||
const renderBulkEditToolbars = () => (
|
||||
<>
|
||||
<div className="bulk-edit-toolbar">
|
||||
<button className="btn btn-sm" onClick={handleBulkPause} disabled={isApplying} title="Pause all selected tasks that are not already paused">
|
||||
<button className="btn btn-sm" onClick={handleBulkPause} disabled={isApplying} title={t("listView.pauseSelectedTitle", "Pause all selected tasks that are not already paused")}>
|
||||
<Pause size={14} />
|
||||
Pause selected
|
||||
{t("listView.pauseSelected", "Pause selected")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={handleBulkUnpause} disabled={isApplying} title="Unpause selected tasks that are currently paused">
|
||||
<button className="btn btn-sm" onClick={handleBulkUnpause} disabled={isApplying} title={t("listView.unpauseSelectedTitle", "Unpause selected tasks that are currently paused")}>
|
||||
<Play size={14} />
|
||||
Unpause selected
|
||||
{t("listView.unpauseSelected", "Unpause selected")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={handleBulkArchive} disabled={isApplying} title="Archive selected tasks that are in Done">
|
||||
<button className="btn btn-sm" onClick={handleBulkArchive} disabled={isApplying} title={t("listView.archiveSelectedTitle", "Archive selected tasks that are in Done")}>
|
||||
<Archive size={14} />
|
||||
Archive selected
|
||||
{t("listView.archiveSelected", "Archive selected")}
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={handleBulkDelete} disabled={isApplying} title="Delete selected tasks">
|
||||
<button className="btn btn-danger btn-sm" onClick={handleBulkDelete} disabled={isApplying} title={t("listView.deleteSelectedTitle", "Delete selected tasks")}>
|
||||
<Trash2 size={14} />
|
||||
Delete selected
|
||||
{t("listView.deleteSelected", "Delete selected")}
|
||||
</button>
|
||||
</div>
|
||||
{availableModels && availableModels.length > 0 ? (
|
||||
<div className="bulk-edit-toolbar">
|
||||
<span className="bulk-edit-label">Bulk Edit Models & Node:</span>
|
||||
<span className="bulk-edit-label">{t("listView.bulkEditModelsLabel", "Bulk Edit Models & Node:")}</span>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<CustomModelDropdown
|
||||
models={availableModels}
|
||||
value={executorModel}
|
||||
onChange={setExecutorModel}
|
||||
label="Executor Model"
|
||||
label={t("listView.executorModel", "Executor Model")}
|
||||
noChangeValue="__no_change__"
|
||||
noChangeLabel="No change"
|
||||
noChangeLabel={t("listView.noChange", "No change")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
@@ -1510,9 +1510,9 @@ export function ListView({
|
||||
models={availableModels}
|
||||
value={validatorModel}
|
||||
onChange={setValidatorModel}
|
||||
label="Reviewer Model"
|
||||
label={t("listView.reviewerModel", "Reviewer Model")}
|
||||
noChangeValue="__no_change__"
|
||||
noChangeLabel="No change"
|
||||
noChangeLabel={t("listView.noChange", "No change")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
@@ -1524,11 +1524,11 @@ export function ListView({
|
||||
className="select bulk-node-select"
|
||||
value={nodeOverride}
|
||||
onChange={(e) => setNodeOverride(e.target.value)}
|
||||
aria-label="Node Override"
|
||||
aria-label={t("listView.nodeOverrideLabel", "Node Override")}
|
||||
disabled={isLoadingNodes}
|
||||
>
|
||||
<option value="__no_change__">No change</option>
|
||||
<option value="">Use project default</option>
|
||||
<option value="__no_change__">{t("listView.noChange", "No change")}</option>
|
||||
<option value="">{t("listView.useProjectDefault", "Use project default")}</option>
|
||||
{availableNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
|
||||
@@ -1542,7 +1542,7 @@ export function ListView({
|
||||
onClick={handleApplyBulkUpdate}
|
||||
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
|
||||
>
|
||||
{isApplying ? "Applying..." : "Apply"}
|
||||
{isApplying ? t("listView.applying", "Applying...") : t("listView.apply", "Apply")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1555,7 +1555,7 @@ export function ListView({
|
||||
<>
|
||||
<div className="list-toolbar">
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
|
||||
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm list-view-options-toggle"
|
||||
@@ -1564,17 +1564,17 @@ export function ListView({
|
||||
aria-controls="list-view-options-panel-mobile"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
View options
|
||||
{t("listView.viewOptions", "View options")}
|
||||
</button>
|
||||
{onNewTask ? (
|
||||
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
|
||||
+ New Task
|
||||
{t("listView.newTask", "+ New Task")}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="list-stats">
|
||||
{selectedColumn
|
||||
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
|
||||
: `${filteredCount} of ${tasks.length} tasks`}
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: columnLabel(selectedColumn) })
|
||||
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
|
||||
</div>
|
||||
</div>
|
||||
{viewOptionsOpen ? (
|
||||
@@ -1585,9 +1585,9 @@ export function ListView({
|
||||
<div className="list-mobile-bulk-actions-wrapper">{renderBulkEditToolbars()}</div>
|
||||
) : (
|
||||
<div className="list-mobile-bulk-actions">
|
||||
<span className="list-mobile-bulk-actions__count">{`${selectedTaskIds.size} selected`}</span>
|
||||
<span className="list-mobile-bulk-actions__count">{t("listView.selectedCount", "{{count}} selected", { count: selectedTaskIds.size })}</span>
|
||||
<button className="btn btn-sm" onClick={clearSelection}>
|
||||
Clear
|
||||
{t("listView.clear", "Clear")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -1604,41 +1604,41 @@ export function ListView({
|
||||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<aside className="list-sidebar-controls" aria-label="List controls">
|
||||
<aside className="list-sidebar-controls" aria-label={t("listView.listControlsLabel", "List controls")}>
|
||||
<div className="list-sidebar-controls__header">
|
||||
<p className="list-stats">
|
||||
{selectedColumn
|
||||
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
|
||||
: `${filteredCount} of ${tasks.length} tasks`}
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: columnLabel(selectedColumn) })
|
||||
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
|
||||
{hiddenCompletedCount > 0 && !selectedColumn && (
|
||||
<span className="list-stats-hidden"> ({hiddenCompletedCount} hidden)</span>
|
||||
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="list-sidebar-controls__actions">
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
|
||||
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
|
||||
</button>
|
||||
{onNewTask ? (
|
||||
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
|
||||
+ New Task
|
||||
{t("listView.newTask", "+ New Task")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="list-sidebar-summary-chips">
|
||||
{selectedColumn ? (
|
||||
<button className="btn btn-sm" onClick={clearColumnFilter} aria-label="Clear column filter">
|
||||
{`Filter: ${COLUMN_LABELS[selectedColumn]}`}
|
||||
<button className="btn btn-sm" onClick={clearColumnFilter} aria-label={t("listView.clearColumnFilter", "Clear column filter")}>
|
||||
{t("listView.filterChip", "Filter: {{column}}", { column: columnLabel(selectedColumn) })}
|
||||
</button>
|
||||
) : null}
|
||||
{hideDoneTasks ? <span className="list-sidebar-chip">Done hidden</span> : null}
|
||||
{staleOnlyFilter ? <span className="list-sidebar-chip">Stale only</span> : null}
|
||||
{stalePausedReviewOnlyFilter ? <span className="list-sidebar-chip">Stale paused review</span> : null}
|
||||
{hideDoneTasks ? <span className="list-sidebar-chip">{t("listView.doneHiddenChip", "Done hidden")}</span> : null}
|
||||
{staleOnlyFilter ? <span className="list-sidebar-chip">{t("listView.staleOnly", "Stale only")}</span> : null}
|
||||
{stalePausedReviewOnlyFilter ? <span className="list-sidebar-chip">{t("listView.stalePausedReview", "Stale paused review")}</span> : null}
|
||||
{bulkEditEnabled ? (
|
||||
<span className="list-sidebar-chip">Bulk edit</span>
|
||||
<span className="list-sidebar-chip">{t("listView.bulkEdit", "Bulk edit")}</span>
|
||||
) : null}
|
||||
{bulkEditEnabled && selectedTaskIds.size > 0 ? (
|
||||
<button className="btn btn-sm" onClick={clearSelection}>
|
||||
{`${selectedTaskIds.size} selected`}
|
||||
{t("listView.selectedCount", "{{count}} selected", { count: selectedTaskIds.size })}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1650,7 +1650,7 @@ export function ListView({
|
||||
aria-controls="list-view-options-panel"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
View options
|
||||
{t("listView.viewOptions", "View options")}
|
||||
</button>
|
||||
{viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")}
|
||||
{bulkEditEnabled && selectedTaskIds.size > 0 ? renderBulkEditToolbars() : null}
|
||||
@@ -1658,7 +1658,7 @@ export function ListView({
|
||||
)}
|
||||
<div className="list-quick-entry-above-table">
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
|
||||
onCreate={onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"))}
|
||||
addToast={addToast}
|
||||
tasks={tasks}
|
||||
availableModels={availableModels}
|
||||
@@ -1684,7 +1684,7 @@ export function ListView({
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
<div className="list-empty">
|
||||
{searchQuery ? "No tasks match your filter" : "No tasks yet"}
|
||||
{searchQuery ? t("listView.noTasksMatch", "No tasks match your filter") : t("listView.noTasksYet", "No tasks yet")}
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="list-cards">
|
||||
@@ -1718,14 +1718,14 @@ export function ListView({
|
||||
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
|
||||
/>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
||||
<span className="list-section-title">{columnLabel(column)}</span>
|
||||
<span className="list-section-count">{columnTasks.length}</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<div className="list-empty-cell list-card-empty">No tasks</div>
|
||||
<div className="list-empty-cell list-card-empty">{t("listView.noTasks", "No tasks")}</div>
|
||||
) : (
|
||||
columnTasks.map((task) => {
|
||||
const isDoneColumn = task.column === "done";
|
||||
@@ -1763,7 +1763,7 @@ export function ListView({
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={task.column === "archived"}
|
||||
aria-label={`Select ${task.id}`}
|
||||
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
@@ -1773,18 +1773,18 @@ export function ListView({
|
||||
{task.executionMode === "fast" && (
|
||||
<span
|
||||
className="list-execution-mode-badge list-execution-mode-badge--fast"
|
||||
title="Fast mode"
|
||||
aria-label="Fast mode"
|
||||
title={t("listView.fastMode", "Fast mode")}
|
||||
aria-label={t("listView.fastMode", "Fast mode")}
|
||||
>
|
||||
<Zap aria-hidden="true" />
|
||||
<span className="visually-hidden">Fast mode</span>
|
||||
<span className="visually-hidden">{t("listView.fastMode", "Fast mode")}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="list-card-spacer" />
|
||||
{isPaused && task.pausedByAgentId ? (
|
||||
<span className="list-status-badge paused">paused by agent</span>
|
||||
<span className="list-status-badge paused">{t("listView.pausedByAgent", "paused by agent")}</span>
|
||||
) : isStuckState ? (
|
||||
<span className="list-status-badge stuck">Stuck</span>
|
||||
<span className="list-status-badge stuck">{t("listView.stuck", "Stuck")}</span>
|
||||
) : hasStatus ? (
|
||||
<span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}>
|
||||
{getTaskStatusLabel(visualStatus ?? "")}
|
||||
@@ -1842,35 +1842,35 @@ export function ListView({
|
||||
if (el) el.indeterminate = isSelectIndeterminate;
|
||||
}}
|
||||
onChange={toggleSelectAll}
|
||||
aria-label="Select all visible tasks"
|
||||
aria-label={t("listView.selectAll", "Select all visible tasks")}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("title") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("title")}>
|
||||
Title {getSortIcon("title")}
|
||||
{t("listView.colTitle", "Title")} {getSortIcon("title")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("status") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("status")}>
|
||||
Status {getSortIcon("status")}
|
||||
{t("listView.colStatus", "Status")} {getSortIcon("status")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("column") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("column")}>
|
||||
Column {getSortIcon("column")}
|
||||
{t("listView.colColumn", "Column")} {getSortIcon("column")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("retries") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("retries")}>
|
||||
Retries {getSortIcon("retries")}
|
||||
{t("listView.colRetries", "Retries")} {getSortIcon("retries")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("dependencies") && (
|
||||
<th className="list-header-cell">Dependencies</th>
|
||||
<th className="list-header-cell">{t("listView.colDependencies", "Dependencies")}</th>
|
||||
)}
|
||||
{visibleColumns.has("progress") && (
|
||||
<th className="list-header-cell">Progress</th>
|
||||
<th className="list-header-cell">{t("listView.colProgress", "Progress")}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1904,7 +1904,7 @@ export function ListView({
|
||||
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
|
||||
/>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
||||
<span className="list-section-title">{columnLabel(column)}</span>
|
||||
<span className="list-section-count">{columnTasks.length}</span>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -1915,7 +1915,7 @@ export function ListView({
|
||||
{isEmpty ? (
|
||||
<tr className="list-section-empty">
|
||||
<td colSpan={visibleColumns.size + (bulkEditEnabled ? 1 : 0)} className="list-empty-cell">
|
||||
No tasks
|
||||
{t("listView.noTasks", "No tasks")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -1958,7 +1958,7 @@ export function ListView({
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={task.column === "archived"}
|
||||
aria-label={`Select ${task.id}`}
|
||||
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
@@ -1970,11 +1970,11 @@ export function ListView({
|
||||
{task.executionMode === "fast" && (
|
||||
<span
|
||||
className="list-execution-mode-badge list-execution-mode-badge--fast"
|
||||
title="Fast mode"
|
||||
aria-label="Fast mode"
|
||||
title={t("listView.fastMode", "Fast mode")}
|
||||
aria-label={t("listView.fastMode", "Fast mode")}
|
||||
>
|
||||
<Zap aria-hidden="true" />
|
||||
<span className="visually-hidden">Fast mode</span>
|
||||
<span className="visually-hidden">{t("listView.fastMode", "Fast mode")}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="list-title-text">{task.title || task.description}</span>
|
||||
@@ -1985,10 +1985,10 @@ export function ListView({
|
||||
{visibleColumns.has("status") && (
|
||||
<td className="list-cell">
|
||||
{isPaused && task.pausedByAgentId ? (
|
||||
<span className="list-status-badge paused">paused by agent</span>
|
||||
<span className="list-status-badge paused">{t("listView.pausedByAgent", "paused by agent")}</span>
|
||||
) : isStuckState ? (
|
||||
<span className="list-status-badge stuck">
|
||||
Stuck
|
||||
{t("listView.stuck", "Stuck")}
|
||||
</span>
|
||||
) : visualStatus ? (
|
||||
<span
|
||||
@@ -2012,7 +2012,7 @@ export function ListView({
|
||||
color: COLUMN_COLOR_MAP[task.column],
|
||||
}}
|
||||
>
|
||||
{COLUMN_LABELS[task.column]}
|
||||
{columnLabel(task.column)}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
@@ -2075,7 +2075,7 @@ export function ListView({
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize task list sidebar"
|
||||
aria-label={t("listView.resizeSidebar", "Resize task list sidebar")}
|
||||
aria-valuemin={LIST_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={Math.round(
|
||||
getSidebarMaxWidth(
|
||||
@@ -2088,7 +2088,7 @@ export function ListView({
|
||||
<div className="list-split-detail" data-testid="list-split-detail">
|
||||
{!selectedTaskSnapshot ? (
|
||||
<div className="list-split-detail-empty">
|
||||
<p>Select a task to view details</p>
|
||||
<p>{t("listView.selectTaskPrompt", "Select a task to view details")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-split-detail-content" data-testid="list-split-detail-content">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchLlamaCppStatus, setLlamaCppEnabled, type LlamaCppStatus } from "../api";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import "./LlamaCppProviderCard.css";
|
||||
@@ -11,6 +12,7 @@ interface LlamaCppProviderCardProps {
|
||||
}
|
||||
|
||||
export function LlamaCppProviderCard({ authenticated, onToggled, compact = false }: LlamaCppProviderCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [status, setStatus] = useState<LlamaCppStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -56,19 +58,19 @@ export function LlamaCppProviderCard({ authenticated, onToggled, compact = false
|
||||
const serverAvailable = status?.server.available ?? false;
|
||||
|
||||
const statusText = !status
|
||||
? "Probing llama.cpp server…"
|
||||
? t("providers.llamaCpp.probing", "Probing llama.cpp server…")
|
||||
: status.server.available
|
||||
? `Server reachable at ${status.server.url}`
|
||||
: `Server unavailable: ${status.server.reason ?? "not reachable"}`;
|
||||
? t("providers.llamaCpp.reachable", "Server reachable at {{url}}", { url: status.server.url })
|
||||
: t("providers.llamaCpp.unavailable", "Server unavailable: {{reason}}", { reason: status.server.reason ?? "not reachable" });
|
||||
|
||||
const actions = (
|
||||
<div className="auth-provider-cli-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleTest()} disabled={busy !== null}>
|
||||
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" />Testing…</> : "Test"}
|
||||
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" />{t("providers.llamaCpp.testing", "Testing…")}</> : t("providers.llamaCpp.test", "Test")}
|
||||
</button>
|
||||
{enabled ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
|
||||
{busy === "disabling" ? "Disabling…" : "Disable"}
|
||||
{busy === "disabling" ? t("providers.llamaCpp.disabling", "Disabling…") : t("providers.llamaCpp.disable", "Disable")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -77,7 +79,7 @@ export function LlamaCppProviderCard({ authenticated, onToggled, compact = false
|
||||
onClick={() => void handleToggle(true)}
|
||||
disabled={busy !== null || !serverAvailable}
|
||||
>
|
||||
{busy === "enabling" ? "Enabling…" : "Enable"}
|
||||
{busy === "enabling" ? t("providers.llamaCpp.enabling", "Enabling…") : t("providers.llamaCpp.enable", "Enable")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "./MailboxModal.css";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
X,
|
||||
Mail,
|
||||
@@ -57,7 +59,7 @@ interface MailboxModalProps {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
function formatTimestamp(ts: string, t?: TFunction<"app">): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -65,10 +67,10 @@ function formatTimestamp(ts: string): string {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffMins < 1) return t?.("mailbox.timeJustNow", "Just now") ?? "Just now";
|
||||
if (diffMins < 60) return t?.("mailbox.timeMinsAgo", "{{count}}m ago", { count: diffMins }) ?? `${diffMins}m ago`;
|
||||
if (diffHours < 24) return t?.("mailbox.timeHoursAgo", "{{count}}h ago", { count: diffHours }) ?? `${diffHours}h ago`;
|
||||
if (diffDays < 7) return t?.("mailbox.timeDaysAgo", "{{count}}d ago", { count: diffDays }) ?? `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
@@ -77,22 +79,23 @@ function participantLabel(
|
||||
id: string,
|
||||
type: ParticipantType,
|
||||
agentNamesById?: ReadonlyMap<string, string>,
|
||||
t?: TFunction<"app">,
|
||||
): string {
|
||||
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
||||
if (type === "user") return id === "dashboard" ? (t?.("mailbox.labelYou", "You") ?? "You") : (t?.("mailbox.labelUser", "User: {{id}}", { id }) ?? `User: ${id}`);
|
||||
if (type === "agent") {
|
||||
const name = agentNamesById?.get(id)?.trim();
|
||||
if (!name || name === id) return `Agent: ${id}`;
|
||||
return `Agent: ${name}`;
|
||||
if (!name || name === id) return (t?.("mailbox.labelAgent", "Agent: {{id}}", { id }) ?? `Agent: ${id}`);
|
||||
return (t?.("mailbox.labelAgentNamed", "Agent: {{name}}", { name }) ?? `Agent: ${name}`);
|
||||
}
|
||||
return "System";
|
||||
return t?.("mailbox.labelSystem", "System") ?? "System";
|
||||
}
|
||||
|
||||
function messageTypeLabel(type: MessageType): string {
|
||||
function messageTypeLabel(type: MessageType, t?: TFunction<"app">): string {
|
||||
switch (type) {
|
||||
case "agent-to-agent": return "Agent ↔ Agent";
|
||||
case "agent-to-user": return "Agent → You";
|
||||
case "user-to-agent": return "You → Agent";
|
||||
case "system": return "System";
|
||||
case "agent-to-agent": return t?.("mailbox.typeAgentToAgent", "Agent ↔ Agent") ?? "Agent ↔ Agent";
|
||||
case "agent-to-user": return t?.("mailbox.typeAgentToUser", "Agent → You") ?? "Agent → You";
|
||||
case "user-to-agent": return t?.("mailbox.typeUserToAgent", "You → Agent") ?? "You → Agent";
|
||||
case "system": return t?.("mailbox.typeSystem", "System") ?? "System";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +158,7 @@ export function MailboxModal({
|
||||
addToast,
|
||||
agents = [],
|
||||
}: MailboxModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const cacheSuffix = projectId ?? "";
|
||||
const inboxCacheKey = `${SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX}${cacheSuffix}`;
|
||||
const outboxCacheKey = `${SWR_CACHE_KEYS.MAILBOX_OUTBOX_PREFIX}${cacheSuffix}`;
|
||||
@@ -398,7 +402,7 @@ export function MailboxModal({
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
// Non-critical failure marking message as read
|
||||
}
|
||||
}
|
||||
// Load conversation thread
|
||||
@@ -489,11 +493,11 @@ export function MailboxModal({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
addToast?.(`Marked ${result.markedAsRead} messages as read`, "success");
|
||||
addToast?.(t("mailbox.markedAsRead", "Marked {{count}} messages as read", { count: result.markedAsRead }), "success");
|
||||
} catch {
|
||||
addToast?.("Failed to mark messages as read", "error");
|
||||
addToast?.(t("mailbox.markReadFailed", "Failed to mark messages as read"), "error");
|
||||
}
|
||||
}, [addToast, inboxCacheKey, projectId, unreadCountCacheKey]);
|
||||
}, [addToast, inboxCacheKey, projectId, unreadCountCacheKey, t]);
|
||||
|
||||
const handleDeleteMessage = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -505,11 +509,11 @@ export function MailboxModal({
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
addToast?.("Message deleted", "success");
|
||||
addToast?.(t("mailbox.messageDeleted", "Message deleted"), "success");
|
||||
} catch {
|
||||
addToast?.("Failed to delete message", "error");
|
||||
addToast?.(t("mailbox.deleteFailed", "Failed to delete message"), "error");
|
||||
}
|
||||
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast]);
|
||||
}, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast, t]);
|
||||
|
||||
const handleReply = useCallback((message: Message) => {
|
||||
setComposeRecipient({ id: message.fromId, type: message.fromType });
|
||||
@@ -524,12 +528,12 @@ export function MailboxModal({
|
||||
setShowComposer(false);
|
||||
setComposeRecipient(null);
|
||||
setComposeReplyContext(null);
|
||||
addToast?.("Message sent", "success");
|
||||
addToast?.(t("mailbox.messageSent", "Message sent"), "success");
|
||||
// Refresh current tab
|
||||
if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "agents" && selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
|
||||
else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast]);
|
||||
}, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, t]);
|
||||
|
||||
const handleOpenCompose = useCallback(() => {
|
||||
// Pre-fill recipient from selected agent if available
|
||||
@@ -630,7 +634,7 @@ export function MailboxModal({
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span>
|
||||
↪ Replying to {cacheMessage ? messagePreview(cacheMessage.content, 60) : `message ${replyToId}`}
|
||||
↪ {t("mailbox.replyingTo", "Replying to {{preview}}", { preview: cacheMessage ? messagePreview(cacheMessage.content, 60) : `message ${replyToId}` })}
|
||||
</span>
|
||||
{isLoadingReply && <Loader2 size={14} className="spin" />}
|
||||
</button>
|
||||
@@ -641,8 +645,8 @@ export function MailboxModal({
|
||||
{cacheMessage && (
|
||||
<>
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
<span>{participantLabel(cacheMessage.fromId, cacheMessage.fromType, agentNamesById)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(cacheMessage.createdAt)}</span>
|
||||
<span>{participantLabel(cacheMessage.fromId, cacheMessage.fromType, agentNamesById, t)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(cacheMessage.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-conversation-msg-body">{cacheMessage.content}</div>
|
||||
{cacheMessage.metadata?.replyTo?.messageId && !nextAncestorIds.has(cacheMessage.metadata.replyTo.messageId) && (
|
||||
@@ -677,7 +681,7 @@ export function MailboxModal({
|
||||
<div className="modal-header mailbox-header">
|
||||
<div className="mailbox-title">
|
||||
<Mail size={18} />
|
||||
<span>Mailbox</span>
|
||||
<span>{t("mailbox.title", "Mailbox")}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
|
||||
{unreadCount}
|
||||
@@ -688,21 +692,21 @@ export function MailboxModal({
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleOpenCompose}
|
||||
title="Compose message"
|
||||
title={t("mailbox.composeTitle", "Compose message")}
|
||||
data-testid="mailbox-header-compose"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Compose</span>
|
||||
<span>{t("mailbox.composeButton", "Compose")}</span>
|
||||
</button>
|
||||
{activeTab === "inbox" && unreadCount > 0 && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleMarkAllRead}
|
||||
title="Mark all as read"
|
||||
title={t("mailbox.markAllReadTitle", "Mark all as read")}
|
||||
data-testid="mailbox-mark-all-read"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
<span>Mark all read</span>
|
||||
<span>{t("mailbox.markAllReadButton", "Mark all read")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -714,7 +718,7 @@ export function MailboxModal({
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
title={t("mailbox.refreshTitle", "Refresh")}
|
||||
data-testid="mailbox-refresh"
|
||||
>
|
||||
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
@@ -722,8 +726,8 @@ export function MailboxModal({
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
aria-label={t("mailbox.closeAriaLabel", "Close")}
|
||||
title={t("mailbox.closeTitle", "Close")}
|
||||
data-testid="mailbox-close"
|
||||
>
|
||||
<X size={16} />
|
||||
@@ -739,7 +743,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-tab-inbox"
|
||||
>
|
||||
<InboxIcon size={14} />
|
||||
<span>Inbox</span>
|
||||
<span>{t("mailbox.inboxTab", "Inbox")}</span>
|
||||
{unreadCount > 0 && <span className="mailbox-tab-badge">{unreadCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
@@ -748,7 +752,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-tab-outbox"
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>Outbox</span>
|
||||
<span>{t("mailbox.outboxTab", "Outbox")}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
@@ -756,7 +760,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-tab-agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
<span>Agents</span>
|
||||
<span>{t("mailbox.agentsTab", "Agents")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -771,11 +775,11 @@ export function MailboxModal({
|
||||
onClick={handleCloseMessage}
|
||||
data-testid="mailbox-back-to-list"
|
||||
>
|
||||
← Back
|
||||
{t("mailbox.backButton", "← Back")}
|
||||
</button>
|
||||
<div className="mailbox-message-detail-meta">
|
||||
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
|
||||
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type, t)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-message-detail-actions">
|
||||
{selectedMessage.fromType === "agent" && (
|
||||
@@ -785,7 +789,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-reply"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Reply</span>
|
||||
<span>{t("mailbox.replyButton", "Reply")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -794,30 +798,31 @@ export function MailboxModal({
|
||||
data-testid="mailbox-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete</span>
|
||||
<span>{t("mailbox.deleteButton", "Delete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-message-participants">
|
||||
<div className="mailbox-participant">
|
||||
<span className="mailbox-participant-label">From:</span>
|
||||
<span className="mailbox-participant-label">{t("mailbox.fromLabel", "From:")}
|
||||
</span>
|
||||
<span className="mailbox-participant-value">
|
||||
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||
{participantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById)}
|
||||
{participantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mailbox-participant">
|
||||
<span className="mailbox-participant-label">To:</span>
|
||||
<span className="mailbox-participant-label">{t("mailbox.toLabel", "To:")}</span>
|
||||
<span className="mailbox-participant-value">
|
||||
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||
{participantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById)}
|
||||
{participantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Conversation thread */}
|
||||
{threadMessages.length > 1 && (
|
||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||
<div className="mailbox-conversation-label">Conversation</div>
|
||||
<div className="mailbox-conversation-label">{t("mailbox.conversationLabel", "Conversation")}</div>
|
||||
{threadMessages.map((msg) => {
|
||||
const replyToId = msg.metadata?.replyTo?.messageId;
|
||||
const replyToMessage = replyToId
|
||||
@@ -831,8 +836,8 @@ export function MailboxModal({
|
||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||
>
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
<span>{participantLabel(msg.fromId, msg.fromType, agentNamesById)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span>{participantLabel(msg.fromId, msg.fromType, agentNamesById, t)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
{replyToId && (
|
||||
<ReplyContextExpandable
|
||||
@@ -897,7 +902,7 @@ export function MailboxModal({
|
||||
{inbox && inbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-inbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No messages in your inbox</p>
|
||||
<p>{t("mailbox.noInbox", "No messages in your inbox")}</p>
|
||||
</div>
|
||||
)}
|
||||
{inbox?.messages.map((msg) => (
|
||||
@@ -914,9 +919,9 @@ export function MailboxModal({
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{participantLabel(msg.fromId, msg.fromType, agentNamesById)}
|
||||
{participantLabel(msg.fromId, msg.fromType, agentNamesById, t)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -933,7 +938,7 @@ export function MailboxModal({
|
||||
{outbox && outbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-outbox-empty">
|
||||
<Send size={32} />
|
||||
<p>No sent messages</p>
|
||||
<p>{t("mailbox.noOutbox", "No sent messages")}</p>
|
||||
</div>
|
||||
)}
|
||||
{outbox?.messages.map((msg) => (
|
||||
@@ -950,9 +955,9 @@ export function MailboxModal({
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-to">
|
||||
To: {participantLabel(msg.toId, msg.toType, agentNamesById)}
|
||||
{t("mailbox.toPrefix", "To: {{recipient}}", { recipient: participantLabel(msg.toId, msg.toType, agentNamesById, t) })}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -967,7 +972,7 @@ export function MailboxModal({
|
||||
{agents.length === 0 ? (
|
||||
<div className="mailbox-empty">
|
||||
<Bot size={32} />
|
||||
<p>No agents found</p>
|
||||
<p>{t("mailbox.noAgents", "No agents found")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -993,7 +998,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-compose-btn"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Compose</span>
|
||||
<span>{t("mailbox.composeButton", "Compose")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1006,7 +1011,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-agent-subtab-inbox"
|
||||
>
|
||||
<InboxIcon size={12} />
|
||||
<span>Inbox</span>
|
||||
<span>{t("mailbox.inboxTab", "Inbox")}</span>
|
||||
{agentMailbox && agentMailbox.unreadCount > 0 && (
|
||||
<span className="mailbox-tab-badge">{agentMailbox.unreadCount}</span>
|
||||
)}
|
||||
@@ -1017,7 +1022,7 @@ export function MailboxModal({
|
||||
data-testid="mailbox-agent-subtab-outbox"
|
||||
>
|
||||
<Send size={12} />
|
||||
<span>Outbox</span>
|
||||
<span>{t("mailbox.outboxTab", "Outbox")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1026,7 +1031,7 @@ export function MailboxModal({
|
||||
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No agent-to-agent messages</p>
|
||||
<p>{t("mailbox.noAgentMessages", "No agent-to-agent messages")}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.map((msg) => (
|
||||
@@ -1043,13 +1048,13 @@ export function MailboxModal({
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{participantLabel(msg.fromId, msg.fromType, agentNamesById)}
|
||||
{participantLabel(msg.fromId, msg.fromType, agentNamesById, t)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-participants" data-testid={`mailbox-item-participants-${msg.id}`}>
|
||||
<span>From: {participantLabel(msg.fromId, msg.fromType, agentNamesById)}</span>
|
||||
<span>To: {participantLabel(msg.toId, msg.toType, agentNamesById)}</span>
|
||||
<span>{t("mailbox.fromPrefix", "From: {{participant}}", { participant: participantLabel(msg.fromId, msg.fromType, agentNamesById, t) })}</span>
|
||||
<span>{t("mailbox.toPrefix", "To: {{recipient}}", { recipient: participantLabel(msg.toId, msg.toType, agentNamesById, t) })}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -1060,13 +1065,13 @@ export function MailboxModal({
|
||||
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No received messages for this agent</p>
|
||||
<p>{t("mailbox.noReceivedMessages", "No received messages for this agent")}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<Send size={32} />
|
||||
<p>No sent messages for this agent</p>
|
||||
<p>{t("mailbox.noSentMessages", "No sent messages for this agent")}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
||||
@@ -1083,9 +1088,9 @@ export function MailboxModal({
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{participantLabel(msg.fromId, msg.fromType, agentNamesById)}
|
||||
{participantLabel(msg.fromId, msg.fromType, agentNamesById, t)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -1105,9 +1110,9 @@ export function MailboxModal({
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-to">
|
||||
To: {participantLabel(msg.toId, msg.toType, agentNamesById)}
|
||||
{t("mailbox.toPrefix", "To: {{recipient}}", { recipient: participantLabel(msg.toId, msg.toType, agentNamesById, t) })}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "./MailboxModal.css";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
@@ -87,7 +89,7 @@ function readMailboxSidebarWidth(projectId?: string): number {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
function formatTimestamp(ts: string, t?: TFunction<"app">): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -95,10 +97,10 @@ function formatTimestamp(ts: string): string {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffMins < 1) return t ? t("mailbox.justNow", "Just now") : "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ${t ? t("mailbox.ago", "ago") : "ago"}`;
|
||||
if (diffHours < 24) return `${diffHours}h ${t ? t("mailbox.ago", "ago") : "ago"}`;
|
||||
if (diffDays < 7) return `${diffDays}d ${t ? t("mailbox.ago", "ago") : "ago"}`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
@@ -107,15 +109,16 @@ function participantLabel(
|
||||
id: string,
|
||||
type: ParticipantType,
|
||||
agentNamesById?: ReadonlyMap<string, string>,
|
||||
t?: TFunction<"app">,
|
||||
): string {
|
||||
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
||||
if (type === "user") return id === "dashboard" ? (t ? t("mailbox.you", "You") : "You") : `${t ? t("mailbox.user", "User") : "User"}: ${id}`;
|
||||
if (type === "agent") {
|
||||
const name = agentNamesById?.get(id)?.trim();
|
||||
if (!name) return `Agent: ${id}`;
|
||||
if (name === id) return `Agent: ${id}`;
|
||||
return `Agent: ${name} (${id})`;
|
||||
if (!name) return `${t ? t("mailbox.agent", "Agent") : "Agent"}: ${id}`;
|
||||
if (name === id) return `${t ? t("mailbox.agent", "Agent") : "Agent"}: ${id}`;
|
||||
return `${t ? t("mailbox.agent", "Agent") : "Agent"}: ${name} (${id})`;
|
||||
}
|
||||
return "System";
|
||||
return t ? t("mailbox.system", "System") : "System";
|
||||
}
|
||||
|
||||
function messageTypeLabel(type: MessageType): string {
|
||||
@@ -193,6 +196,7 @@ export function MailboxView({
|
||||
addToast,
|
||||
onUnreadCountChange,
|
||||
}: MailboxViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<MailboxTab>("inbox");
|
||||
const [inbox, setInbox] = useState<InboxResponse | null>(null);
|
||||
const [outbox, setOutbox] = useState<OutboxResponse | null>(null);
|
||||
@@ -220,8 +224,8 @@ export function MailboxView({
|
||||
[agents],
|
||||
);
|
||||
const getParticipantLabel = useCallback(
|
||||
(id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById),
|
||||
[agentNamesById],
|
||||
(id: string, type: ParticipantType) => participantLabel(id, type, agentNamesById, t),
|
||||
[agentNamesById, t],
|
||||
);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
@@ -675,12 +679,12 @@ export function MailboxView({
|
||||
onClick={handleCloseMessage}
|
||||
data-testid="mailbox-back-to-list"
|
||||
>
|
||||
← Back
|
||||
← {t("mailbox.back", "Back")}
|
||||
</button>
|
||||
)}
|
||||
<div className="mailbox-message-detail-meta">
|
||||
<span className="mailbox-message-type">{messageTypeLabel(selectedMessage.type)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(selectedMessage.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-message-detail-actions">
|
||||
{selectedMessage.fromType === "agent" && (
|
||||
@@ -690,7 +694,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-reply"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Reply</span>
|
||||
<span>{t("mailbox.reply", "Reply")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -699,20 +703,20 @@ export function MailboxView({
|
||||
data-testid="mailbox-delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Delete</span>
|
||||
<span>{t("mailbox.delete", "Delete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-message-participants">
|
||||
<div className="mailbox-participant">
|
||||
<span className="mailbox-participant-label">From:</span>
|
||||
<span className="mailbox-participant-label">{t("mailbox.from", "From")}:</span>
|
||||
<span className="mailbox-participant-value">
|
||||
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||
{getParticipantLabel(selectedMessage.fromId, selectedMessage.fromType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mailbox-participant">
|
||||
<span className="mailbox-participant-label">To:</span>
|
||||
<span className="mailbox-participant-label">{t("mailbox.to", "To")}:</span>
|
||||
<span className="mailbox-participant-value">
|
||||
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||
{getParticipantLabel(selectedMessage.toId, selectedMessage.toType)}
|
||||
@@ -721,7 +725,7 @@ export function MailboxView({
|
||||
</div>
|
||||
{threadMessages.length > 1 && (
|
||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||
<div className="mailbox-conversation-label">Conversation</div>
|
||||
<div className="mailbox-conversation-label">{t("mailbox.conversation", "Conversation")}</div>
|
||||
{threadMessages.map((msg) => {
|
||||
const replyToId = msg.metadata?.replyTo?.messageId;
|
||||
const replyToMessage = replyToId
|
||||
@@ -736,11 +740,11 @@ export function MailboxView({
|
||||
>
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
<span>{getParticipantLabel(msg.fromId, msg.fromType)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
{replyToId && (
|
||||
<div className="mailbox-reply-context-static" data-testid={`mailbox-reply-context-${msg.id}`}>
|
||||
↪ Replying to {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
||||
↪ {t("mailbox.replyingTo", "Replying to")} {replyToMessage ? messagePreview(replyToMessage.content, 60) : `message ${replyToId}`}
|
||||
</div>
|
||||
)}
|
||||
<MailboxMessageContent
|
||||
@@ -756,7 +760,7 @@ export function MailboxView({
|
||||
<>
|
||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||
<div className="mailbox-reply-context-static" data-testid="mailbox-selected-reply-context">
|
||||
↪ Replying to message {selectedMessage.metadata.replyTo.messageId}
|
||||
↪ {t("mailbox.replyingToMessage", "Replying to message")} {selectedMessage.metadata.replyTo.messageId}
|
||||
</div>
|
||||
)}
|
||||
<MailboxMessageContent
|
||||
@@ -778,7 +782,7 @@ export function MailboxView({
|
||||
{inbox && inbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-inbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No messages in your inbox</p>
|
||||
<p>{t("mailbox.noMessagesInbox", "No messages in your inbox")}</p>
|
||||
</div>
|
||||
)}
|
||||
{inbox?.messages.map((msg) => (
|
||||
@@ -797,7 +801,7 @@ export function MailboxView({
|
||||
<span className="mailbox-item-from">
|
||||
{getParticipantLabel(msg.fromId, msg.fromType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -813,7 +817,7 @@ export function MailboxView({
|
||||
{outbox && outbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-outbox-empty">
|
||||
<Send size={32} />
|
||||
<p>No sent messages</p>
|
||||
<p>{t("mailbox.noSentMessages", "No sent messages")}</p>
|
||||
</div>
|
||||
)}
|
||||
{outbox?.messages.map((msg) => (
|
||||
@@ -832,7 +836,7 @@ export function MailboxView({
|
||||
<span className="mailbox-item-to">
|
||||
To: {getParticipantLabel(msg.toId, msg.toType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -849,21 +853,21 @@ export function MailboxView({
|
||||
onClick={() => { setApprovalSubTab("pending"); setSelectedApproval(null); }}
|
||||
data-testid="mailbox-approval-filter-pending"
|
||||
>
|
||||
Pending
|
||||
{t("mailbox.pending", "Pending")}
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "history" ? "active" : ""}`}
|
||||
onClick={() => { setApprovalSubTab("history"); setSelectedApproval(null); }}
|
||||
data-testid="mailbox-approval-filter-history"
|
||||
>
|
||||
History
|
||||
{t("mailbox.history", "History")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mailbox-list" data-testid="mailbox-approval-list">
|
||||
{approvals.length === 0 && !isLoading && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-approval-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>{approvalSubTab === "pending" ? "No pending approvals" : "No historical approvals"}</p>
|
||||
<p>{approvalSubTab === "pending" ? t("mailbox.noPendingApprovals", "No pending approvals") : t("mailbox.noHistoricalApprovals", "No historical approvals")}</p>
|
||||
</div>
|
||||
)}
|
||||
{approvals.map((request) => (
|
||||
@@ -893,7 +897,7 @@ export function MailboxView({
|
||||
{agents.length === 0 ? (
|
||||
<div className="mailbox-empty">
|
||||
<Bot size={32} />
|
||||
<p>No agents found</p>
|
||||
<p>{t("mailbox.noAgentsFound", "No agents found")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -905,7 +909,7 @@ export function MailboxView({
|
||||
onChange={(e) => { setSelectedAgentId(e.target.value); setAgentSubTab("inbox"); }}
|
||||
data-testid="mailbox-agent-select"
|
||||
>
|
||||
<option value={ALL_AGENTS_MAILBOX_ID}>All agents</option>
|
||||
<option value={ALL_AGENTS_MAILBOX_ID}>{t("mailbox.allAgents", "All agents")}</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name || agent.id}
|
||||
@@ -919,7 +923,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-compose-btn"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Compose</span>
|
||||
<span>{t("mailbox.compose", "Compose")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -931,7 +935,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-agent-subtab-inbox"
|
||||
>
|
||||
<InboxIcon size={12} />
|
||||
<span>Inbox</span>
|
||||
<span>{t("mailbox.inbox", "Inbox")}</span>
|
||||
{agentMailbox && agentMailbox.unreadCount > 0 && (
|
||||
<span className="mailbox-tab-badge">{agentMailbox.unreadCount}</span>
|
||||
)}
|
||||
@@ -942,7 +946,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-agent-subtab-outbox"
|
||||
>
|
||||
<Send size={12} />
|
||||
<span>Outbox</span>
|
||||
<span>{t("mailbox.outbox", "Outbox")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -951,7 +955,7 @@ export function MailboxView({
|
||||
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No agent-to-agent messages</p>
|
||||
<p>{t("mailbox.noAgentMessages", "No agent-to-agent messages")}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId === ALL_AGENTS_MAILBOX_ID && allAgentsMailbox && allAgentsMailbox.messages.map((msg) => (
|
||||
@@ -968,11 +972,11 @@ export function MailboxView({
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">{getParticipantLabel(msg.fromId, msg.fromType)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-participants" data-testid={`mailbox-item-participants-${msg.id}`}>
|
||||
<span>From: {getParticipantLabel(msg.fromId, msg.fromType)}</span>
|
||||
<span>To: {getParticipantLabel(msg.toId, msg.toType)}</span>
|
||||
<span>{t("mailbox.from", "From")}: {getParticipantLabel(msg.fromId, msg.fromType)}</span>
|
||||
<span>{t("mailbox.to", "To")}: {getParticipantLabel(msg.toId, msg.toType)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -982,13 +986,13 @@ export function MailboxView({
|
||||
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>No received messages for this agent</p>
|
||||
<p>{t("mailbox.noReceivedMessages", "No received messages for this agent")}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "outbox" && agentMailbox.outbox.length === 0 && (
|
||||
<div className="mailbox-empty">
|
||||
<Send size={32} />
|
||||
<p>No sent messages for this agent</p>
|
||||
<p>{t("mailbox.noSentMessagesAgent", "No sent messages for this agent")}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID && agentMailbox && agentSubTab === "inbox" && agentMailbox.inbox.map((msg) => (
|
||||
@@ -1007,7 +1011,7 @@ export function MailboxView({
|
||||
<span className="mailbox-item-from">
|
||||
{getParticipantLabel(msg.fromId, msg.fromType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -1029,7 +1033,7 @@ export function MailboxView({
|
||||
<span className="mailbox-item-to">
|
||||
To: {getParticipantLabel(msg.toId, msg.toType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt, t)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
@@ -1120,7 +1124,7 @@ export function MailboxView({
|
||||
return (
|
||||
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
|
||||
<Mail size={24} />
|
||||
<p>Select a message to read</p>
|
||||
<p>{t("mailbox.selectMessageToRead", "Select a message to read")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1131,7 +1135,7 @@ export function MailboxView({
|
||||
<div className="mailbox-header">
|
||||
<div className="mailbox-title">
|
||||
<Mail size={18} />
|
||||
<span>Mailbox</span>
|
||||
<span>{t("mailbox.title", "Mailbox")}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
|
||||
{unreadCount}
|
||||
@@ -1142,21 +1146,21 @@ export function MailboxView({
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleOpenCompose}
|
||||
title="Compose message"
|
||||
title={t("mailbox.composeMessageTitle", "Compose message")}
|
||||
data-testid="mailbox-header-compose"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span>Compose</span>
|
||||
<span>{t("mailbox.compose", "Compose")}</span>
|
||||
</button>
|
||||
{activeTab === "inbox" && unreadCount > 0 && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleMarkAllRead}
|
||||
title="Mark all as read"
|
||||
title={t("mailbox.markAllReadTitle", "Mark all as read")}
|
||||
data-testid="mailbox-mark-all-read"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
<span>Mark all read</span>
|
||||
<span>{t("mailbox.markAllRead", "Mark all read")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1169,7 +1173,7 @@ export function MailboxView({
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
title={t("mailbox.refreshTitle", "Refresh")}
|
||||
data-testid="mailbox-refresh"
|
||||
>
|
||||
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
@@ -1185,7 +1189,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-tab-inbox"
|
||||
>
|
||||
<InboxIcon size={14} />
|
||||
<span>Inbox</span>
|
||||
<span>{t("mailbox.inbox", "Inbox")}</span>
|
||||
{unreadCount > 0 && <span className="mailbox-tab-badge">{unreadCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
@@ -1194,7 +1198,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-tab-outbox"
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>Outbox</span>
|
||||
<span>{t("mailbox.outbox", "Outbox")}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
@@ -1202,7 +1206,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-tab-agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
<span>Agents</span>
|
||||
<span>{t("mailbox.agents", "Agents")}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "approvals" ? "active" : ""}`}
|
||||
@@ -1210,7 +1214,7 @@ export function MailboxView({
|
||||
data-testid="mailbox-tab-approvals"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
<span>Approvals</span>
|
||||
<span>{t("mailbox.approvals", "Approvals")}</span>
|
||||
{approvalPendingCount > 0 && <span className="mailbox-tab-badge" data-testid="mailbox-approvals-pending-badge">{approvalPendingCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import "./MemoryView.css";
|
||||
import "./SettingsModal.css";
|
||||
@@ -22,18 +23,6 @@ const CATEGORY_HEADERS: Record<string, string> = {
|
||||
"Context": "context",
|
||||
};
|
||||
|
||||
const MEMORY_LAYER_NAMES: Record<MemoryFileInfo["layer"], string> = {
|
||||
"long-term": "Long-term",
|
||||
daily: "Daily",
|
||||
dreams: "Dreams",
|
||||
};
|
||||
|
||||
const MEMORY_LAYER_DESCRIPTIONS: Record<MemoryFileInfo["layer"], string> = {
|
||||
"long-term": "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams.",
|
||||
daily: "Raw daily observations, open loops, and running context for dream processing.",
|
||||
dreams: "Synthesized patterns and open loops promoted from daily memory.",
|
||||
};
|
||||
|
||||
const MEMORY_FILE_OPTION_LABEL_MAX_CHARS = 72;
|
||||
|
||||
function truncateMiddle(value: string, maxChars: number): string {
|
||||
@@ -109,33 +98,8 @@ function countTotalInsights(categories: ParsedInsightCategory[]): number {
|
||||
return categories.reduce((sum, cat) => sum + cat.items.length, 0);
|
||||
}
|
||||
|
||||
/** Get backend display name */
|
||||
function getBackendDisplayName(backend: string): string {
|
||||
switch (backend) {
|
||||
case "file":
|
||||
return "File (.fusion/memory/, agent/<agent-name>/memory/)";
|
||||
case "readonly":
|
||||
return "Read-Only";
|
||||
case "qmd":
|
||||
return "QMD (Quantized Memory Distillation)";
|
||||
default:
|
||||
return backend;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get health badge text */
|
||||
function getHealthBadgeText(health: "healthy" | "warning" | "issues"): string {
|
||||
switch (health) {
|
||||
case "healthy":
|
||||
return "Healthy";
|
||||
case "warning":
|
||||
return "Warning";
|
||||
case "issues":
|
||||
return "Issues Found";
|
||||
}
|
||||
}
|
||||
|
||||
export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<Tab>("working");
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [editingInsights, setEditingInsights] = useState(false);
|
||||
@@ -207,8 +171,12 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
);
|
||||
|
||||
const selectedLayerDescription = selectedMemoryFile
|
||||
? MEMORY_LAYER_DESCRIPTIONS[selectedMemoryFile.layer]
|
||||
: "Edits the selected memory file.";
|
||||
? (selectedMemoryFile.layer === "long-term"
|
||||
? t("memory.layerDescLongTerm", "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams.")
|
||||
: selectedMemoryFile.layer === "daily"
|
||||
? t("memory.layerDescDaily", "Raw daily observations, open loops, and running context for dream processing.")
|
||||
: t("memory.layerDescDreams", "Synthesized patterns and open loops promoted from daily memory."))
|
||||
: t("memory.editorDefaultDescription", "Edits the selected memory file.");
|
||||
|
||||
// Parse insights content
|
||||
const parsedCategories = useMemo(
|
||||
@@ -243,16 +211,16 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
try {
|
||||
await selectFile(path);
|
||||
} catch {
|
||||
addToast("Failed to load memory file", "error");
|
||||
addToast(t("memory.loadFileFailed", "Failed to load memory file"), "error");
|
||||
}
|
||||
}, [selectFile, addToast]);
|
||||
|
||||
const handleSaveSelectedFile = useCallback(async () => {
|
||||
try {
|
||||
await saveSelectedFile();
|
||||
addToast("Memory saved", "success");
|
||||
addToast(t("memory.memorySaved", "Memory saved"), "success");
|
||||
} catch {
|
||||
addToast("Failed to save memory", "error");
|
||||
addToast(t("memory.saveMemoryFailed", "Failed to save memory"), "error");
|
||||
}
|
||||
}, [saveSelectedFile, addToast]);
|
||||
|
||||
@@ -284,9 +252,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
|
||||
try {
|
||||
await saveMemorySettings(patch);
|
||||
addToast("Memory settings saved", "success");
|
||||
addToast(t("memory.settingsSaved", "Memory settings saved"), "success");
|
||||
} catch {
|
||||
addToast("Failed to save memory settings", "error");
|
||||
addToast(t("memory.saveSettingsFailed", "Failed to save memory settings"), "error");
|
||||
}
|
||||
}, [memorySettingsDirty, memorySettingsDraft, memorySettings, saveMemorySettings, addToast]);
|
||||
|
||||
@@ -294,11 +262,11 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
try {
|
||||
const result = await installQmdAction();
|
||||
addToast(
|
||||
result.qmdAvailable ? "qmd installed successfully" : "qmd install finished, but qmd is still unavailable",
|
||||
result.qmdAvailable ? t("memory.qmdInstallSuccess", "qmd installed successfully") : t("memory.qmdInstallUnavailable", "qmd install finished, but qmd is still unavailable"),
|
||||
result.qmdAvailable ? "success" : "info",
|
||||
);
|
||||
} catch {
|
||||
addToast("Failed to install qmd", "error");
|
||||
addToast(t("memory.installQmdFailed", "Failed to install qmd"), "error");
|
||||
}
|
||||
}, [installQmdAction, addToast]);
|
||||
|
||||
@@ -310,11 +278,11 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
const result = await testRetrieval(memoryTestQuery);
|
||||
setMemoryTestResult(result);
|
||||
addToast(
|
||||
result.qmdAvailable ? "Memory retrieval test complete" : "qmd is not installed; local fallback was used",
|
||||
result.qmdAvailable ? t("memory.retrievalTestComplete", "Memory retrieval test complete") : t("memory.retrievalTestFallback", "qmd is not installed; local fallback was used"),
|
||||
result.qmdAvailable ? "success" : "info",
|
||||
);
|
||||
} catch {
|
||||
addToast("Failed to test memory retrieval", "error");
|
||||
addToast(t("memory.retrievalTestFailed", "Failed to test memory retrieval"), "error");
|
||||
} finally {
|
||||
setMemoryTestLoading(false);
|
||||
}
|
||||
@@ -323,10 +291,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
const handleDreamNow = useCallback(async () => {
|
||||
try {
|
||||
await triggerDreamNow();
|
||||
addToast("Dream processing completed", "success");
|
||||
addToast(t("memory.dreamProcessingComplete", "Dream processing completed"), "success");
|
||||
await reloadMemoryFiles();
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : "Failed to run dream processing", "error");
|
||||
addToast(error instanceof Error ? error.message : t("memory.dreamProcessingFailed", "Failed to run dream processing"), "error");
|
||||
}
|
||||
}, [triggerDreamNow, reloadMemoryFiles, addToast]);
|
||||
|
||||
@@ -334,9 +302,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
const handleCompactMemory = useCallback(async () => {
|
||||
try {
|
||||
await compactMemory(selectedFilePath);
|
||||
addToast("Memory file compacted", "success");
|
||||
addToast(t("memory.fileCompacted", "Memory file compacted"), "success");
|
||||
} catch {
|
||||
addToast("Failed to compact memory", "error");
|
||||
addToast(t("memory.compactFailed", "Failed to compact memory"), "error");
|
||||
}
|
||||
}, [compactMemory, selectedFilePath, addToast]);
|
||||
|
||||
@@ -346,7 +314,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
const result = await extractInsights();
|
||||
addToast(result.summary, "success");
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : "Failed to extract insights", "error");
|
||||
addToast(err instanceof Error ? err.message : t("memory.extractInsightsFailed", "Failed to extract insights"), "error");
|
||||
}
|
||||
}, [extractInsights, addToast]);
|
||||
|
||||
@@ -357,9 +325,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
await saveInsights(insightsEditorContent);
|
||||
setEditingInsights(false);
|
||||
setInsightsEditorContent(null);
|
||||
addToast("Insights saved", "success");
|
||||
addToast(t("memory.insightsSaved", "Insights saved"), "success");
|
||||
} catch {
|
||||
addToast("Failed to save insights", "error");
|
||||
addToast(t("memory.saveInsightsFailed", "Failed to save insights"), "error");
|
||||
}
|
||||
}, [insightsEditorContent, saveInsights, addToast]);
|
||||
|
||||
@@ -383,9 +351,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{/* Header */}
|
||||
<div className="memory-view-header">
|
||||
<div>
|
||||
<h2>Memory</h2>
|
||||
<h2>{t("memory.title", "Memory")}</h2>
|
||||
<p className="memory-view-description">
|
||||
Working memory, long-term insights, and engine status
|
||||
{t("memory.description", "Working memory, long-term insights, and engine status")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,7 +368,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onClick={() => setActiveTab("working")}
|
||||
data-testid="memory-tab-working"
|
||||
>
|
||||
Working Memory
|
||||
{t("memory.tabWorking", "Working Memory")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -410,7 +378,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onClick={() => setActiveTab("insights")}
|
||||
data-testid="memory-tab-insights"
|
||||
>
|
||||
Insights
|
||||
{t("memory.tabInsights", "Insights")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -420,7 +388,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onClick={() => setActiveTab("engines")}
|
||||
data-testid="memory-tab-engines"
|
||||
>
|
||||
Engines
|
||||
{t("memory.tabEngines", "Engines")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -431,20 +399,20 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
<div className="memory-working-tab">
|
||||
{backendStatusResolved && !isWritable && (
|
||||
<div className="memory-readonly-banner">
|
||||
This memory backend is read-only. Changes cannot be saved.
|
||||
{t("memory.readOnlyBanner", "This memory backend is read-only. Changes cannot be saved.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memoryFilesLoading || selectedFileLoading ? (
|
||||
<div className="memory-empty-state">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>Loading memory file…</span>
|
||||
<span>{t("memory.loadingFile", "Loading memory file…")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="memory-editor-section">
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryViewFilePath">Memory File</label>
|
||||
<label htmlFor="memoryViewFilePath">{t("memory.fileLabel", "Memory File")}</label>
|
||||
<select
|
||||
id="memoryViewFilePath"
|
||||
className="select"
|
||||
@@ -462,23 +430,23 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</select>
|
||||
<small>
|
||||
{selectedFileDirty
|
||||
? "Save or discard the current edits before switching files."
|
||||
: "Choose any project memory file to view or edit."}
|
||||
? t("memory.fileSwitchDirtyHint", "Save or discard the current edits before switching files.")
|
||||
: t("memory.fileSwitchHint", "Choose any project memory file to view or edit.")}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{selectedMemoryFile && (
|
||||
<div className="memory-file-summary">
|
||||
<span>{MEMORY_LAYER_NAMES[selectedMemoryFile.layer]}</span>
|
||||
<span>{selectedMemoryFile.layer === "long-term" ? t("memory.layerLongTerm", "Long-term") : selectedMemoryFile.layer === "daily" ? t("memory.layerDaily", "Daily") : t("memory.layerDreams", "Dreams")}</span>
|
||||
<strong>{selectedMemoryFile.path}</strong>
|
||||
<small>
|
||||
{selectedMemoryFile.size.toLocaleString()} bytes · updated {new Date(selectedMemoryFile.updatedAt).toLocaleString()}
|
||||
{t("memory.fileSummary", "{{size}} bytes · updated {{updatedAt}}", { size: selectedMemoryFile.size.toLocaleString(), updatedAt: new Date(selectedMemoryFile.updatedAt).toLocaleString() })}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group memory-editor-form-group">
|
||||
<label>{selectedMemoryFile?.label || "Memory Editor"}</label>
|
||||
<label>{selectedMemoryFile?.label || t("memory.editorLabel", "Memory Editor")}</label>
|
||||
<small>{selectedLayerDescription}</small>
|
||||
<div className="memory-editor-container">
|
||||
<FileEditor
|
||||
@@ -492,7 +460,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
</div>
|
||||
|
||||
<div className="memory-action-bar">
|
||||
<span className="memory-char-count">{selectedFileContent.length} characters</span>
|
||||
<span className="memory-char-count">{t("memory.charCount", "{{count}} characters", { count: selectedFileContent.length })}</span>
|
||||
<div className="memory-flex-spacer" />
|
||||
{isWritable && selectedFileContent.length > 0 && (
|
||||
<button
|
||||
@@ -504,10 +472,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{compacting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Compacting…
|
||||
{t("memory.compacting", "Compacting…")}
|
||||
</>
|
||||
) : (
|
||||
"Compact Selected File"
|
||||
t("memory.compactSelectedFile", "Compact Selected File")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -521,10 +489,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{savingSelectedFile ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Saving…
|
||||
{t("memory.saving", "Saving…")}
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
t("memory.save", "Save")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -546,15 +514,15 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
}}
|
||||
disabled={!memorySettingsDraft.memoryEnabled || settingsLoading}
|
||||
/>
|
||||
Process dreams from daily memory
|
||||
{t("memory.dreamsEnabledLabel", "Process dreams from daily memory")}
|
||||
</label>
|
||||
<small>Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.</small>
|
||||
<small>{t("memory.dreamsEnabledHint", "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.")}</small>
|
||||
</div>
|
||||
|
||||
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryDreamsEnabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryDreamsSchedule">Dream Schedule</label>
|
||||
<label htmlFor="memoryDreamsSchedule">{t("memory.dreamsScheduleLabel", "Dream Schedule")}</label>
|
||||
<input
|
||||
id="memoryDreamsSchedule"
|
||||
type="text"
|
||||
@@ -569,7 +537,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
placeholder="0 4 * * *"
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Cron expression for dream processing.</small>
|
||||
<small>{t("memory.dreamsScheduleHint", "Cron expression for dream processing.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<button
|
||||
@@ -581,13 +549,13 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{dreamRunning ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Dreaming…
|
||||
{t("memory.dreaming", "Dreaming…")}
|
||||
</>
|
||||
) : (
|
||||
"Dream Now"
|
||||
t("memory.dreamNow", "Dream Now")
|
||||
)}
|
||||
</button>
|
||||
<small>Manually trigger dream processing now.</small>
|
||||
<small>{t("memory.dreamNowHint", "Manually trigger dream processing now.")}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -608,15 +576,15 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
}}
|
||||
disabled={!memorySettingsDraft.memoryEnabled || settingsLoading}
|
||||
/>
|
||||
Auto-Summarize Memory
|
||||
{t("memory.autoSummarizeLabel", "Auto-Summarize Memory")}
|
||||
</label>
|
||||
<small>Automatically compact memory when it exceeds the threshold on a schedule</small>
|
||||
<small>{t("memory.autoSummarizeHint", "Automatically compact memory when it exceeds the threshold on a schedule")}</small>
|
||||
</div>
|
||||
|
||||
{memorySettingsDraft.memoryEnabled && memorySettingsDraft.memoryAutoSummarizeEnabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeThresholdChars">Compaction Threshold (chars)</label>
|
||||
<label htmlFor="memoryAutoSummarizeThresholdChars">{t("memory.compactionThresholdLabel", "Compaction Threshold (chars)")}</label>
|
||||
<input
|
||||
id="memoryAutoSummarizeThresholdChars"
|
||||
type="number"
|
||||
@@ -631,10 +599,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
min={1000}
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Memory will be compacted when it exceeds this character count</small>
|
||||
<small>{t("memory.compactionThresholdHint", "Memory will be compacted when it exceeds this character count")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryAutoSummarizeSchedule">Schedule (cron)</label>
|
||||
<label htmlFor="memoryAutoSummarizeSchedule">{t("memory.autoSummarizeScheduleLabel", "Schedule (cron)")}</label>
|
||||
<input
|
||||
id="memoryAutoSummarizeSchedule"
|
||||
type="text"
|
||||
@@ -649,7 +617,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
placeholder="0 3 * * *"
|
||||
disabled={settingsLoading}
|
||||
/>
|
||||
<small>Cron expression for auto-summarize schedule (default: daily at 3 AM)</small>
|
||||
<small>{t("memory.autoSummarizeScheduleHint", "Cron expression for auto-summarize schedule (default: daily at 3 AM)")}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -657,7 +625,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
|
||||
{!memorySettingsDraft.memoryEnabled && (
|
||||
<div className="settings-empty-state memory-status-message">
|
||||
Memory is currently disabled. Enable memory tools in Settings to edit these automations.
|
||||
{t("memory.disabledMessage", "Memory is currently disabled. Enable memory tools in Settings to edit these automations.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -672,10 +640,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{savingMemorySettings ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Saving…
|
||||
{t("memory.saving", "Saving…")}
|
||||
</>
|
||||
) : (
|
||||
"Save Settings"
|
||||
t("memory.saveSettings", "Save Settings")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -692,7 +660,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{insightsLoading ? (
|
||||
<div className="memory-empty-state">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>Loading insights…</span>
|
||||
<span>{t("memory.loadingInsights", "Loading insights…")}</span>
|
||||
</div>
|
||||
) : editingInsights ? (
|
||||
// Raw editor mode
|
||||
@@ -711,24 +679,23 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCancelEditingInsights}
|
||||
>
|
||||
Cancel
|
||||
{t("memory.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSaveInsights}
|
||||
>
|
||||
Save Insights
|
||||
{t("memory.saveInsights", "Save Insights")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : !insightsExists || parsedCategories.length === 0 ? (
|
||||
// Empty state
|
||||
<div className="memory-empty-state">
|
||||
<p>No insights extracted yet.</p>
|
||||
<p>{t("memory.noInsights", "No insights extracted yet.")}</p>
|
||||
<p>
|
||||
Insights are automatically extracted from working memory.
|
||||
Click "Extract Now" to trigger extraction manually.
|
||||
{t("memory.noInsightsHint", "Insights are automatically extracted from working memory. Click \"Extract Now\" to trigger extraction manually.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -739,10 +706,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{extracting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Extracting…
|
||||
{t("memory.extracting", "Extracting…")}
|
||||
</>
|
||||
) : (
|
||||
"Extract Now"
|
||||
t("memory.extractNow", "Extract Now")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -752,16 +719,16 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
<div className="memory-stats-row">
|
||||
<div className="memory-stat-card">
|
||||
<div className="memory-stat-value">{totalInsights}</div>
|
||||
<div className="memory-stat-label">Total Insights</div>
|
||||
<div className="memory-stat-label">{t("memory.totalInsights", "Total Insights")}</div>
|
||||
</div>
|
||||
<div className="memory-stat-card">
|
||||
<div className="memory-stat-value">{parsedCategories.length}</div>
|
||||
<div className="memory-stat-label">Categories</div>
|
||||
<div className="memory-stat-label">{t("memory.categories", "Categories")}</div>
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<div className="memory-stat-card">
|
||||
<div className="memory-stat-value memory-stat-value--updated">{lastUpdated}</div>
|
||||
<div className="memory-stat-label">Last Updated</div>
|
||||
<div className="memory-stat-label">{t("memory.lastUpdated", "Last Updated")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -776,10 +743,10 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{extracting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Extracting…
|
||||
{t("memory.extracting", "Extracting…")}
|
||||
</>
|
||||
) : (
|
||||
"Extract Now"
|
||||
t("memory.extractNow", "Extract Now")
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -787,7 +754,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleStartEditingInsights}
|
||||
>
|
||||
Edit Raw
|
||||
{t("memory.editRaw", "Edit Raw")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -837,22 +804,22 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{backendLoading || auditLoading ? (
|
||||
<div className="memory-empty-state">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>Loading engine status…</span>
|
||||
<span>{t("memory.loadingEngineStatus", "Loading engine status…")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* QMD Integration Card */}
|
||||
<div className="memory-engine-card memory-qmd-card">
|
||||
<h3>QMD Integration</h3>
|
||||
<h3>{t("memory.qmdIntegrationTitle", "QMD Integration")}</h3>
|
||||
{backendStatus?.qmdAvailable === true ? (
|
||||
<div className="memory-engine-status">
|
||||
<span className="memory-health-badge memory-health-badge--healthy">Installed</span>
|
||||
<span className="memory-char-count">qmd is available on PATH.</span>
|
||||
<span className="memory-health-badge memory-health-badge--healthy">{t("memory.qmdInstalled", "Installed")}</span>
|
||||
<span className="memory-char-count">{t("memory.qmdAvailableOnPath", "qmd is available on PATH.")}</span>
|
||||
</div>
|
||||
) : backendStatus?.qmdAvailable === false ? (
|
||||
<div className="settings-empty-state memory-status-message">
|
||||
<span>
|
||||
qmd is not installed. Search will use local files. Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
|
||||
{t("memory.qmdNotInstalled", "qmd is not installed. Search will use local files. Install indexed retrieval:")} <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -860,41 +827,41 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onClick={handleInstallQmd}
|
||||
disabled={installingQmd}
|
||||
>
|
||||
{installingQmd ? "Installing…" : "Install qmd"}
|
||||
{installingQmd ? t("memory.installing", "Installing…") : t("memory.installQmd", "Install qmd")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="memory-engine-status">
|
||||
<span className="memory-health-badge">Checking</span>
|
||||
<span className="memory-char-count">Checking qmd availability…</span>
|
||||
<span className="memory-health-badge">{t("memory.qmdChecking", "Checking")}</span>
|
||||
<span className="memory-char-count">{t("memory.qmdCheckingAvailability", "Checking qmd availability…")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="memory-capability-row">
|
||||
{backendStatus?.capabilities?.readable && (
|
||||
<span className="memory-capability-badge">Readable</span>
|
||||
<span className="memory-capability-badge">{t("memory.capReadable", "Readable")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.writable && (
|
||||
<span className="memory-capability-badge">Writable</span>
|
||||
<span className="memory-capability-badge">{t("memory.capWritable", "Writable")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.supportsAtomicWrite && (
|
||||
<span className="memory-capability-badge">Atomic Writes</span>
|
||||
<span className="memory-capability-badge">{t("memory.capAtomicWrites", "Atomic Writes")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.persistent && (
|
||||
<span className="memory-capability-badge">Persistent</span>
|
||||
<span className="memory-capability-badge">{t("memory.capPersistent", "Persistent")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Retrieval Test Card */}
|
||||
<div className="memory-engine-card memory-retrieval-card">
|
||||
<h3>Test Memory Search</h3>
|
||||
<h3>{t("memory.testMemorySearchTitle", "Test Memory Search")}</h3>
|
||||
<div className="memory-retrieval-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={memoryTestQuery}
|
||||
onChange={(event) => setMemoryTestQuery(event.target.value)}
|
||||
placeholder="Search memory with qmd"
|
||||
placeholder={t("memory.searchPlaceholder", "Search memory with qmd")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -902,21 +869,20 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onClick={handleTestRetrieval}
|
||||
disabled={memoryTestLoading}
|
||||
>
|
||||
{memoryTestLoading ? "Testing…" : "Test Retrieval"}
|
||||
{memoryTestLoading ? t("memory.testing", "Testing…") : t("memory.testRetrieval", "Test Retrieval")}
|
||||
</button>
|
||||
</div>
|
||||
<small className="settings-muted">
|
||||
Runs the same qmd-backed memory_search path agents use.
|
||||
{t("memory.testSearchHint", "Runs the same qmd-backed memory_search path agents use.")}
|
||||
</small>
|
||||
|
||||
{memoryTestResult && (
|
||||
<div className="memory-test-result">
|
||||
<strong>
|
||||
{memoryTestResult.results.length} result{memoryTestResult.results.length === 1 ? "" : "s"}
|
||||
{" "}for "{memoryTestResult.query}"
|
||||
{t("memory.testResultCount", "{{count}} result for \"{{query}}\"", { count: memoryTestResult.results.length, query: memoryTestResult.query, defaultValue_one: "{{count}} result for \"{{query}}\"", defaultValue_other: "{{count}} results for \"{{query}}\"" })}
|
||||
</strong>
|
||||
<small>
|
||||
qmd {memoryTestResult.qmdAvailable ? "available" : "missing"} · {memoryTestResult.usedFallback ? "local fallback used" : "qmd path used"}
|
||||
{t("memory.testResultStatus", "qmd {{qmdStatus}} · {{fallbackStatus}}", { qmdStatus: memoryTestResult.qmdAvailable ? t("memory.qmdStatusAvailable", "available") : t("memory.qmdStatusMissing", "missing"), fallbackStatus: memoryTestResult.usedFallback ? t("memory.localFallbackUsed", "local fallback used") : t("memory.qmdPathUsed", "qmd path used") })}
|
||||
</small>
|
||||
{memoryTestResult.results.length > 0 ? (
|
||||
<ul>
|
||||
@@ -928,7 +894,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small>No matching memory found.</small>
|
||||
<small>{t("memory.noMatchingMemory", "No matching memory found.")}</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -936,22 +902,30 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
|
||||
{/* Backend Card */}
|
||||
<div className="memory-engine-card">
|
||||
<h3>Current Backend</h3>
|
||||
<h3>{t("memory.currentBackendTitle", "Current Backend")}</h3>
|
||||
<div className="memory-engine-status">
|
||||
<span className="memory-emphasis-text">{getBackendDisplayName(backendStatus?.currentBackend ?? "unknown")}</span>
|
||||
<span className="memory-emphasis-text">{
|
||||
backendStatus?.currentBackend === "file"
|
||||
? t("memory.backendFile", "File (.fusion/memory/, agent/<agent-name>/memory/)")
|
||||
: backendStatus?.currentBackend === "readonly"
|
||||
? t("memory.backendReadonly", "Read-Only")
|
||||
: backendStatus?.currentBackend === "qmd"
|
||||
? t("memory.backendQmd", "QMD (Quantized Memory Distillation)")
|
||||
: (backendStatus?.currentBackend ?? "unknown")
|
||||
}</span>
|
||||
</div>
|
||||
<div className="memory-capability-row">
|
||||
{backendStatus?.capabilities?.readable && (
|
||||
<span className="memory-capability-badge">Readable</span>
|
||||
<span className="memory-capability-badge">{t("memory.capReadable", "Readable")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.writable && (
|
||||
<span className="memory-capability-badge">Writable</span>
|
||||
<span className="memory-capability-badge">{t("memory.capWritable", "Writable")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.supportsAtomicWrite && (
|
||||
<span className="memory-capability-badge">Atomic Writes</span>
|
||||
<span className="memory-capability-badge">{t("memory.capAtomicWrites", "Atomic Writes")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.persistent && (
|
||||
<span className="memory-capability-badge">Persistent</span>
|
||||
<span className="memory-capability-badge">{t("memory.capPersistent", "Persistent")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -960,50 +934,50 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{auditReport && (
|
||||
<div className="memory-engine-card">
|
||||
<div className="memory-health-header">
|
||||
<h3>Health Status</h3>
|
||||
<h3>{t("memory.healthStatusTitle", "Health Status")}</h3>
|
||||
<span className={`memory-health-badge memory-health-badge--${auditReport.health}`}>
|
||||
{getHealthBadgeText(auditReport.health)}
|
||||
{auditReport.health === "healthy" ? t("memory.healthHealthy", "Healthy") : auditReport.health === "warning" ? t("memory.healthWarning", "Warning") : t("memory.healthIssues", "Issues Found")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="memory-health-grid">
|
||||
<div>
|
||||
<div className="memory-health-label">Working Memory</div>
|
||||
<div className="memory-emphasis-text">{auditReport.workingMemory.size} chars</div>
|
||||
<div className="memory-health-label">{t("memory.workingMemoryLabel", "Working Memory")}</div>
|
||||
<div className="memory-emphasis-text">{t("memory.sizeChars", "{{size}} chars", { size: auditReport.workingMemory.size })}</div>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.workingMemory.sectionCount} sections
|
||||
{t("memory.sectionCount", "{{count}} sections", { count: auditReport.workingMemory.sectionCount })}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="memory-health-label">Insights Memory</div>
|
||||
<div className="memory-emphasis-text">{auditReport.insightsMemory.size} chars</div>
|
||||
<div className="memory-health-label">{t("memory.insightsMemoryLabel", "Insights Memory")}</div>
|
||||
<div className="memory-emphasis-text">{t("memory.sizeChars", "{{size}} chars", { size: auditReport.insightsMemory.size })}</div>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.insightsMemory.insightCount} insights
|
||||
{t("memory.insightCount", "{{count}} insights", { count: auditReport.insightsMemory.insightCount })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="memory-health-section">
|
||||
<div className="memory-health-label">Last Extraction</div>
|
||||
<div className="memory-health-label">{t("memory.lastExtractionLabel", "Last Extraction")}</div>
|
||||
<div className="memory-emphasis-text">
|
||||
{auditReport.extraction.success ? (
|
||||
<span className="memory-status-text memory-status-text--success">Success</span>
|
||||
<span className="memory-status-text memory-status-text--success">{t("memory.extractionSuccess", "Success")}</span>
|
||||
) : (
|
||||
<span className="memory-status-text memory-status-text--error">Failed</span>
|
||||
<span className="memory-status-text memory-status-text--error">{t("memory.extractionFailed", "Failed")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="memory-health-detail">
|
||||
{auditReport.extraction.summary || `${auditReport.extraction.insightCount} insights extracted`}
|
||||
{auditReport.extraction.summary || t("memory.insightsExtracted", "{{count}} insights extracted", { count: auditReport.extraction.insightCount })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="memory-health-section">
|
||||
<div className="memory-health-label">Pruning</div>
|
||||
<div className="memory-health-label">{t("memory.pruningLabel", "Pruning")}</div>
|
||||
<div className="memory-emphasis-text">
|
||||
{auditReport.pruning.applied ? (
|
||||
<span className="memory-status-text memory-status-text--warning">Applied</span>
|
||||
<span className="memory-status-text memory-status-text--warning">{t("memory.pruningApplied", "Applied")}</span>
|
||||
) : (
|
||||
<span className="memory-status-text memory-status-text--muted">Not needed</span>
|
||||
<span className="memory-status-text memory-status-text--muted">{t("memory.pruningNotNeeded", "Not needed")}</span>
|
||||
)}
|
||||
</div>
|
||||
{auditReport.pruning.applied && (
|
||||
@@ -1018,7 +992,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
{/* Audit Checks */}
|
||||
{auditReport && auditReport.checks.length > 0 && (
|
||||
<div className="memory-engine-card">
|
||||
<h3>Audit Checks</h3>
|
||||
<h3>{t("memory.auditChecksTitle", "Audit Checks")}</h3>
|
||||
<div>
|
||||
{auditReport.checks.map((check) => (
|
||||
<div key={check.id} className="memory-audit-check">
|
||||
@@ -1042,23 +1016,23 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => refreshAudit()}
|
||||
>
|
||||
Run Audit
|
||||
{t("memory.runAudit", "Run Audit")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Note about Settings */}
|
||||
<div className="memory-settings-note">
|
||||
<span>Note: Change backend type in</span>
|
||||
<span>{t("memory.settingsNote", "Note: Change backend type in")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="memory-settings-note-button"
|
||||
onClick={() => {
|
||||
// This would open the settings modal with memory section focused
|
||||
// For now, just add a toast hint
|
||||
addToast("Open Settings → Memory to change backend type", "info");
|
||||
addToast(t("memory.settingsNoteToast", "Open Settings → Memory to change backend type"), "info");
|
||||
}}
|
||||
>
|
||||
Settings → Memory
|
||||
{t("memory.settingsNoteLink", "Settings → Memory")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import StashConflictModal from "./StashConflictModal";
|
||||
import { useMergeAdvanceNotice } from "../hooks/useMergeAdvanceNotice";
|
||||
import "./MergeAdvanceNotice.css";
|
||||
@@ -21,6 +22,7 @@ const disabledReasonCopy: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: MergeAdvanceNoticeProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const bannerRef = useRef<HTMLDivElement | null>(null);
|
||||
const {
|
||||
notice,
|
||||
@@ -59,16 +61,16 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
}
|
||||
|
||||
const disablePush = pushState === "pending" || pushStatus.canPush === false || pulling;
|
||||
const pushLabel = forceWithLease ? "Push (force-with-lease)" : "Push to origin";
|
||||
const pushLabel = forceWithLease ? t("merge.pushForceWithLease", "Push (force-with-lease)") : t("merge.pushToOrigin", "Push to origin");
|
||||
|
||||
return (
|
||||
<section className="merge-advance-notice__push">
|
||||
<p className="merge-advance-notice__push-heading">
|
||||
Push {pushStatus.integrationBranch} to origin — ahead by {pushStatus.aheadCount} commit{pushStatus.aheadCount === 1 ? "" : "s"}.
|
||||
{t("merge.pushHeading", "Push {{branch}} to origin — ahead by {{count}} commit{{plural}}.", { branch: pushStatus.integrationBranch, count: pushStatus.aheadCount, plural: pushStatus.aheadCount === 1 ? "" : "s" })}
|
||||
</p>
|
||||
<div className="merge-advance-notice__push-actions">
|
||||
{pushState === "ok" ? (
|
||||
<span>Pushed to origin/{pushStatus.integrationBranch} @ {shortSha(pushStatus.remoteSha)}.</span>
|
||||
<span>{t("merge.pushSuccess", "Pushed to origin/{{branch}} @ {{sha}}.", { branch: pushStatus.integrationBranch, sha: shortSha(pushStatus.remoteSha) })}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -76,7 +78,7 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
disabled={disablePush}
|
||||
onClick={() => { void push(); }}
|
||||
>
|
||||
{pushState === "pending" ? "Pushing…" : pushLabel}
|
||||
{pushState === "pending" ? t("merge.pushing", "Pushing…") : pushLabel}
|
||||
</button>
|
||||
)}
|
||||
{!pushStatus.canPush && pushStatus.disabledReason && pushStatus.disabledReason in disabledReasonCopy ? (
|
||||
@@ -86,25 +88,25 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
{typeof pushState === "object" && (pushState.outcome === "rejected-non-ff" || pushState.outcome === "sha-mismatch") ? (
|
||||
<div className="merge-advance-notice__push-error" role="alert">
|
||||
<span>{pushState.error}</span>{" "}
|
||||
<button type="button" className="btn btn-sm" onClick={() => { void pull(); }}>Smart Pull</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => { void pull(); }}>{t("merge.smartPull", "Smart Pull")}</button>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof pushState === "object" && (pushState.outcome === "rejected-other" || pushState.outcome === "failed") ? (
|
||||
<div className="merge-advance-notice__push-error" role="alert">
|
||||
<span>{pushState.error}</span>
|
||||
{pushState.stderr ? <pre>{pushState.stderr}</pre> : null}
|
||||
<button type="button" className="btn btn-sm" onClick={clearPushError}>Dismiss</button>
|
||||
<button type="button" className="btn btn-sm" onClick={clearPushError}>{t("actions.dismiss", "Dismiss")}</button>
|
||||
</div>
|
||||
) : null}
|
||||
<details className="merge-advance-notice__push-advanced">
|
||||
<summary>Advanced</summary>
|
||||
<summary>{t("merge.advanced", "Advanced")}</summary>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceWithLease}
|
||||
onChange={(event) => setForceWithLease(event.target.checked)}
|
||||
/>
|
||||
{" "}Allow force-with-lease (use only when you know origin diverged intentionally)
|
||||
{" "}{t("merge.forceWithLeaseLabel", "Allow force-with-lease (use only when you know origin diverged intentionally)")}
|
||||
</label>
|
||||
</details>
|
||||
</section>
|
||||
@@ -115,23 +117,23 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
<>
|
||||
<div ref={bannerRef} className="merge-advance-notice" role="status" aria-live="polite">
|
||||
<div className="merge-advance-notice__content">
|
||||
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
|
||||
Your checked-out copy at {checkout.worktreePath} is behind.
|
||||
{localChangesPreserved ? " (local changes will be auto-stashed and restored)" : ""}
|
||||
<strong>{t("merge.advancedTo", "{{branch}} advanced to {{sha}}.", { branch: notice.integrationBranch, sha: shortSha(notice.toSha) })}</strong>{" "}
|
||||
{t("merge.checkedOutBehind", "Your checked-out copy at {{path}} is behind.", { path: checkout.worktreePath })}
|
||||
{localChangesPreserved ? t("merge.changesWillAutoStash", " (local changes will be auto-stashed and restored)") : ""}
|
||||
{pullError ? <span className="merge-advance-notice__error" role="alert"> {pullError}</span> : null}
|
||||
{pulling ? <span className="merge-advance-notice__hint"> Pulling…</span> : null}
|
||||
{pulling ? <span className="merge-advance-notice__hint"> {t("merge.pulling", "Pulling…")}</span> : null}
|
||||
{renderPushSection()}
|
||||
</div>
|
||||
<div className="merge-advance-notice__actions">
|
||||
{conflictState ? null : (
|
||||
<button type="button" className="btn btn-sm" disabled={pulling} onClick={() => { void pull(); }}>
|
||||
Pull
|
||||
{t("actions.pull", "Pull")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="merge-advance-notice__dismiss touch-target"
|
||||
aria-label="Dismiss merge advance notice"
|
||||
aria-label={t("merge.dismissNotice", "Dismiss merge advance notice")}
|
||||
onClick={dismissWithFocusGuard}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
interface MergeDetailsProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
function shortSha(sha?: string): string {
|
||||
if (!sha) return "Unknown";
|
||||
function shortSha(sha?: string, t?: (key: string, defaultValue: string) => string): string {
|
||||
if (!sha) return t ? t("merge.unknown", "Unknown") : "Unknown";
|
||||
return sha.slice(0, 7);
|
||||
}
|
||||
|
||||
export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (task.column !== "done" || !task.mergeDetails) {
|
||||
return null;
|
||||
}
|
||||
@@ -18,27 +20,27 @@ export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Merge Details</h4>
|
||||
<h4>{t("merge.title", "Merge Details")}</h4>
|
||||
<div className="pr-card merge-details-card">
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Status</span>
|
||||
<span className="detail-log-outcome">{details.mergeConfirmed === false ? "Recorded without local merge confirmation" : "Merged successfully"}</span>
|
||||
<span className="detail-log-action">{t("merge.status", "Status")}</span>
|
||||
<span className="detail-log-outcome">{details.mergeConfirmed === false ? t("merge.recordedNoConfirm", "Recorded without local merge confirmation") : t("merge.mergedSuccess", "Merged successfully")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Commit</span>
|
||||
<span className="detail-log-outcome">{shortSha(details.commitSha)}</span>
|
||||
<span className="detail-log-action">{t("merge.commit", "Commit")}</span>
|
||||
<span className="detail-log-outcome">{shortSha(details.commitSha, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span
|
||||
className="detail-log-action"
|
||||
title="Final commit shortstat; for the full landed diff across all task commits, see the Changes tab."
|
||||
title={t("merge.shortstatTitle", "Final commit shortstat; for the full landed diff across all task commits, see the Changes tab.")}
|
||||
>
|
||||
Files in merge commit
|
||||
{t("merge.filesChanged", "Files in merge commit")}
|
||||
</span>
|
||||
<span className="detail-log-outcome">{details.filesChanged ?? 0}</span>
|
||||
</div>
|
||||
@@ -47,9 +49,9 @@ export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
<div className="detail-log-header">
|
||||
<span
|
||||
className="detail-log-action"
|
||||
title="Final commit shortstat; for the full landed diff across all task commits, see the Changes tab."
|
||||
title={t("merge.shortstatTitle", "Final commit shortstat; for the full landed diff across all task commits, see the Changes tab.")}
|
||||
>
|
||||
Merge-commit insertions / deletions
|
||||
{t("merge.insertionsDeletions", "Merge-commit insertions / deletions")}
|
||||
</span>
|
||||
<span className="detail-log-outcome">+{details.insertions ?? 0} / -{details.deletions ?? 0}</span>
|
||||
</div>
|
||||
@@ -57,7 +59,7 @@ export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
{details.mergedAt ? (
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Merged at</span>
|
||||
<span className="detail-log-action">{t("merge.mergedAt", "Merged at")}</span>
|
||||
<span className="detail-log-outcome">{new Date(details.mergedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +67,7 @@ export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
{details.prNumber ? (
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">PR</span>
|
||||
<span className="detail-log-action">{t("merge.pr", "PR")}</span>
|
||||
{task.prInfo?.url ? (
|
||||
<a
|
||||
className="detail-source-link detail-log-outcome"
|
||||
@@ -84,7 +86,7 @@ export function MergeDetails({ task }: MergeDetailsProps) {
|
||||
{details.mergeCommitMessage ? (
|
||||
<div className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-action">Message</span>
|
||||
<span className="detail-log-action">{t("merge.message", "Message")}</span>
|
||||
</div>
|
||||
<div className="detail-log-outcome">{details.mergeCommitMessage}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NodeMeshState } from "@fusion/core";
|
||||
|
||||
export interface MeshTopologyProps {
|
||||
@@ -19,6 +20,7 @@ const MIN_VIEWBOX_SIZE = 300;
|
||||
const MAX_REMOTE_DISTANCE = 120;
|
||||
|
||||
function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElement {
|
||||
const { t } = useTranslation("app");
|
||||
const localNode = useMemo(() => nodes.find((n) => n.nodeType === "local") ?? nodes[0], [nodes]);
|
||||
const remoteNodes = useMemo(() => nodes.filter((n) => n.nodeId !== localNode?.nodeId), [nodes, localNode?.nodeId]);
|
||||
|
||||
@@ -66,12 +68,12 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
}, [nodePositions, nodes]);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return <div className={`mesh-topology mesh-topology--empty ${className ?? ""}`}><div className="mesh-topology__empty-state"><p>No nodes to display</p></div></div>;
|
||||
return <div className={`mesh-topology mesh-topology--empty ${className ?? ""}`}><div className="mesh-topology__empty-state"><p>{t("mesh.noNodes", "No nodes to display")}</p></div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mesh-topology ${className ?? ""}`}>
|
||||
<svg className="mesh-topology__svg" viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} preserveAspectRatio="xMidYMid meet" aria-label="Node mesh topology visualization">
|
||||
<svg className="mesh-topology__svg" viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} preserveAspectRatio="xMidYMid meet" aria-label={t("mesh.ariaLabel", "Node mesh topology visualization")}>
|
||||
{links.map((link) => (
|
||||
<line key={link.key} className="mesh-topology__link mesh-topology__peer-line" x1={link.from.x} y1={link.from.y} x2={link.to.x} y2={link.to.y} />
|
||||
))}
|
||||
@@ -93,12 +95,12 @@ function MeshTopologyInner({ nodes, className }: MeshTopologyProps): ReactElemen
|
||||
</svg>
|
||||
|
||||
<div className="mesh-topology__legend">
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.online }} /><span>Online</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.offline }} /><span>Offline</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.connecting }} /><span>Connecting</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.error }} /><span>Error</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.online }} /><span>{t("mesh.online", "Online")}</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.offline }} /><span>{t("mesh.offline", "Offline")}</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.connecting }} /><span>{t("mesh.connecting", "Connecting")}</span></div>
|
||||
<div className="mesh-topology__legend-item"><span className="mesh-topology__legend-dot" style={{ background: STATUS_COLORS.error }} /><span>{t("mesh.error", "Error")}</span></div>
|
||||
</div>
|
||||
{links.length === 0 && <p className="mesh-topology__notice">Peer-to-peer discovery data unavailable.</p>}
|
||||
{links.length === 0 && <p className="mesh-topology__notice">{t("mesh.noPeers", "Peer-to-peer discovery data unavailable.")}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
|
||||
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
|
||||
import type { ParticipantType, MessageType } from "@fusion/core";
|
||||
@@ -41,6 +42,7 @@ export function MessageComposer({
|
||||
addToast,
|
||||
isLoadingAgents = false,
|
||||
}: MessageComposerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [toId, setToId] = useState(recipient?.id ?? "");
|
||||
const [toType, setToType] = useState<ParticipantType>(recipient?.type ?? "agent");
|
||||
const [content, setContent] = useState("");
|
||||
@@ -140,11 +142,11 @@ export function MessageComposer({
|
||||
return (
|
||||
<div className="message-composer" data-testid="message-composer">
|
||||
<div className="message-composer-header">
|
||||
<span>{replyContext ? "Reply" : "New Message"}</span>
|
||||
<span>{replyContext ? t("composer.replyTitle", "Reply") : t("composer.newMessageTitle", "New Message")}</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onCancel}
|
||||
aria-label="Cancel"
|
||||
aria-label={t("actions.cancel", "Cancel")}
|
||||
data-testid="message-composer-cancel"
|
||||
>
|
||||
<X size={16} />
|
||||
@@ -156,7 +158,7 @@ export function MessageComposer({
|
||||
{!recipient && (
|
||||
<div className="message-composer-field">
|
||||
<label className="message-composer-label" htmlFor="message-recipient">
|
||||
To:
|
||||
{t("composer.toLabel", "To:")}
|
||||
</label>
|
||||
<select
|
||||
id="message-recipient"
|
||||
@@ -167,7 +169,7 @@ export function MessageComposer({
|
||||
data-testid="message-composer-recipient"
|
||||
>
|
||||
<option value="">
|
||||
{isLoadingAgents ? "Loading agents…" : agents.length === 0 ? "No agents available" : "Select agent…"}
|
||||
{isLoadingAgents ? t("composer.loadingAgents", "Loading agents…") : agents.length === 0 ? t("composer.noAgentsAvailable", "No agents available") : t("composer.selectAgent", "Select agent…")}
|
||||
</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
@@ -181,7 +183,7 @@ export function MessageComposer({
|
||||
{/* Recipient display (when pre-filled from reply) */}
|
||||
{recipient && (
|
||||
<div className="message-composer-field">
|
||||
<span className="message-composer-label">To:</span>
|
||||
<span className="message-composer-label">{t("composer.toLabel", "To:")}</span>
|
||||
<span className="message-composer-recipient-fixed">
|
||||
<Bot size={14} />
|
||||
{prefilledRecipientAgent?.name || recipient.id}
|
||||
@@ -191,9 +193,9 @@ export function MessageComposer({
|
||||
|
||||
{replyContext && (
|
||||
<div className="message-composer-field" data-testid="message-composer-reply-context">
|
||||
<span className="message-composer-label">Replying to:</span>
|
||||
<span className="message-composer-label">{t("composer.replyingToLabel", "Replying to:")}</span>
|
||||
<span className="message-composer-recipient-fixed">
|
||||
{replyContext.preview?.trim() ? replyContext.preview : `Message ${replyContext.messageId}`}
|
||||
{replyContext.preview?.trim() ? replyContext.preview : t("composer.messageId", "Message {{id}}", { id: replyContext.messageId })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,13 +203,13 @@ export function MessageComposer({
|
||||
{/* Content */}
|
||||
<div className="message-composer-field message-composer-field--content">
|
||||
<label className="message-composer-label" htmlFor="message-content">
|
||||
Message:
|
||||
{t("composer.messageLabel", "Message:")}
|
||||
</label>
|
||||
<textarea
|
||||
id="message-content"
|
||||
ref={setTextareaRef}
|
||||
className="message-composer-textarea"
|
||||
placeholder="Type your message…"
|
||||
placeholder={t("composer.messagePlaceholder", "Type your message…")}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={scrollTextareaIntoView}
|
||||
@@ -233,11 +235,11 @@ export function MessageComposer({
|
||||
data-testid="message-composer-wake"
|
||||
/>
|
||||
<span>
|
||||
Wake agent immediately
|
||||
{t("composer.wakeAgentCheckbox", "Wake agent immediately")}
|
||||
<span className="message-composer-wake-hint" data-testid="message-composer-wake-hint">
|
||||
{recipientAlwaysImmediate
|
||||
? "(agent is already set to immediate response mode)"
|
||||
: "(one-off override for this message only)"}
|
||||
? t("composer.wakeAlwaysImmediate", "(agent is already set to immediate response mode)")
|
||||
: t("composer.wakeOneOff", "(one-off override for this message only)")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -259,7 +261,7 @@ export function MessageComposer({
|
||||
onClick={onCancel}
|
||||
data-testid="message-composer-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
@@ -270,12 +272,12 @@ export function MessageComposer({
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>Sending…</span>
|
||||
<span>{t("composer.sendingButton", "Sending…")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={14} />
|
||||
<span>Send</span>
|
||||
<span>{t("actions.send", "Send")}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -76,6 +77,7 @@ export function MilestoneSliceInterviewModal({
|
||||
projectId,
|
||||
resumeSessionId,
|
||||
}: MilestoneSliceInterviewModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
@@ -477,13 +479,13 @@ export function MilestoneSliceInterviewModal({
|
||||
<button
|
||||
className="modal-send-to-background"
|
||||
onClick={handleSendToBackground}
|
||||
title="Send to background"
|
||||
aria-label="Send to background"
|
||||
title={t("interview.sendToBackground", "Send to background")}
|
||||
aria-label={t("interview.sendToBackground", "Send to background")}
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={handleCancel} aria-label="Close">
|
||||
<button className="modal-close" onClick={handleCancel} aria-label={t("actions.close", "Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -491,10 +493,10 @@ export function MilestoneSliceInterviewModal({
|
||||
|
||||
<div className="planning-modal-body">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">{t("interview.reconnecting", "Reconnecting…")}</div>}
|
||||
{activeInAnotherTab && (
|
||||
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
|
||||
Session is active in another tab.
|
||||
{t("interview.sessionActiveAnotherTab", "Session is active in another tab.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -503,15 +505,13 @@ export function MilestoneSliceInterviewModal({
|
||||
<div className="planning-view-scroll">
|
||||
<div className="planning-intro">
|
||||
<Sparkles size={32} className="icon-triage-lg" />
|
||||
<h4>Refine {targetLabel} scope with AI</h4>
|
||||
<h4>{t("interview.refineScope", `Refine ${targetLabel} scope with AI`)}</h4>
|
||||
<p className="text-muted">
|
||||
The AI will interview you to refine the {targetType}'s scope, acceptance criteria,
|
||||
and verification methods. Each {targetType} can have its own refined plan or inherit
|
||||
context from the mission level.
|
||||
{t("interview.interviewDescription", `The AI will interview you to refine the ${targetType}'s scope, acceptance criteria, and verification methods. Each ${targetType} can have its own refined plan or inherit context from the mission level.`)}
|
||||
</p>
|
||||
{missionContext && (
|
||||
<div className="planning-context-info">
|
||||
<span className="planning-context-label">Mission context:</span>
|
||||
<span className="planning-context-label">{t("interview.missionContext", "Mission context:")}</span>
|
||||
<span className="planning-context-text">{missionContext}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -524,7 +524,7 @@ export function MilestoneSliceInterviewModal({
|
||||
onClick={() => void handleStartInterview()}
|
||||
>
|
||||
<Sparkles size={16} className="icon-mr-8" />
|
||||
Start Interview
|
||||
{t("interview.startInterview", "Start Interview")}
|
||||
</button>
|
||||
<button
|
||||
className="btn planning-use-context-btn"
|
||||
@@ -532,10 +532,10 @@ export function MilestoneSliceInterviewModal({
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? <Loader2 size={16} className="spin" /> : null}
|
||||
Use Mission Context
|
||||
{t("interview.useMissionContext", "Use Mission Context")}
|
||||
</button>
|
||||
<button className="btn" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,14 +544,14 @@ export function MilestoneSliceInterviewModal({
|
||||
{view.type === "loading" && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin icon-todo" />
|
||||
<p>{streamingOutput ? "AI is thinking..." : "Preparing next question..."}</p>
|
||||
<p>{streamingOutput ? t("interview.aiThinking", "AI is thinking...") : t("interview.preparingQuestion", "Preparing next question...")}</p>
|
||||
<div className="planning-thinking-container">
|
||||
<button
|
||||
className="planning-thinking-toggle"
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
type="button"
|
||||
>
|
||||
{showThinking ? "Hide thinking" : "Show thinking"}
|
||||
{showThinking ? t("interview.hideThinking", "Hide thinking") : t("interview.showThinking", "Show thinking")}
|
||||
</button>
|
||||
{showThinking && streamingOutput && (
|
||||
<div className="planning-thinking-output">
|
||||
@@ -577,7 +577,7 @@ export function MilestoneSliceInterviewModal({
|
||||
<div className="ai-error-message">{view.errorMessage}</div>
|
||||
<div className="ai-error-actions">
|
||||
<button className="btn" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -610,16 +610,16 @@ export function MilestoneSliceInterviewModal({
|
||||
<div className="planning-view-scroll">
|
||||
<div className="planning-applied-content">
|
||||
<CheckCircle size={48} className="icon-success" />
|
||||
<h4>{targetLabel} Updated</h4>
|
||||
<h4>{t("interview.updated", `${targetLabel} Updated`)}</h4>
|
||||
<p className="text-muted">
|
||||
The {targetType}'s scope and verification have been {view.type === "applied" ? "applied" : "updated"}.
|
||||
{t("interview.appliedMessage", `The ${targetType}'s scope and verification have been applied.`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planning-view-footer">
|
||||
<button className="btn btn-primary" onClick={onApplied}>
|
||||
Done
|
||||
{t("actions.done", "Done")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -640,6 +640,7 @@ interface InterviewQuestionFormProps {
|
||||
}
|
||||
|
||||
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
@@ -704,7 +705,7 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="planning-progress-text">Question {progress} of ~6</span>
|
||||
<span className="planning-progress-text">{t("interview.progressText", `Question ${progress} of ~6`)}</span>
|
||||
</div>
|
||||
|
||||
<div className="planning-question-content">
|
||||
@@ -718,7 +719,7 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={4}
|
||||
placeholder="Type your answer here..."
|
||||
placeholder={t("interview.typeAnswerHere", "Type your answer here...")}
|
||||
value={textValue}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -788,14 +789,14 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
onClick={() => setResponse({ [question.id]: true })}
|
||||
>
|
||||
<CheckCircle size={18} />
|
||||
Yes
|
||||
{t("interview.yes", "Yes")}
|
||||
</button>
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
|
||||
onClick={() => setResponse({ [question.id]: false })}
|
||||
>
|
||||
<X size={18} />
|
||||
No
|
||||
{t("interview.no", "No")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -804,13 +805,13 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
{question.type !== "text" && (
|
||||
<div className="planning-comment-section">
|
||||
<label className="planning-comment-label" htmlFor={`planning-comment-${question.id}`}>
|
||||
Additional comments (optional)
|
||||
{t("interview.additionalComments", "Additional comments (optional)")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`planning-comment-${question.id}`}
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="Add any extra context or direction..."
|
||||
placeholder={t("interview.addContextDirection", "Add any extra context or direction...")}
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.target.value)}
|
||||
/>
|
||||
@@ -826,7 +827,7 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid()}
|
||||
>
|
||||
Continue
|
||||
{t("actions.continue", "Continue")}
|
||||
<ArrowRight size={16} className="icon-ml-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -853,6 +854,7 @@ function SummaryReview({
|
||||
onCancel,
|
||||
isApplying,
|
||||
}: SummaryReviewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [editedSummary, setEditedSummary] = useState<TargetInterviewSummary>(summary);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
@@ -880,7 +882,7 @@ function SummaryReview({
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
<span>Refined Scope</span>
|
||||
<span>{t("interview.refinedScope", "Refined Scope")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -888,7 +890,7 @@ function SummaryReview({
|
||||
<div className="planning-summary-content">
|
||||
{editedSummary.description && (
|
||||
<div className="planning-summary-field">
|
||||
<label>Description</label>
|
||||
<label>{t("interview.description", "Description")}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editedSummary.description}
|
||||
@@ -899,7 +901,7 @@ function SummaryReview({
|
||||
|
||||
{editedSummary.planningNotes && (
|
||||
<div className="planning-summary-field">
|
||||
<label>Planning Notes</label>
|
||||
<label>{t("interview.planningNotes", "Planning Notes")}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editedSummary.planningNotes}
|
||||
@@ -910,7 +912,7 @@ function SummaryReview({
|
||||
|
||||
{editedSummary.verification && (
|
||||
<div className="planning-summary-field">
|
||||
<label>Verification Criteria</label>
|
||||
<label>{t("interview.verificationCriteria", "Verification Criteria")}</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={editedSummary.verification}
|
||||
@@ -923,7 +925,7 @@ function SummaryReview({
|
||||
!editedSummary.planningNotes &&
|
||||
!editedSummary.verification && (
|
||||
<p className="text-muted planning-summary-empty">
|
||||
No additional details were generated for this item.
|
||||
{t("interview.noAdditionalDetails", "No additional details were generated for this item.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -938,10 +940,10 @@ function SummaryReview({
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? <Loader2 size={16} className="spin" /> : null}
|
||||
Apply
|
||||
{t("actions.apply", "Apply")}
|
||||
</button>
|
||||
<button className="btn" onClick={onCancel} disabled={isApplying}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -111,6 +112,7 @@ export function MissionInterviewModal({
|
||||
onSendToBackground,
|
||||
showSendToBackgroundButton = false,
|
||||
}: MissionInterviewModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
useMobileScrollLock(isOpen);
|
||||
const [missionGoal, setMissionGoal] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
@@ -271,7 +273,7 @@ export function MissionInterviewModal({
|
||||
});
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
const errorMessage = message || t("missions.interviewErrorDefault", "Session failed while contacting the AI.");
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
@@ -753,20 +755,20 @@ export function MissionInterviewModal({
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<Target size={20} className="icon-triage" />
|
||||
<h3>Plan Mission with AI</h3>
|
||||
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
|
||||
</div>
|
||||
<div className="modal-header-actions">
|
||||
{canSendToBackground && (
|
||||
<button
|
||||
className="modal-send-to-background"
|
||||
onClick={handleSendToBackground}
|
||||
title="Send to background"
|
||||
aria-label="Send to background"
|
||||
title={t("missions.sendToBackground", "Send to background")}
|
||||
aria-label={t("missions.sendToBackground", "Send to background")}
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={handleClose} aria-label="Close">
|
||||
<button className="modal-close" onClick={handleClose} aria-label={t("actions.close", "Close")}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -774,10 +776,10 @@ export function MissionInterviewModal({
|
||||
|
||||
<div className="planning-modal-body">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">{t("missions.reconnecting", "Reconnecting…")}</div>}
|
||||
{activeInAnotherTab && (
|
||||
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
|
||||
Session is active in another tab.
|
||||
{t("missions.sessionActiveAnother", "Session is active in another tab.")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -786,22 +788,20 @@ export function MissionInterviewModal({
|
||||
<div className="planning-view-scroll">
|
||||
<div className="planning-intro">
|
||||
<Sparkles size={32} className="icon-triage-lg" />
|
||||
<h4>Transform your vision into a structured mission</h4>
|
||||
<h4>{t("missions.transformVision", "Transform your vision into a structured mission")}</h4>
|
||||
<p className="text-muted">
|
||||
Describe what you want to build. The AI will interview you to understand scope,
|
||||
constraints, and requirements, then produce a structured plan with milestones,
|
||||
slices, and features.
|
||||
{t("missions.describeGoal", "Describe what you want to build. The AI will interview you to understand scope, constraints, and requirements, then produce a structured plan with milestones, slices, and features.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="mission-goal">What do you want to build?</label>
|
||||
<label htmlFor="mission-goal">{t("missions.whatToBuild", "What do you want to build?")}</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="mission-goal"
|
||||
rows={4}
|
||||
className="planning-textarea"
|
||||
placeholder="e.g., Build a real-time collaborative document editor with presence, comments, and version history..."
|
||||
placeholder={t("missions.buildExample", "e.g., Build a real-time collaborative document editor with presence, comments, and version history...")}
|
||||
value={missionGoal}
|
||||
onChange={(e) => setMissionGoal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -814,7 +814,7 @@ export function MissionInterviewModal({
|
||||
</div>
|
||||
|
||||
<div className="planning-examples">
|
||||
<span className="planning-examples-label">Try an example:</span>
|
||||
<span className="planning-examples-label">{t("missions.tryExample", "Try an example:")}</span>
|
||||
<div className="planning-example-chips">
|
||||
{EXAMPLE_MISSIONS.map((mission, i) => (
|
||||
<button
|
||||
@@ -830,10 +830,10 @@ export function MissionInterviewModal({
|
||||
|
||||
<div className="planning-model-select-group">
|
||||
<label htmlFor="mission-interview-modal-model" className="form-label">
|
||||
Planning Model
|
||||
{t("missions.planningModel", "Planning Model")}
|
||||
{modelsLoading && (
|
||||
<span className="text-muted text-muted-sm">
|
||||
Loading models…
|
||||
{t("missions.loadingModels", "Loading models…")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
@@ -869,14 +869,14 @@ export function MissionInterviewModal({
|
||||
setFavoriteModels(resp.favoriteModels);
|
||||
setModelsError(null);
|
||||
} catch (err) {
|
||||
setModelsError(getErrorMessage(err) || "Failed to load models");
|
||||
setModelsError(getErrorMessage(err) || t("missions.failedLoadModels", "Failed to load models"));
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
{t("actions.retry", "Retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -901,7 +901,7 @@ export function MissionInterviewModal({
|
||||
disabled={!missionGoal.trim()}
|
||||
>
|
||||
<Target size={16} className="icon-mr-8" />
|
||||
Start Interview
|
||||
{t("missions.startInterview", "Start Interview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -910,14 +910,14 @@ export function MissionInterviewModal({
|
||||
{view.type === "loading" && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin icon-todo" />
|
||||
<p>{streamingOutput ? "AI is thinking..." : "Preparing next question..."}</p>
|
||||
<p>{streamingOutput ? t("missions.aiThinking", "AI is thinking...") : t("missions.prepareQuestion", "Preparing next question...")}</p>
|
||||
<div className="planning-thinking-container">
|
||||
<button
|
||||
className="planning-thinking-toggle"
|
||||
onClick={() => setShowThinking(!showThinking)}
|
||||
type="button"
|
||||
>
|
||||
{showThinking ? "Hide thinking" : "Show thinking"}
|
||||
{showThinking ? t("missions.hideThinking", "Hide thinking") : t("missions.showThinking", "Show thinking")}
|
||||
</button>
|
||||
{showThinking && streamingOutput && (
|
||||
<div className="planning-thinking-output">
|
||||
@@ -947,9 +947,9 @@ export function MissionInterviewModal({
|
||||
<div className="ai-error-actions">
|
||||
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
|
||||
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
<span className="icon-ml-6">{isRetrying ? "Retrying..." : "Retry"}</span>
|
||||
<span className="icon-ml-6">{isRetrying ? t("missions.retrying", "Retrying...") : t("actions.retry", "Retry")}</span>
|
||||
</button>
|
||||
<button className="btn" onClick={handleClose} disabled={isRetrying}>Dismiss</button>
|
||||
<button className="btn" onClick={handleClose} disabled={isRetrying}>{t("missions.dismiss", "Dismiss")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -991,8 +991,8 @@ export function MissionInterviewModal({
|
||||
<Lock size={16} />
|
||||
<span>
|
||||
{allowTakeover
|
||||
? "This session is active in another tab"
|
||||
: "This session is active in another tab (live heartbeat)"}
|
||||
? t("missions.sessionActiveTab", "This session is active in another tab")
|
||||
: t("missions.sessionActiveHeartbeat", "This session is active in another tab (live heartbeat)")}
|
||||
</span>
|
||||
{allowTakeover && (
|
||||
<button
|
||||
@@ -1003,7 +1003,7 @@ export function MissionInterviewModal({
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
{isLockLoading ? t("missions.takingControl", "Taking control...") : t("missions.takeControl", "Take Control")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1025,6 +1025,7 @@ interface InterviewQuestionFormProps {
|
||||
}
|
||||
|
||||
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [response, setResponse] = useState<QuestionResponse>({});
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
@@ -1089,7 +1090,7 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="planning-progress-text">Question {progress} of ~6</span>
|
||||
<span className="planning-progress-text">{t("missions.progressText", "Question {{count}} of ~6", { count: progress })}</span>
|
||||
</div>
|
||||
|
||||
<div className="planning-question-content">
|
||||
@@ -1103,7 +1104,7 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={4}
|
||||
placeholder="Type your answer here..."
|
||||
placeholder={t("missions.typeAnswer", "Type your answer here...")}
|
||||
value={textValue}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -1173,14 +1174,14 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
onClick={() => setResponse({ [question.id]: true })}
|
||||
>
|
||||
<CheckCircle size={18} />
|
||||
Yes
|
||||
{t("actions.yes", "Yes")}
|
||||
</button>
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
|
||||
onClick={() => setResponse({ [question.id]: false })}
|
||||
>
|
||||
<X size={18} />
|
||||
No
|
||||
{t("actions.no", "No")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1189,13 +1190,13 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
{question.type !== "text" && (
|
||||
<div className="planning-comment-section">
|
||||
<label className="planning-comment-label" htmlFor={`planning-comment-${question.id}`}>
|
||||
Additional comments (optional)
|
||||
{t("missions.additionalComments", "Additional comments (optional)")}
|
||||
</label>
|
||||
<textarea
|
||||
id={`planning-comment-${question.id}`}
|
||||
className="planning-textarea"
|
||||
rows={2}
|
||||
placeholder="Add any extra context or direction..."
|
||||
placeholder={t("missions.addContext", "Add any extra context or direction...")}
|
||||
value={commentValue}
|
||||
onChange={(e) => setCommentValue(e.target.value)}
|
||||
/>
|
||||
@@ -1211,7 +1212,7 @@ function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }:
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid()}
|
||||
>
|
||||
Continue
|
||||
{t("actions.continue", "Continue")}
|
||||
<ArrowRight size={16} className="icon-ml-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -1238,6 +1239,7 @@ function MissionPlanReview({
|
||||
onStartOver,
|
||||
isCreating,
|
||||
}: MissionPlanReviewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [expandedMilestones, setExpandedMilestones] = useState<Set<number>>(
|
||||
() => new Set(summary.milestones.map((_, i) => i))
|
||||
);
|
||||
@@ -1346,16 +1348,16 @@ function MissionPlanReview({
|
||||
|
||||
<div className="planning-summary-header">
|
||||
<CheckCircle size={24} className="icon-success" />
|
||||
<h4>Mission Plan Ready</h4>
|
||||
<h4>{t("missions.planReady", "Mission Plan Ready")}</h4>
|
||||
<p className="text-muted">
|
||||
{summary.milestones.length} milestones, {totalFeatures} features. Review and edit before approving.
|
||||
{t("missions.summaryStats", "{{milestones}} milestones, {{features}} features. Review and edit before approving.", { milestones: summary.milestones.length, features: totalFeatures })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="planning-summary-form">
|
||||
{/* Mission title & description */}
|
||||
<div className="form-group">
|
||||
<label>Mission Title</label>
|
||||
<label>{t("missions.titleLabel", "Mission Title")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
@@ -1364,7 +1366,7 @@ function MissionPlanReview({
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Mission Description</label>
|
||||
<label>{t("missions.descriptionLabel", "Mission Description")}</label>
|
||||
<textarea
|
||||
className="planning-textarea"
|
||||
rows={3}
|
||||
@@ -1375,7 +1377,7 @@ function MissionPlanReview({
|
||||
|
||||
{/* Milestones hierarchy */}
|
||||
<div className="form-group">
|
||||
<label>Roadmap</label>
|
||||
<label>{t("missions.roadmapLabel", "Roadmap")}</label>
|
||||
<div className="roadmap-list">
|
||||
{summary.milestones.map((milestone, mi) => (
|
||||
<div
|
||||
@@ -1407,7 +1409,7 @@ function MissionPlanReview({
|
||||
e.stopPropagation();
|
||||
removeMilestone(mi);
|
||||
}}
|
||||
title="Remove milestone"
|
||||
title={t("missions.removeMilestone", "Remove milestone")}
|
||||
>
|
||||
<Trash2 size={14} className="icon-text-secondary" />
|
||||
</button>
|
||||
@@ -1425,12 +1427,12 @@ function MissionPlanReview({
|
||||
/>
|
||||
<div className="roadmap-field-group">
|
||||
<label className="roadmap-field-label">
|
||||
Verification Criteria
|
||||
{t("missions.verificationCriteria", "Verification Criteria")}
|
||||
</label>
|
||||
<textarea
|
||||
className="planning-textarea roadmap-textarea-sm"
|
||||
rows={2}
|
||||
placeholder="How to confirm this milestone is complete..."
|
||||
placeholder={t("missions.confirmMilestonePlaceholder", "How to confirm this milestone is complete...")}
|
||||
value={milestone.verification || ""}
|
||||
onChange={(e) => updateMilestone(mi, { verification: e.target.value })}
|
||||
/>
|
||||
@@ -1468,7 +1470,7 @@ function MissionPlanReview({
|
||||
e.stopPropagation();
|
||||
removeSlice(mi, si);
|
||||
}}
|
||||
title="Remove slice"
|
||||
title={t("missions.removeSlice", "Remove slice")}
|
||||
>
|
||||
<Trash2 size={12} className="icon-text-secondary" />
|
||||
</button>
|
||||
@@ -1480,12 +1482,12 @@ function MissionPlanReview({
|
||||
{/* Slice verification */}
|
||||
<div className="roadmap-slice-field-group">
|
||||
<label className="roadmap-field-label">
|
||||
Slice Verification
|
||||
{t("missions.sliceVerification", "Slice Verification")}
|
||||
</label>
|
||||
<textarea
|
||||
className="planning-textarea roadmap-textarea-xs"
|
||||
rows={1}
|
||||
placeholder="How to confirm this slice is done..."
|
||||
placeholder={t("missions.confirmSlicePlaceholder", "How to confirm this slice is done...")}
|
||||
value={slice.verification || ""}
|
||||
onChange={(e) => updateSlice(mi, si, { verification: e.target.value })}
|
||||
/>
|
||||
@@ -1520,7 +1522,7 @@ function MissionPlanReview({
|
||||
<button
|
||||
className="btn-icon roadmap-shrink"
|
||||
onClick={() => removeFeature(mi, si, fi)}
|
||||
title="Remove feature"
|
||||
title={t("missions.removeFeature", "Remove feature")}
|
||||
>
|
||||
<Trash2 size={12} className="icon-text-secondary" />
|
||||
</button>
|
||||
@@ -1532,7 +1534,7 @@ function MissionPlanReview({
|
||||
onClick={() => addFeature(mi, si)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Feature
|
||||
{t("missions.addFeature", "Add Feature")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1551,7 +1553,7 @@ function MissionPlanReview({
|
||||
<div className="planning-actions planning-summary-actions">
|
||||
<button className="btn" onClick={onStartOver} disabled={isCreating}>
|
||||
<ArrowLeft size={16} className="icon-mr-4" />
|
||||
Start Over
|
||||
{t("missions.startOver", "Start Over")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -1561,12 +1563,12 @@ function MissionPlanReview({
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spin icon-mr-8" />
|
||||
Creating Mission...
|
||||
{t("missions.creatingMission", "Creating Mission...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} className="icon-mr-8" />
|
||||
Approve Plan
|
||||
{t("missions.approvePlan", "Approve Plan")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ import {
|
||||
Workflow,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchScripts } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
@@ -147,6 +148,7 @@ export function MobileNavBar({
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
}: MobileNavBarProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const mode = useViewportMode();
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||
@@ -283,7 +285,7 @@ export function MobileNavBar({
|
||||
ref={navRef}
|
||||
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}${keyboardOpen ? " mobile-nav-bar--keyboard-open" : ""}`}
|
||||
role="tablist"
|
||||
aria-label="Primary navigation"
|
||||
aria-label={t("nav.primaryNavAriaLabel", "Primary navigation")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -301,7 +303,7 @@ export function MobileNavBar({
|
||||
}}
|
||||
>
|
||||
<LayoutGrid />
|
||||
<span className="mobile-nav-tab-label">Tasks</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.tasks", "Tasks")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -313,7 +315,7 @@ export function MobileNavBar({
|
||||
onClick={() => onChangeView("agents")}
|
||||
>
|
||||
<Bot />
|
||||
<span className="mobile-nav-tab-label">Agents</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.agents", "Agents")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -325,7 +327,7 @@ export function MobileNavBar({
|
||||
onClick={() => onChangeView("missions")}
|
||||
>
|
||||
<Target />
|
||||
<span className="mobile-nav-tab-label">Missions</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.missions", "Missions")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -339,10 +341,10 @@ export function MobileNavBar({
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<MessageSquare />
|
||||
{chatHasUnreadResponse && view !== "chat" && (
|
||||
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label="Unread chat response" />
|
||||
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={t("nav.chatUnreadAriaLabel", "Unread chat response")} />
|
||||
)}
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">Chat</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.chat", "Chat")}</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -357,10 +359,10 @@ export function MobileNavBar({
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<Mail />
|
||||
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
|
||||
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label="Pending approvals" />
|
||||
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label={t("nav.mailboxPendingAriaLabel", "Pending approvals")} />
|
||||
)}
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">Mailbox</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.mailbox", "Mailbox")}</span>
|
||||
{mailboxUnreadCount > 0 && (
|
||||
<span className="mobile-nav-tab-badge">{formatCount(mailboxUnreadCount)}</span>
|
||||
)}
|
||||
@@ -376,7 +378,7 @@ export function MobileNavBar({
|
||||
onClick={() => onChangeView("skills")}
|
||||
>
|
||||
<Zap />
|
||||
<span className="mobile-nav-tab-label">Skills</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.skills", "Skills")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -409,7 +411,7 @@ export function MobileNavBar({
|
||||
onClick={() => setIsMoreOpen((prev) => !prev)}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="mobile-nav-tab-label">More</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.more", "More")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -421,7 +423,7 @@ export function MobileNavBar({
|
||||
/>
|
||||
<div className="mobile-more-sheet">
|
||||
<div className="mobile-more-sheet-handle" />
|
||||
<div className="mobile-more-sheet-title">Navigate</div>
|
||||
<div className="mobile-more-sheet-title">{t("nav.moreSheetTitle", "Navigate")}</div>
|
||||
|
||||
{shellConnectionControl ? (
|
||||
<div className="mobile-more-shell-connection" data-testid="mobile-more-shell-connection">
|
||||
@@ -436,7 +438,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenMailbox)}
|
||||
>
|
||||
<Mail />
|
||||
<span>Mailbox</span>
|
||||
<span>{t("nav.mailbox", "Mailbox")}</span>
|
||||
{mailboxUnreadCount > 0 && (
|
||||
<span className="mobile-more-item-badge mobile-more-item-badge--unread">{formatCount(mailboxUnreadCount)}</span>
|
||||
)}
|
||||
@@ -452,7 +454,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenActivityLog)}
|
||||
>
|
||||
<Activity />
|
||||
<span>Activity Log</span>
|
||||
<span>{t("nav.activityLog", "Activity Log")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -462,7 +464,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenSystemStats)}
|
||||
>
|
||||
<Monitor />
|
||||
<span>System Stats</span>
|
||||
<span>{t("nav.systemStats", "System Stats")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -472,7 +474,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenGitManager)}
|
||||
>
|
||||
<GitBranch />
|
||||
<span>Git Manager</span>
|
||||
<span>{t("nav.gitManager", "Git Manager")}</span>
|
||||
</button>
|
||||
|
||||
<div className="mobile-more-split-row">
|
||||
@@ -483,7 +485,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onToggleTerminal)}
|
||||
>
|
||||
<Terminal />
|
||||
<span>Terminal</span>
|
||||
<span>{t("nav.terminal", "Terminal")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -492,7 +494,7 @@ export function MobileNavBar({
|
||||
onClick={() => setIsScriptsSubmenuOpen((prev) => !prev)}
|
||||
aria-expanded={isScriptsSubmenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Show scripts"
|
||||
aria-label={t("nav.showScriptsAriaLabel", "Show scripts")}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
@@ -501,11 +503,11 @@ export function MobileNavBar({
|
||||
</button>
|
||||
</div>
|
||||
{isScriptsSubmenuOpen && (
|
||||
<div className="mobile-more-submenu" role="menu" aria-label="Scripts submenu">
|
||||
<div className="mobile-more-submenu" role="menu" aria-label={t("nav.scriptsSubmenuAriaLabel", "Scripts submenu")}>
|
||||
{scriptsLoading ? (
|
||||
<div className="mobile-more-submenu-loading" data-testid="mobile-more-scripts-loading">
|
||||
<Loader2 className="animate-spin" />
|
||||
<span>Loading scripts…</span>
|
||||
<span>{t("nav.loadingScripts", "Loading scripts…")}</span>
|
||||
</div>
|
||||
) : scriptEntries.length > 0 ? (
|
||||
<>
|
||||
@@ -537,7 +539,7 @@ export function MobileNavBar({
|
||||
}}
|
||||
>
|
||||
<FileCode />
|
||||
<span>Manage Scripts…</span>
|
||||
<span>{t("nav.manageScripts", "Manage Scripts…")}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -554,7 +556,7 @@ export function MobileNavBar({
|
||||
}}
|
||||
>
|
||||
<FileCode />
|
||||
<span>No scripts — add one…</span>
|
||||
<span>{t("nav.noScriptsAddOne", "No scripts — add one…")}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
@@ -568,7 +570,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenFiles)}
|
||||
>
|
||||
<Folder />
|
||||
<span>Files</span>
|
||||
<span>{t("nav.files", "Files")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -578,7 +580,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(planningHandler)}
|
||||
>
|
||||
<Lightbulb />
|
||||
<span>Planning</span>
|
||||
<span>{t("nav.planning", "Planning")}</span>
|
||||
{activePlanningSessionCount > 0 && (
|
||||
<span className="mobile-more-item-badge">{formatCount(activePlanningSessionCount)}</span>
|
||||
)}
|
||||
@@ -591,7 +593,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenWorkflowSteps)}
|
||||
>
|
||||
<Workflow />
|
||||
<span>Workflow Steps</span>
|
||||
<span>{t("nav.workflowSteps", "Workflow Steps")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -601,7 +603,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenSchedules)}
|
||||
>
|
||||
<Clock />
|
||||
<span>Automation</span>
|
||||
<span>{t("nav.automation", "Automation")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -611,7 +613,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenGitHubImport)}
|
||||
>
|
||||
<GitHubLogo />
|
||||
<span>Import from GitHub</span>
|
||||
<span>{t("nav.importFromGitHub", "Import from GitHub")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -621,7 +623,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenUsage)}
|
||||
>
|
||||
<Activity />
|
||||
<span>Usage</span>
|
||||
<span>{t("nav.usage", "Usage")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -631,7 +633,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onViewAllProjects)}
|
||||
>
|
||||
<Grid3X3 />
|
||||
<span>Projects</span>
|
||||
<span>{t("nav.projects", "Projects")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -641,7 +643,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("documents"))}
|
||||
>
|
||||
<FileText />
|
||||
<span>Documents</span>
|
||||
<span>{t("nav.documents", "Documents")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -651,7 +653,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("reliability"))}
|
||||
>
|
||||
<Activity />
|
||||
<span>Reliability</span>
|
||||
<span>{t("nav.reliability", "Reliability")}</span>
|
||||
</button>
|
||||
{experimentalFeatures?.evalsView && (
|
||||
<button
|
||||
@@ -661,7 +663,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("evals"))}
|
||||
>
|
||||
<Target />
|
||||
<span>Evals</span>
|
||||
<span>{t("nav.evals", "Evals")}</span>
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.goalsView && (
|
||||
@@ -672,7 +674,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("goalsView"))}
|
||||
>
|
||||
<Target />
|
||||
<span>Goals</span>
|
||||
<span>{t("nav.goals", "Goals")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -684,7 +686,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("skills"))}
|
||||
>
|
||||
<Zap />
|
||||
<span>Skills</span>
|
||||
<span>{t("nav.skills", "Skills")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -697,7 +699,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("stash-recovery"))}
|
||||
>
|
||||
<History />
|
||||
<span>Stash Recovery</span>
|
||||
<span>{t("nav.stashRecovery", "Stash Recovery")}</span>
|
||||
{stashOrphanCount > 0 ? <span className="mobile-more-item-badge">{formatCount(stashOrphanCount)}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -709,7 +711,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("research"))}
|
||||
>
|
||||
<Search />
|
||||
<span>Research</span>
|
||||
<span>{t("nav.research", "Research")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -721,7 +723,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("insights"))}
|
||||
>
|
||||
<Sparkles />
|
||||
<span>Insights</span>
|
||||
<span>{t("nav.insights", "Insights")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -733,7 +735,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("memory"))}
|
||||
>
|
||||
<Brain />
|
||||
<span>Memory</span>
|
||||
<span>{t("nav.memory", "Memory")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -744,7 +746,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onChangeView("secrets"))}
|
||||
>
|
||||
<Lock />
|
||||
<span>Secrets</span>
|
||||
<span>{t("nav.secrets", "Secrets")}</span>
|
||||
</button>
|
||||
|
||||
{experimentalFeatures?.devServerView && (
|
||||
@@ -757,7 +759,7 @@ export function MobileNavBar({
|
||||
}}
|
||||
>
|
||||
<Monitor />
|
||||
<span>Dev Server</span>
|
||||
<span>{t("nav.devServer", "Dev Server")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -769,7 +771,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenNodes)}
|
||||
>
|
||||
<Network />
|
||||
<span>Nodes</span>
|
||||
<span>{t("nav.nodes", "Nodes")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -781,7 +783,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(() => onOpenTodos?.())}
|
||||
>
|
||||
<CheckSquare />
|
||||
<span>Todos</span>
|
||||
<span>{t("nav.todos", "Todos")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -811,7 +813,7 @@ export function MobileNavBar({
|
||||
onClick={() => handleMoreAction(onOpenSettings)}
|
||||
>
|
||||
<Settings />
|
||||
<span>Settings</span>
|
||||
<span>{t("nav.settings", "Settings")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ModelPreset } from "@fusion/core";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
@@ -57,6 +58,7 @@ export function ModelSelectionModal({
|
||||
selectedPresetId,
|
||||
onPresetChange,
|
||||
}: ModelSelectionModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -142,9 +144,9 @@ export function ModelSelectionModal({
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<Brain size={20} style={{ color: "var(--todo)" }} />
|
||||
<h3>Select Models</h3>
|
||||
<h3>{t("modelSelection.title", "Select Models")}</h3>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close" data-testid="model-selection-close">
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")} data-testid="model-selection-close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -153,7 +155,7 @@ export function ModelSelectionModal({
|
||||
{modelsLoading ? (
|
||||
<div className="planning-loading">
|
||||
<div className="detail-section">
|
||||
<p className="text-muted">Loading models…</p>
|
||||
<p className="text-muted">{t("modelSelection.loading", "Loading models…")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
@@ -162,20 +164,20 @@ export function ModelSelectionModal({
|
||||
<span>{modelsError}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={onRetry} data-testid="model-selection-retry">
|
||||
Retry
|
||||
{t("actions.retry", "Retry")}
|
||||
</button>
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="detail-section">
|
||||
<div className="inline-create-model-empty">
|
||||
No models available. Configure authentication in Settings to enable model selection.
|
||||
{t("modelSelection.noModels", "No models available. Configure authentication in Settings to enable model selection.")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
<div className="planning-summary-header">
|
||||
<p className="text-muted">Choose models for this task. If not selected, default models will be used.</p>
|
||||
<p className="text-muted">{t("modelSelection.choose", "Choose models for this task. If not selected, default models will be used.")}</p>
|
||||
</div>
|
||||
|
||||
<div className="planning-summary-form">
|
||||
@@ -183,13 +185,13 @@ export function ModelSelectionModal({
|
||||
<div className="task-detail-section">
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="model-selection-preset" className="inline-create-model-label">
|
||||
Preset
|
||||
{t("modelSelection.preset", "Preset")}
|
||||
</label>
|
||||
<span
|
||||
className={`model-badge ${selectedPresetId ? "model-badge-custom" : "model-badge-default"}`}
|
||||
data-testid="preset-badge"
|
||||
>
|
||||
{selectedPreset ? selectedPreset.name : "Use default"}
|
||||
{selectedPreset ? selectedPreset.name : t("modelSelection.useDefault", "Use default")}
|
||||
</span>
|
||||
<select
|
||||
id="model-selection-preset"
|
||||
@@ -197,12 +199,12 @@ export function ModelSelectionModal({
|
||||
onChange={(e) => handlePresetSelect(e.target.value)}
|
||||
data-testid="model-selection-preset"
|
||||
>
|
||||
<option value="default">Use default</option>
|
||||
<option value="default">{t("modelSelection.useDefault", "Use default")}</option>
|
||||
{presets!.length > 0 && <option disabled>──────────</option>}
|
||||
{presets!.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Custom</option>
|
||||
<option value="custom">{t("modelSelection.custom", "Custom")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,7 +266,7 @@ export function ModelSelectionModal({
|
||||
|
||||
<div className="planning-actions planning-summary-actions">
|
||||
<button className="btn" onClick={onClose} data-testid="model-selection-done">
|
||||
Done
|
||||
{t("actions.done", "Done")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./ModelSelectorTab.css";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateTask } from "../api";
|
||||
import type { Settings, Task, TaskDetail } from "@fusion/core";
|
||||
import {
|
||||
@@ -110,6 +111,7 @@ function getSuccessToastMessage(target: "executor" | "validator" | "planning", s
|
||||
}
|
||||
|
||||
export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: ModelSelectorTabProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const {
|
||||
availableModels,
|
||||
favoriteProviders,
|
||||
@@ -135,17 +137,17 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
try {
|
||||
await toggleFavoriteProvider(provider);
|
||||
} catch {
|
||||
addToast("Failed to update favorites", "error");
|
||||
addToast(t("models.errors.failedUpdateFavorites", "Failed to update favorites"), "error");
|
||||
}
|
||||
}, [toggleFavoriteProvider, addToast]);
|
||||
}, [toggleFavoriteProvider, addToast, t]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
try {
|
||||
await toggleFavoriteModel(modelId);
|
||||
} catch {
|
||||
addToast("Failed to update model favorites", "error");
|
||||
addToast(t("models.errors.failedUpdateModelFavorites", "Failed to update model favorites"), "error");
|
||||
}
|
||||
}, [toggleFavoriteModel, addToast]);
|
||||
}, [toggleFavoriteModel, addToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
activeTaskIdRef.current = task.id;
|
||||
@@ -249,7 +251,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
setSelectedPlanning(previousSavedPlanning);
|
||||
}
|
||||
|
||||
addToast(getErrorMessage(err) || "Failed to save model settings", "error");
|
||||
addToast(getErrorMessage(err) || t("models.errors.failedSaveSettings", "Failed to save model settings"), "error");
|
||||
} finally {
|
||||
if (activeTaskIdRef.current === requestTaskId) {
|
||||
setSavingTarget(null);
|
||||
@@ -328,12 +330,12 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
const effectiveDefault = settings?.defaultThinkingLevel ?? "off";
|
||||
if (nextThinking === null) {
|
||||
addToast(
|
||||
`Thinking level set to default (${effectiveDefault})`,
|
||||
t("models.messages.thinkingLevelSetDefault", "Thinking level set to default ({{level}})", { level: effectiveDefault }),
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
addToast(
|
||||
`Thinking level set to ${nextThinking}`,
|
||||
t("models.messages.thinkingLevelSet", "Thinking level set to {{level}}", { level: nextThinking }),
|
||||
"success",
|
||||
);
|
||||
}
|
||||
@@ -343,14 +345,14 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
}
|
||||
|
||||
setSelectedThinking(previousThinking);
|
||||
addToast(getErrorMessage(err) || "Failed to save thinking level", "error");
|
||||
addToast(getErrorMessage(err) || t("models.errors.failedSaveThinking", "Failed to save thinking level"), "error");
|
||||
} finally {
|
||||
if (activeTaskIdRef.current === requestTaskId) {
|
||||
setSavingTarget(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[task.id, savedThinking, settings, addToast, onTaskUpdated],
|
||||
[task.id, savedThinking, settings, addToast, onTaskUpdated, t],
|
||||
);
|
||||
|
||||
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;
|
||||
@@ -359,25 +361,25 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
|
||||
return (
|
||||
<div className="model-selector-tab">
|
||||
<h4>Model Configuration</h4>
|
||||
<h4>{t("models.titles.configuration", "Model Configuration")}</h4>
|
||||
<p className="model-selector-intro">
|
||||
Override the AI models used for this task. When not specified, project or global defaults are used.
|
||||
{t("models.descriptions.override", "Override the AI models used for this task. When not specified, project or global defaults are used.")}
|
||||
</p>
|
||||
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading available models…</div>
|
||||
<div className="model-selector-loading">{t("models.states.loading", "Loading available models…")}</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<div className="model-selector-empty">
|
||||
No models available. Configure authentication in Settings to enable model selection.
|
||||
{t("models.emptyStates.noModels", "No models available. Configure authentication in Settings to enable model selection.")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="executorModel">Executor Model</label>
|
||||
<label htmlFor="executorModel">{t("models.labels.executorModel", "Executor Model")}</label>
|
||||
<div className="model-selector-current">
|
||||
{executorUsingDefault ? (
|
||||
<span className="model-badge model-badge-default">
|
||||
Using default{effectiveExecutor.provider && effectiveExecutor.modelId ? ` (${effectiveExecutor.provider}/${effectiveExecutor.modelId})` : ""}
|
||||
{t("models.states.usingDefault", "Using default")}{effectiveExecutor.provider && effectiveExecutor.modelId ? ` (${effectiveExecutor.provider}/${effectiveExecutor.modelId})` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
@@ -388,26 +390,26 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
</div>
|
||||
<CustomModelDropdown
|
||||
id="executorModel"
|
||||
label="Executor Model"
|
||||
label={t("models.labels.executorModel", "Executor Model")}
|
||||
value={executorValue}
|
||||
onChange={handleExecutorChange}
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
placeholder="Select executor model…"
|
||||
placeholder={t("models.placeholders.selectExecutor", "Select executor model…")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
<small>The AI model used to implement this task.</small>
|
||||
<small>{t("models.descriptions.executor", "The AI model used to implement this task.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="validatorModel">Reviewer Model</label>
|
||||
<label htmlFor="validatorModel">{t("models.labels.reviewerModel", "Reviewer Model")}</label>
|
||||
<div className="model-selector-current">
|
||||
{validatorUsingDefault ? (
|
||||
<span className="model-badge model-badge-default">
|
||||
Using default{effectiveValidator.provider && effectiveValidator.modelId ? ` (${effectiveValidator.provider}/${effectiveValidator.modelId})` : ""}
|
||||
{t("models.states.usingDefault", "Using default")}{effectiveValidator.provider && effectiveValidator.modelId ? ` (${effectiveValidator.provider}/${effectiveValidator.modelId})` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
@@ -418,26 +420,26 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
</div>
|
||||
<CustomModelDropdown
|
||||
id="validatorModel"
|
||||
label="Reviewer Model"
|
||||
label={t("models.labels.reviewerModel", "Reviewer Model")}
|
||||
value={validatorValue}
|
||||
onChange={handleValidatorChange}
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
placeholder="Select reviewer model…"
|
||||
placeholder={t("models.placeholders.selectReviewer", "Select reviewer model…")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
<small>The AI model used to review code and plans for this task.</small>
|
||||
<small>{t("models.descriptions.reviewer", "The AI model used to review code and plans for this task.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="planningModel">Planning Model</label>
|
||||
<label htmlFor="planningModel">{t("models.labels.planningModel", "Planning Model")}</label>
|
||||
<div className="model-selector-current">
|
||||
{planningUsingDefault ? (
|
||||
<span className="model-badge model-badge-default">
|
||||
Using default{effectivePlanning.provider && effectivePlanning.modelId ? ` (${effectivePlanning.provider}/${effectivePlanning.modelId})` : ""}
|
||||
{t("models.states.usingDefault", "Using default")}{effectivePlanning.provider && effectivePlanning.modelId ? ` (${effectivePlanning.provider}/${effectivePlanning.modelId})` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
@@ -448,26 +450,26 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
</div>
|
||||
<CustomModelDropdown
|
||||
id="planningModel"
|
||||
label="Planning Model"
|
||||
label={t("models.labels.planningModel", "Planning Model")}
|
||||
value={planningValue}
|
||||
onChange={handlePlanningChange}
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
placeholder="Select planning model…"
|
||||
placeholder={t("models.placeholders.selectPlanning", "Select planning model…")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
<small>The AI model used for task specification (triage phase).</small>
|
||||
<small>{t("models.descriptions.planning", "The AI model used for task specification (triage phase).")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="thinkingLevel">Thinking Level</label>
|
||||
<label htmlFor="thinkingLevel">{t("models.labels.thinkingLevel", "Thinking Level")}</label>
|
||||
<div className="model-selector-current">
|
||||
{savedThinking === null ? (
|
||||
<span className="model-badge model-badge-default">
|
||||
Using default ({settings?.defaultThinkingLevel ?? "off"})
|
||||
{t("models.states.usingDefault", "Using default")} ({settings?.defaultThinkingLevel ?? "off"})
|
||||
</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
@@ -482,20 +484,20 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
|
||||
disabled={isSaving}
|
||||
className="thinking-level-select"
|
||||
>
|
||||
<option value="">Default ({settings?.defaultThinkingLevel ?? "off"})</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="">{t("models.options.default", "Default")} ({settings?.defaultThinkingLevel ?? "off"})</option>
|
||||
<option value="off">{t("models.options.off", "Off")}</option>
|
||||
<option value="minimal">{t("models.options.minimal", "Minimal")}</option>
|
||||
<option value="low">{t("models.options.low", "Low")}</option>
|
||||
<option value="medium">{t("models.options.medium", "Medium")}</option>
|
||||
<option value="high">{t("models.options.high", "High")}</option>
|
||||
</select>
|
||||
<small>Controls the reasoning effort for the AI agent. Higher levels use more tokens.</small>
|
||||
<small>{t("models.descriptions.thinkingLevel", "Controls the reasoning effort for the AI agent. Higher levels use more tokens.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="model-selector-status">
|
||||
{executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === null
|
||||
? "Using project or global default models."
|
||||
: "Model settings are up to date."}
|
||||
? t("models.messages.usingDefaults", "Using project or global default models.")
|
||||
: t("models.messages.upToDate", "Model settings are up to date.")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FusionShellApi, ShellConnectionProfile, ShellConnectionState } from "../types/native-shell";
|
||||
import "./NativeShellConnectionManager.css";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface NativeShellConnectionManagerProps {
|
||||
}
|
||||
|
||||
export function NativeShellConnectionManager({ open, shellApi, shellState, onClose }: NativeShellConnectionManagerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const activeProfile = useMemo(
|
||||
() => shellState.profiles.find((profile) => profile.id === shellState.activeProfileId) ?? null,
|
||||
[shellState.activeProfileId, shellState.profiles],
|
||||
@@ -82,25 +84,25 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open">
|
||||
<div className="modal native-shell-connection-manager" role="dialog" aria-label="Connection Manager">
|
||||
<div className="modal native-shell-connection-manager" role="dialog" aria-label={t("shell.connectionManagerLabel", "Connection Manager")}>
|
||||
<div className="modal-header">
|
||||
<h2>Connection Manager</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<h2>{t("shell.connectionManager", "Connection Manager")}</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.close", "Close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shellState.host === "desktop-shell" && (
|
||||
<div className="native-shell-connection-manager__mode-row">
|
||||
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>Local</button>
|
||||
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>Remote</button>
|
||||
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>{t("shell.modeLocal", "Local")}</button>
|
||||
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>{t("shell.modeRemote", "Remote")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="native-shell-connection-manager__profiles">
|
||||
{shellState.profiles.length === 0 ? (
|
||||
<div className="card native-shell-connection-manager__empty-state">
|
||||
<p className="settings-muted">No remote servers saved yet.</p>
|
||||
<p className="settings-muted">{t("shell.noServersSaved", "No remote servers saved yet.")}</p>
|
||||
<div className="native-shell-connection-manager__profile-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -111,11 +113,11 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Add server
|
||||
{t("shell.addServer", "Add server")}
|
||||
</button>
|
||||
{shellState.host === "mobile-shell" && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleScanQr()}>
|
||||
Scan QR
|
||||
{t("shell.scanQr", "Scan QR")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -126,22 +128,22 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
<div>
|
||||
<strong>{profile.name}</strong>
|
||||
<div className="settings-muted">{profile.serverUrl}</div>
|
||||
{profile.id === shellState.activeProfileId && <span className="native-shell-connection-manager__active-pill">Active</span>}
|
||||
{profile.id === shellState.activeProfileId && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
|
||||
</div>
|
||||
<div className="native-shell-connection-manager__profile-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
aria-label={`Edit ${profile.name}`}
|
||||
aria-label={t("shell.editProfile", "Edit {{name}}", { name: profile.name })}
|
||||
onClick={() => {
|
||||
setEditingProfileId(profile.id);
|
||||
setDraft(profile);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
{t("actions.edit", "Edit")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" aria-label={`Use ${profile.name}`} onClick={() => void shellApi.setActiveProfile(profile.id)}>Use</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" aria-label={`Delete ${profile.name}`} onClick={() => setDeleteCandidate(profile)}>Delete</button>
|
||||
<button type="button" className="btn btn-sm" aria-label={t("shell.useProfile", "Use {{name}}", { name: profile.name })} onClick={() => void shellApi.setActiveProfile(profile.id)}>{t("shell.use", "Use")}</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" aria-label={t("shell.deleteProfile", "Delete {{name}}", { name: profile.name })} onClick={() => setDeleteCandidate(profile)}>{t("actions.delete", "Delete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -158,39 +160,39 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Add connection
|
||||
{t("shell.addConnection", "Add connection")}
|
||||
</button>
|
||||
{shellState.host === "mobile-shell" && (
|
||||
<button type="button" className="btn" onClick={() => void handleScanQr()}>
|
||||
Scan QR
|
||||
{t("shell.scanQr", "Scan QR")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group native-shell-connection-manager__editor">
|
||||
<label htmlFor="native-shell-connection-manager-name">Name</label>
|
||||
<label htmlFor="native-shell-connection-manager-name">{t("shell.nameLabel", "Name")}</label>
|
||||
<input id="native-shell-connection-manager-name" className="input" value={workingName} onChange={(event) => setDraft((value) => ({ ...value, name: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-url">Server URL</label>
|
||||
<label htmlFor="native-shell-connection-manager-url">{t("shell.serverUrlLabel", "Server URL")}</label>
|
||||
<input id="native-shell-connection-manager-url" className="input" value={workingUrl} onChange={(event) => setDraft((value) => ({ ...value, serverUrl: event.target.value }))} />
|
||||
<label htmlFor="native-shell-connection-manager-token">Auth token (optional)</label>
|
||||
<label htmlFor="native-shell-connection-manager-token">{t("shell.authTokenLabel", "Auth token (optional)")}</label>
|
||||
<input id="native-shell-connection-manager-token" className="input" type="password" value={workingToken ?? ""} onChange={(event) => setDraft((value) => ({ ...value, authToken: event.target.value }))} />
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
|
||||
{deleteCandidate && (
|
||||
<div className="native-shell-connection-manager__delete-confirm" role="alertdialog" aria-label="Delete server confirmation">
|
||||
<p>Delete <strong>{deleteCandidate.name}</strong>? This removes the saved profile.</p>
|
||||
<div className="native-shell-connection-manager__delete-confirm" role="alertdialog" aria-label={t("shell.deleteConfirmLabel", "Delete server confirmation")}>
|
||||
<p>{t("shell.deleteConfirmMessage", "Delete {{name}}? This removes the saved profile.", { name: deleteCandidate.name })}</p>
|
||||
<div className="native-shell-connection-manager__profile-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setDeleteCandidate(null)}>Cancel</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => void handleConfirmDelete()}>Delete</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setDeleteCandidate(null)}>{t("actions.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => void handleConfirmDelete()}>{t("actions.delete", "Delete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>Close</button>
|
||||
<button type="button" className="btn" onClick={resetEditor}>Cancel</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>Save</button>
|
||||
<button type="button" className="btn" onClick={onClose}>{t("actions.close", "Close")}</button>
|
||||
<button type="button" className="btn" onClick={resetEditor}>{t("actions.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void saveCurrent()} disabled={!workingUrl.trim()}>{t("actions.save", "Save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FusionShellApi, ShellConnectionState } from "../types/native-shell";
|
||||
import "./NativeShellOnboardingModal.css";
|
||||
|
||||
@@ -18,8 +19,9 @@ interface NativeShellOnboardingModalProps {
|
||||
}
|
||||
|
||||
export function NativeShellOnboardingModal({ open, shellApi, shellState, onComplete }: NativeShellOnboardingModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [mode, setMode] = useState<"local" | "remote">(shellState.desktopMode ?? "remote");
|
||||
const [name, setName] = useState("Remote Server");
|
||||
const [name, setName] = useState(t("onboarding.defaultName", "Remote Server"));
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -46,14 +48,14 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
<div className="modal-overlay open">
|
||||
<div className="modal native-shell-onboarding-modal">
|
||||
<div className="modal-header">
|
||||
<h2>Welcome to Fusion</h2>
|
||||
<h2>{t("onboarding.welcome", "Welcome to Fusion")}</h2>
|
||||
</div>
|
||||
<div className="native-shell-onboarding-body">
|
||||
<p>Fusion helps you plan, run, and review AI-assisted engineering work.</p>
|
||||
<p>{t("onboarding.description", "Fusion helps you plan, run, and review AI-assisted engineering work.")}</p>
|
||||
{isDesktop && (
|
||||
<div className="native-shell-onboarding-mode-row">
|
||||
<button type="button" className={`btn ${mode === "local" ? "btn-primary" : ""}`} onClick={() => setMode("local")}>Local Fusion</button>
|
||||
<button type="button" className={`btn ${mode === "remote" ? "btn-primary" : ""}`} onClick={() => setMode("remote")}>Remote Server</button>
|
||||
<button type="button" className={`btn ${mode === "local" ? "btn-primary" : ""}`} onClick={() => setMode("local")}>{t("onboarding.localFusion", "Local Fusion")}</button>
|
||||
<button type="button" className={`btn ${mode === "remote" ? "btn-primary" : ""}`} onClick={() => setMode("remote")}>{t("onboarding.remoteServer", "Remote Server")}</button>
|
||||
</div>
|
||||
)}
|
||||
{(!isDesktop || mode === "remote") && (
|
||||
@@ -76,13 +78,13 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
}}
|
||||
disabled={scanning}
|
||||
>
|
||||
{scanning ? "Scanning…" : "Scan QR"}
|
||||
{scanning ? t("onboarding.scanning", "Scanning…") : t("onboarding.scanQr", "Scan QR")}
|
||||
</button>
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-profile-name">Profile name</label>
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-profile-name">{t("onboarding.profileName", "Profile name")}</label>
|
||||
<input id="native-shell-onboarding-profile-name" className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-server-url">Server URL</label>
|
||||
<input id="native-shell-onboarding-server-url" className="input" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder="https://your-fusion-host" />
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-auth-token">Auth token (optional)</label>
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-server-url">{t("onboarding.serverUrl", "Server URL")}</label>
|
||||
<input id="native-shell-onboarding-server-url" className="input" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} placeholder={t("onboarding.serverUrlPlaceholder", "https://your-fusion-host")} />
|
||||
<label className="native-shell-onboarding-label" htmlFor="native-shell-onboarding-auth-token">{t("onboarding.authToken", "Auth token (optional)")}</label>
|
||||
<input id="native-shell-onboarding-auth-token" className="input" type="password" value={authToken} onChange={(event) => setAuthToken(event.target.value)} />
|
||||
</>
|
||||
)}
|
||||
@@ -104,7 +106,7 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
}
|
||||
|
||||
const saved = await shellApi.saveProfile({
|
||||
name: name.trim() || "Remote Server",
|
||||
name: name.trim() || t("onboarding.defaultName", "Remote Server"),
|
||||
serverUrl,
|
||||
authToken: authToken || null,
|
||||
});
|
||||
@@ -127,7 +129,7 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
|
||||
}
|
||||
}}
|
||||
>
|
||||
{submitting ? "Saving…" : "Continue"}
|
||||
{submitting ? t("onboarding.saving", "Saving…") : t("onboarding.continue", "Continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./NewAgentDialog.css";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Agent, AgentCapability, ModelInfo, AgentGenerationSpec, PluginRuntimeInfo, AgentOnboardingSummary } from "../api";
|
||||
import { createAgent, fetchAgents, fetchModels } from "../api";
|
||||
import * as apiModule from "../api";
|
||||
@@ -57,6 +58,7 @@ export function NewAgentDialog({
|
||||
existingAgents = [],
|
||||
onPrefillDraft,
|
||||
}: NewAgentDialogProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [step, setStep] = useState(0);
|
||||
const [stepZeroTab, setStepZeroTab] = useState<StepZeroTab>("presets");
|
||||
const [name, setName] = useState("");
|
||||
@@ -291,7 +293,7 @@ export function NewAgentDialog({
|
||||
handleClose();
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
setError(err instanceof Error ? err.message : t("agents.createError", "Failed to create agent"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -309,7 +311,7 @@ export function NewAgentDialog({
|
||||
const renderRuntimeSourceSection = (sourceLabelId: string) => (
|
||||
<>
|
||||
<div className="agent-dialog-field">
|
||||
<label id={sourceLabelId}>Runtime Source</label>
|
||||
<label id={sourceLabelId}>{t("agents.runtimeSource", "Runtime Source")}</label>
|
||||
<div className="agent-runtime-mode-toggle" role="radiogroup" aria-labelledby={sourceLabelId}>
|
||||
<label className={`agent-runtime-mode-option${runtimeMode === "model" ? " agent-runtime-mode-option--active" : ""}`}>
|
||||
<input
|
||||
@@ -319,7 +321,7 @@ export function NewAgentDialog({
|
||||
checked={runtimeMode === "model"}
|
||||
onChange={() => handleRuntimeModeChange("model")}
|
||||
/>
|
||||
<span>Built-in Model</span>
|
||||
<span>{t("agents.runtimeSourceBuiltIn", "Built-in Model")}</span>
|
||||
</label>
|
||||
<label className={`agent-runtime-mode-option${runtimeMode === "runtime" ? " agent-runtime-mode-option--active" : ""}`}>
|
||||
<input
|
||||
@@ -329,23 +331,23 @@ export function NewAgentDialog({
|
||||
checked={runtimeMode === "runtime"}
|
||||
onChange={() => handleRuntimeModeChange("runtime")}
|
||||
/>
|
||||
<span>Plugin Runtime</span>
|
||||
<span>{t("agents.runtimeSourcePlugin", "Plugin Runtime")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{runtimeMode === "model" ? (
|
||||
<div className="agent-dialog-field">
|
||||
<label>Model</label>
|
||||
<label>{t("agents.model", "Model")}</label>
|
||||
{modelsLoading ? (
|
||||
<div className="agent-dialog-loading">Loading models…</div>
|
||||
<div className="agent-dialog-loading">{t("agents.loadingModels", "Loading models…")}</div>
|
||||
) : (
|
||||
<CustomModelDropdown
|
||||
id="agent-model"
|
||||
label="Model"
|
||||
label={t("agents.model", "Model")}
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
models={availableModels}
|
||||
placeholder="Select a model…"
|
||||
placeholder={t("agents.modelPlaceholder", "Select a model…")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={toggleFavoriteProvider}
|
||||
favoriteModels={favoriteModels}
|
||||
@@ -355,9 +357,9 @@ export function NewAgentDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-runtime-hint">Runtime</label>
|
||||
<label htmlFor="agent-runtime-hint">{t("agents.runtime", "Runtime")}</label>
|
||||
{runtimesLoading ? (
|
||||
<div className="agent-dialog-loading">Loading runtimes…</div>
|
||||
<div className="agent-dialog-loading">{t("agents.loadingRuntimes", "Loading runtimes…")}</div>
|
||||
) : (
|
||||
<select
|
||||
id="agent-runtime-hint"
|
||||
@@ -366,7 +368,7 @@ export function NewAgentDialog({
|
||||
onChange={e => setSelectedRuntimeId(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{availableRuntimes.length > 0 ? "Select a plugin runtime…" : "No plugin runtimes available"}
|
||||
{availableRuntimes.length > 0 ? t("agents.runtimePlaceholder", "Select a plugin runtime…") : t("agents.runtimeEmpty", "No plugin runtimes available")}
|
||||
</option>
|
||||
{availableRuntimes.map((runtime) => (
|
||||
<option key={`${runtime.pluginId}:${runtime.runtimeId}`} value={runtime.runtimeId}>
|
||||
@@ -388,14 +390,14 @@ export function NewAgentDialog({
|
||||
// above it because the dialog couldn't escape its container).
|
||||
return createPortal(
|
||||
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
|
||||
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
|
||||
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label={t("agents.dialogAriaLabel", "Create new agent")}>
|
||||
{/* Header */}
|
||||
<div className="agent-dialog-header">
|
||||
<span className="agent-dialog-header-title">New Agent</span>
|
||||
<span className="agent-dialog-header-title">{t("agents.dialogTitle", "New Agent")}</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("agents.closeAriaLabel", "Close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -407,7 +409,7 @@ export function NewAgentDialog({
|
||||
<div
|
||||
key={i}
|
||||
className={`agent-dialog-step${i === step ? " active" : i < step ? " completed" : ""}`}
|
||||
aria-label={`Step ${i + 1}`}
|
||||
aria-label={t("agents.stepAriaLabel", "Step {{step}}", { step: i + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -423,11 +425,11 @@ export function NewAgentDialog({
|
||||
className="btn agent-dialog-interview-btn"
|
||||
onClick={() => setIsInterviewOpen(true)}
|
||||
>
|
||||
AI Interview
|
||||
{t("agents.aiInterview", "AI Interview")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-tabs" role="tablist" aria-label="Agent setup mode">
|
||||
<div className="agent-dialog-tabs" role="tablist" aria-label={t("agents.setupModeAriaLabel", "Agent setup mode")}>
|
||||
<button
|
||||
id="agent-dialog-tab-presets"
|
||||
type="button"
|
||||
@@ -439,7 +441,7 @@ export function NewAgentDialog({
|
||||
onClick={() => setStepZeroTab("presets")}
|
||||
data-testid="agent-dialog-tab-presets"
|
||||
>
|
||||
Preset personas
|
||||
{t("agents.tabPresets", "Preset personas")}
|
||||
</button>
|
||||
<button
|
||||
id="agent-dialog-tab-custom"
|
||||
@@ -452,7 +454,7 @@ export function NewAgentDialog({
|
||||
onClick={() => setStepZeroTab("custom")}
|
||||
data-testid="agent-dialog-tab-custom"
|
||||
>
|
||||
Custom agent
|
||||
{t("agents.tabCustom", "Custom agent")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -465,7 +467,7 @@ export function NewAgentDialog({
|
||||
>
|
||||
<div className="agent-presets">
|
||||
<div className="agent-presets-header">
|
||||
Choose a preset persona to prefill role, identity, soul, and instructions
|
||||
{t("agents.presetsHeader", "Choose a preset persona to prefill role, identity, soul, and instructions")}
|
||||
</div>
|
||||
<div className="agent-presets-grid">
|
||||
{AGENT_PRESETS.map(preset => (
|
||||
@@ -498,42 +500,42 @@ export function NewAgentDialog({
|
||||
aria-labelledby="agent-dialog-tab-custom"
|
||||
>
|
||||
<div className="agent-dialog-section">
|
||||
<div className="agent-dialog-section-header">Identity</div>
|
||||
<div className="agent-dialog-section-header">{t("agents.sectionIdentity", "Identity")}</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-name">Name {!selectedPresetId && <span className="agent-dialog-required">*</span>}</label>
|
||||
<label htmlFor="agent-name">{t("agents.fieldName", "Name")} {!selectedPresetId && <span className="agent-dialog-required">*</span>}</label>
|
||||
<input
|
||||
id="agent-name"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. Frontend Reviewer"
|
||||
placeholder={t("agents.namePlaceholder", "e.g. Frontend Reviewer")}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field agent-dialog-field--title">
|
||||
<label htmlFor="agent-title">Title <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-title">{t("agents.fieldTitle", "Title")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<input
|
||||
id="agent-title"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. Senior Code Reviewer"
|
||||
placeholder={t("agents.titlePlaceholder", "e.g. Senior Code Reviewer")}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-icon">Icon <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-icon">{t("agents.fieldIcon", "Icon")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<input
|
||||
id="agent-icon"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. 🤖"
|
||||
placeholder={t("agents.iconPlaceholder", "e.g. 🤖")}
|
||||
value={icon}
|
||||
onChange={e => setIcon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label>Role</label>
|
||||
<label>{t("agents.fieldRole", "Role")}</label>
|
||||
<div className="agent-role-grid">
|
||||
{AGENT_ROLES.map(r => (
|
||||
<button
|
||||
@@ -550,9 +552,9 @@ export function NewAgentDialog({
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-dialog-section">
|
||||
<div className="agent-dialog-section-header">Configuration</div>
|
||||
<div className="agent-dialog-section-header">{t("agents.sectionConfiguration", "Configuration")}</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-reports-to">{t("agents.fieldReportsTo", "Reports To")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<select
|
||||
id="agent-reports-to"
|
||||
className="select"
|
||||
@@ -560,7 +562,7 @@ export function NewAgentDialog({
|
||||
onChange={e => setReportsTo(e.target.value)}
|
||||
disabled={managersLoading}
|
||||
>
|
||||
<option value="">No manager</option>
|
||||
<option value="">{t("agents.noManager", "No manager")}</option>
|
||||
{availableManagers.map((manager) => (
|
||||
<option key={manager.id} value={manager.id}>
|
||||
{manager.name} ({manager.id})
|
||||
@@ -569,66 +571,66 @@ export function NewAgentDialog({
|
||||
</select>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-soul">Soul <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-soul">{t("agents.fieldSoul", "Soul")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<textarea
|
||||
id="agent-soul"
|
||||
className="input"
|
||||
rows={2}
|
||||
placeholder="Describe the agent's personality and communication style..."
|
||||
placeholder={t("agents.soulPlaceholder", "Describe the agent's personality and communication style...")}
|
||||
value={soul}
|
||||
onChange={e => setSoul(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-memory">Agent Memory <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-memory">{t("agents.fieldMemory", "Agent Memory")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<textarea
|
||||
id="agent-memory"
|
||||
className="input"
|
||||
rows={2}
|
||||
placeholder="Private to this agent — durable preferences, operating habits, and context it should carry across tasks..."
|
||||
placeholder={t("agents.memoryPlaceholder", "Private to this agent — durable preferences, operating habits, and context it should carry across tasks...")}
|
||||
value={memory}
|
||||
onChange={e => setMemory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-instructions-path">{t("agents.fieldInstructionsPath", "Instructions Path")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<input
|
||||
id="agent-instructions-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/reviewer.md"
|
||||
placeholder={t("agents.instructionsPathPlaceholder", "e.g. .fusion/agents/reviewer.md")}
|
||||
value={instructionsPath}
|
||||
onChange={e => setInstructionsPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-heartbeat-procedure-path">Heartbeat Procedure Path <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-heartbeat-procedure-path">{t("agents.fieldHeartbeatPath", "Heartbeat Procedure Path")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<input
|
||||
id="agent-heartbeat-procedure-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
|
||||
placeholder={t("agents.heartbeatPathPlaceholder", "e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md")}
|
||||
value={heartbeatProcedurePath}
|
||||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||||
/>
|
||||
<p className="agent-dialog-optional agent-dialog-field-hint">
|
||||
Path to the agent's heartbeat procedure path, typically .fusion/agents/ceo-agent2736/HEARTBEAT.md. Legacy id-only default paths still work.
|
||||
{t("agents.heartbeatPathHint", "Path to the agent's heartbeat procedure path, typically .fusion/agents/ceo-agent2736/HEARTBEAT.md. Legacy id-only default paths still work.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<label htmlFor="agent-instructions-text">{t("agents.fieldInstructionsText", "Inline Instructions")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||||
<textarea
|
||||
id="agent-instructions-text"
|
||||
className="input"
|
||||
rows={4}
|
||||
placeholder="Add custom behavior instructions..."
|
||||
placeholder={t("agents.instructionsTextPlaceholder", "Add custom behavior instructions...")}
|
||||
value={instructionsText}
|
||||
onChange={e => setInstructionsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-dialog-section">
|
||||
<div className="agent-dialog-section-header">Runtime</div>
|
||||
<div className="agent-dialog-section-header">{t("agents.sectionRuntime", "Runtime")}</div>
|
||||
{renderRuntimeSourceSection("agent-runtime-source-step-0")}
|
||||
</div>
|
||||
{/* AI-assisted generation */}
|
||||
@@ -639,10 +641,10 @@ export function NewAgentDialog({
|
||||
onClick={() => setIsGenerationModalOpen(true)}
|
||||
>
|
||||
<span>✨</span>
|
||||
Generate with AI
|
||||
{t("agents.generateWithAI", "Generate with AI")}
|
||||
</button>
|
||||
<p className="agent-dialog-ai-hint">
|
||||
Describe your agent's role and let AI generate a specification
|
||||
{t("agents.generateHint", "Describe your agent's role and let AI generate a specification")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -654,22 +656,22 @@ export function NewAgentDialog({
|
||||
<div>
|
||||
{renderRuntimeSourceSection("agent-runtime-source-step-1")}
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-thinking">Thinking Level</label>
|
||||
<label htmlFor="agent-thinking">{t("agents.fieldThinkingLevel", "Thinking Level")}</label>
|
||||
<select
|
||||
id="agent-thinking"
|
||||
className="select"
|
||||
value={runtimeConfig.thinkingLevel}
|
||||
onChange={e => setRuntimeConfig(c => ({ ...c, thinkingLevel: e.target.value as ThinkingLevel }))}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="off">{t("agents.thinkingOff", "Off")}</option>
|
||||
<option value="minimal">{t("agents.thinkingMinimal", "Minimal")}</option>
|
||||
<option value="low">{t("agents.thinkingLow", "Low")}</option>
|
||||
<option value="medium">{t("agents.thinkingMedium", "Medium")}</option>
|
||||
<option value="high">{t("agents.thinkingHigh", "High")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-max-turns">Max Turns</label>
|
||||
<label htmlFor="agent-max-turns">{t("agents.fieldMaxTurns", "Max Turns")}</label>
|
||||
<input
|
||||
id="agent-max-turns"
|
||||
type="number"
|
||||
@@ -683,13 +685,13 @@ export function NewAgentDialog({
|
||||
<div className="agent-dialog-field">
|
||||
<SkillMultiselect
|
||||
id="agent-skills"
|
||||
label="Skills"
|
||||
label={t("agents.fieldSkills", "Skills")}
|
||||
value={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<p className="agent-dialog-optional agent-dialog-skills-hint">
|
||||
Optional skills to assign to this agent
|
||||
{t("agents.skillsHint", "Optional skills to assign to this agent")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -698,38 +700,38 @@ export function NewAgentDialog({
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<p className="agent-dialog-info">
|
||||
Review your agent configuration before creating.
|
||||
{t("agents.reviewHint", "Review your agent configuration before creating.")}
|
||||
</p>
|
||||
<div className="agent-dialog-summary">
|
||||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-name">Name</label>
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-name">{t("agents.fieldName", "Name")}</label>
|
||||
<input
|
||||
id="agent-review-name"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. Frontend Reviewer"
|
||||
placeholder={t("agents.namePlaceholder", "e.g. Frontend Reviewer")}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable agent-dialog-summary-row--title">
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-title">Title</label>
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-title">{t("agents.fieldTitle", "Title")}</label>
|
||||
<input
|
||||
id="agent-review-title"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. Senior Code Reviewer"
|
||||
placeholder={t("agents.titlePlaceholder", "e.g. Senior Code Reviewer")}
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Role</span>
|
||||
<span className="agent-dialog-summary-row-label">{t("agents.fieldRole", "Role")}</span>
|
||||
<span>{selectedRole?.icon} {selectedRole?.label}</span>
|
||||
</div>
|
||||
{selectedReportsToId && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Reports To</span>
|
||||
<span className="agent-dialog-summary-row-label">{t("agents.fieldReportsTo", "Reports To")}</span>
|
||||
<span>
|
||||
{selectedManager
|
||||
? `${selectedManager.name} (${selectedManager.id})`
|
||||
@@ -738,57 +740,57 @@ export function NewAgentDialog({
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-soul">Soul</label>
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-soul">{t("agents.fieldSoul", "Soul")}</label>
|
||||
<textarea
|
||||
id="agent-review-soul"
|
||||
className="input"
|
||||
rows={2}
|
||||
placeholder="Describe the agent's personality and communication style..."
|
||||
placeholder={t("agents.soulPlaceholder", "Describe the agent's personality and communication style...")}
|
||||
value={soul}
|
||||
onChange={e => setSoul(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-heartbeat-procedure-path">Heartbeat Procedure Path</label>
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-heartbeat-procedure-path">{t("agents.fieldHeartbeatPath", "Heartbeat Procedure Path")}</label>
|
||||
<input
|
||||
id="agent-review-heartbeat-procedure-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
|
||||
placeholder={t("agents.heartbeatPathPlaceholder", "e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md")}
|
||||
value={heartbeatProcedurePath}
|
||||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-instructions-path">Instructions Path</label>
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-instructions-path">{t("agents.fieldInstructionsPath", "Instructions Path")}</label>
|
||||
<input
|
||||
id="agent-review-instructions-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/reviewer.md"
|
||||
placeholder={t("agents.instructionsPathPlaceholder", "e.g. .fusion/agents/reviewer.md")}
|
||||
value={instructionsPath}
|
||||
onChange={e => setInstructionsPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-instructions-text">Inline Instructions</label>
|
||||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-instructions-text">{t("agents.fieldInstructionsText", "Inline Instructions")}</label>
|
||||
<textarea
|
||||
id="agent-review-instructions-text"
|
||||
className="input"
|
||||
rows={4}
|
||||
placeholder="Add custom behavior instructions..."
|
||||
placeholder={t("agents.instructionsTextPlaceholder", "Add custom behavior instructions...")}
|
||||
value={instructionsText}
|
||||
onChange={e => setInstructionsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">{runtimeMode === "runtime" ? "Runtime" : "Model"}</span>
|
||||
<span className="agent-dialog-summary-row-label">{runtimeMode === "runtime" ? t("agents.runtime", "Runtime") : t("agents.model", "Model")}</span>
|
||||
<span>
|
||||
{runtimeMode === "runtime" ? (
|
||||
selectedRuntime ? (
|
||||
selectedRuntime.name
|
||||
) : (
|
||||
<em className="agent-dialog-summary-row-value--muted">Not selected</em>
|
||||
<em className="agent-dialog-summary-row-value--muted">{t("agents.notSelected", "Not selected")}</em>
|
||||
)
|
||||
) : selectedModel ? (
|
||||
<>
|
||||
@@ -803,22 +805,22 @@ export function NewAgentDialog({
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<em className="agent-dialog-summary-row-value--muted">default</em>
|
||||
<em className="agent-dialog-summary-row-value--muted">{t("agents.modelDefault", "default")}</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Thinking</span>
|
||||
<span className="agent-dialog-summary-row-label">{t("agents.fieldThinking", "Thinking")}</span>
|
||||
<span className="agent-dialog-summary-row-value--capitalize">{runtimeConfig.thinkingLevel}</span>
|
||||
</div>
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Max Turns</span>
|
||||
<span className="agent-dialog-summary-row-label">{t("agents.fieldMaxTurns", "Max Turns")}</span>
|
||||
<span>{runtimeConfig.maxTurns}</span>
|
||||
</div>
|
||||
{selectedSkills.length > 0 && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Skills</span>
|
||||
<span>{selectedSkills.length} skill{selectedSkills.length !== 1 ? "s" : ""} selected</span>
|
||||
<span className="agent-dialog-summary-row-label">{t("agents.fieldSkills", "Skills")}</span>
|
||||
<span>{t("agents.skillsSelected", "{{count}} skill selected", { count: selectedSkills.length, defaultValue_one: "{{count}} skill selected", defaultValue_other: "{{count}} skills selected" })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -833,11 +835,11 @@ export function NewAgentDialog({
|
||||
<div className="agent-dialog-footer">
|
||||
{step > 0 && (
|
||||
<button className="btn" onClick={() => setStep(s => s - 1)} disabled={isSubmitting}>
|
||||
Back
|
||||
{t("agents.back", "Back")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t("agents.cancel", "Cancel")}
|
||||
</button>
|
||||
{step < 2 ? (
|
||||
<button
|
||||
@@ -845,7 +847,7 @@ export function NewAgentDialog({
|
||||
onClick={() => setStep(s => s + 1)}
|
||||
disabled={step === 0 && !name.trim() && !selectedPresetId}
|
||||
>
|
||||
Next
|
||||
{t("agents.next", "Next")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -853,7 +855,7 @@ export function NewAgentDialog({
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create"}
|
||||
{isSubmitting ? t("agents.creating", "Creating...") : t("agents.create", "Create")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./NewTaskModal.css";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -26,6 +27,7 @@ interface NewTaskModalProps {
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast }: NewTaskModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
@@ -176,8 +178,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const handleClose = useCallback(async () => {
|
||||
if (hasDirtyState) {
|
||||
const shouldDiscard = await confirm({
|
||||
title: "Discard Changes",
|
||||
message: "You have unsaved changes. Discard them?",
|
||||
title: t("newTaskModal.discardChanges", "Discard Changes"),
|
||||
message: t("newTaskModal.unsavedChanges", "You have unsaved changes. Discard them?"),
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldDiscard) return;
|
||||
@@ -209,7 +211,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setGithubTrackingEnabled(false);
|
||||
setGithubRepoOverride("");
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages, confirm]);
|
||||
}, [hasDirtyState, onClose, pendingImages, confirm, t]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmedDesc = description.trim();
|
||||
@@ -276,7 +278,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
|
||||
addToast(t("newTaskModal.failedToUpload", "Failed to upload: {{files}}", { files: failures.join(", ") }), "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,14 +305,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setBranch("");
|
||||
setBaseBranch("");
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to create task", "error");
|
||||
addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -329,7 +331,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
<div className="new-task-quick-fields">
|
||||
{/* Dependencies field */}
|
||||
<div className="form-group">
|
||||
<label>Dependencies</label>
|
||||
<label>{t("newTaskModal.dependencies", "Dependencies")}</label>
|
||||
<div className="dep-trigger-wrap" ref={quickFieldsDepRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -338,20 +340,20 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
disabled={isSubmitting}
|
||||
data-testid="dep-trigger"
|
||||
>
|
||||
{dependencies.length > 0 ? `${dependencies.length} selected` : "Add dependencies"}
|
||||
{dependencies.length > 0 ? t("newTaskModal.selectedCount", "{{count}} selected", { count: dependencies.length }) : t("newTaskModal.addDependencies", "Add dependencies")}
|
||||
</button>
|
||||
{showDeps && (
|
||||
<div className="dep-dropdown">
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
placeholder={t("newTaskModal.searchTasks", "Search tasks…")}
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filteredDeps.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No available tasks</div>
|
||||
<div className="dep-dropdown-empty">{t("newTaskModal.noAvailableTasks", "No available tasks")}</div>
|
||||
) : (
|
||||
filteredDeps.map((t) => (
|
||||
<div
|
||||
@@ -395,7 +397,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
{/* Agent Assignment */}
|
||||
<div className="form-group">
|
||||
<label>Assign Agent</label>
|
||||
<label>{t("newTaskModal.assignAgent", "Assign Agent")}</label>
|
||||
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -411,12 +413,12 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
data-testid="new-task-agent-button"
|
||||
>
|
||||
<Bot size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Assign agent"}
|
||||
{selectedAgentLabel ? ` ${selectedAgentLabel}` : ` ${t("newTaskModal.assignAgentButton", "Assign agent")}`}
|
||||
</button>
|
||||
{showAgentPicker && (
|
||||
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="dep-dropdown-search-header">Select agent</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
|
||||
<div className="dep-dropdown-search-header">{t("newTaskModal.selectAgent", "Select agent")}</div>
|
||||
{agentsLoading && <div className="dep-dropdown-empty">{t("newTaskModal.loadingAgents", "Loading agents...")}</div>}
|
||||
{!agentsLoading && agents.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
@@ -434,7 +436,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
</div>
|
||||
))}
|
||||
{!agentsLoading && agents.length === 0 && (
|
||||
<div className="dep-dropdown-empty">No agents available</div>
|
||||
<div className="dep-dropdown-empty">{t("newTaskModal.noAgentsAvailable", "No agents available")}</div>
|
||||
)}
|
||||
{selectedAgentId && (
|
||||
<div
|
||||
@@ -445,7 +447,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setShowAgentPicker(false);
|
||||
}}
|
||||
>
|
||||
<span className="dep-dropdown-title">Clear selection</span>
|
||||
<span className="dep-dropdown-title">{t("newTaskModal.clearSelection", "Clear selection")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -465,8 +467,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
style={keyboardStyle}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>New Task</h3>
|
||||
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label="Close">
|
||||
<h3>{t("newTaskModal.title", "New Task")}</h3>
|
||||
<button className="modal-close" onClick={handleClose} disabled={isSubmitting} aria-label={t("actions.close", "Close")}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -535,19 +537,19 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
</div>
|
||||
|
||||
{hasInvalidBranchSelection && (
|
||||
<div className="form-error new-task-branch-error">Branch name is required for this branch strategy.</div>
|
||||
<div className="form-error new-task-branch-error">{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t("actions.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Task"}
|
||||
{isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Activity, Box, Play, RotateCw, Server, Settings, Shield, Square, Trash2 } from "lucide-react";
|
||||
import type { ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../api";
|
||||
import { getProjectCountForNode } from "../utils/nodeProjectAssignment";
|
||||
@@ -20,18 +21,20 @@ export interface NodeCardProps {
|
||||
managedDockerNode?: ManagedDockerNodeInfo;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; className: string }> = {
|
||||
online: { label: "Online", color: "var(--color-success)", className: "node-card__status--online" },
|
||||
offline: { label: "Offline", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
connecting: { label: "Connecting", color: "var(--color-warning)", className: "node-card__status--connecting" },
|
||||
error: { label: "Error", color: "var(--color-error)", className: "node-card__status--error" },
|
||||
creating: { label: "Creating", color: "var(--color-warning)", className: "node-card__status--creating" },
|
||||
recreating: { label: "Recreating", color: "var(--color-warning)", className: "node-card__status--recreating" },
|
||||
deleting: { label: "Deleting", color: "var(--color-error)", className: "node-card__status--deleting" },
|
||||
running: { label: "Running", color: "var(--color-success)", className: "node-card__status--online" },
|
||||
stopped: { label: "Stopped", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
exited: { label: "Exited", color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
};
|
||||
function getStatusConfig(t: ReturnType<typeof useTranslation>["t"]): Record<string, { label: string; color: string; className: string }> {
|
||||
return {
|
||||
online: { label: t("nodes.status.online", "Online"), color: "var(--color-success)", className: "node-card__status--online" },
|
||||
offline: { label: t("nodes.status.offline", "Offline"), color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
connecting: { label: t("nodes.status.connecting", "Connecting"), color: "var(--color-warning)", className: "node-card__status--connecting" },
|
||||
error: { label: t("nodes.status.error", "Error"), color: "var(--color-error)", className: "node-card__status--error" },
|
||||
creating: { label: t("nodes.status.creating", "Creating"), color: "var(--color-warning)", className: "node-card__status--creating" },
|
||||
recreating: { label: t("nodes.status.recreating", "Recreating"), color: "var(--color-warning)", className: "node-card__status--recreating" },
|
||||
deleting: { label: t("nodes.status.deleting", "Deleting"), color: "var(--color-error)", className: "node-card__status--deleting" },
|
||||
running: { label: t("nodes.status.running", "Running"), color: "var(--color-success)", className: "node-card__status--online" },
|
||||
stopped: { label: t("nodes.status.stopped", "Stopped"), color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
exited: { label: t("nodes.status.exited", "Exited"), color: "var(--color-error)", className: "node-card__status--offline" },
|
||||
};
|
||||
}
|
||||
|
||||
const AUTH_SYNC_COLORS: Record<string, string> = {
|
||||
match: "var(--color-success)",
|
||||
@@ -42,19 +45,20 @@ const AUTH_SYNC_COLORS: Record<string, string> = {
|
||||
function buildAuthTooltip(
|
||||
state: "match" | "differs" | "not-synced",
|
||||
providers?: Record<string, "match" | "differs">,
|
||||
t?: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (state === "match") return "Auth credentials match";
|
||||
if (state === "not-synced") return "Auth not synced";
|
||||
if (state === "match") return t ? t("nodes.auth.match", "Auth credentials match") : "Auth credentials match";
|
||||
if (state === "not-synced") return t ? t("nodes.auth.notSynced", "Auth not synced") : "Auth not synced";
|
||||
// state === "differs"
|
||||
if (providers && Object.keys(providers).length > 0) {
|
||||
const differing = Object.entries(providers)
|
||||
.filter(([, status]) => status === "differs")
|
||||
.map(([name]) => name);
|
||||
if (differing.length > 0) {
|
||||
return `Auth credentials differ: ${differing.join(", ")}`;
|
||||
return t ? t("nodes.auth.differProviders", "Auth credentials differ: {{providers}}", { providers: differing.join(", ") }) : `Auth credentials differ: ${differing.join(", ")}`;
|
||||
}
|
||||
}
|
||||
return "Auth credentials differ";
|
||||
return t ? t("nodes.auth.differ", "Auth credentials differ") : "Auth credentials differ";
|
||||
}
|
||||
|
||||
function truncateUrl(url: string, maxLength: number = 42): string {
|
||||
@@ -132,12 +136,14 @@ function NodeCardInner({
|
||||
authSyncProviders,
|
||||
managedDockerNode,
|
||||
}: NodeCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [removeArmed, setRemoveArmed] = useState(false);
|
||||
const statusConfig = STATUS_CONFIG[node.status] ?? STATUS_CONFIG.offline;
|
||||
const dockerStatusConfig = managedDockerNode ? (STATUS_CONFIG[managedDockerNode.status] ?? STATUS_CONFIG.error) : null;
|
||||
const statusConfigMap = getStatusConfig(t);
|
||||
const statusConfig = statusConfigMap[node.status] ?? statusConfigMap.offline;
|
||||
const dockerStatusConfig = managedDockerNode ? (statusConfigMap[managedDockerNode.status] ?? statusConfigMap.error) : null;
|
||||
const dockerHost = managedDockerNode?.hostConfig.type === "remote"
|
||||
? `Remote: ${managedDockerNode.hostConfig.host ?? "unknown"}`
|
||||
: "Local Docker";
|
||||
? t("nodes.dockerHost.remote", "Remote: {{host}}", { host: managedDockerNode.hostConfig.host ?? "unknown" })
|
||||
: t("nodes.dockerHost.local", "Local Docker");
|
||||
|
||||
const assignedProjectCount = useMemo(() => {
|
||||
return getProjectCountForNode(projects, node);
|
||||
@@ -192,9 +198,9 @@ function NodeCardInner({
|
||||
<div>
|
||||
<h3 className="node-card__name" title={node.name}>{node.name}</h3>
|
||||
<div className="node-card__meta-row">
|
||||
<span className="node-card__type-badge">{node.type === "local" ? "Local" : "Remote"}</span>
|
||||
<span className="node-card__type-badge">{node.type === "local" ? t("nodes.type.local", "Local") : t("nodes.type.remote", "Remote")}</span>
|
||||
{managedDockerNode && (
|
||||
<span className="node-card__docker-badge" title="Managed Docker node">
|
||||
<span className="node-card__docker-badge" title={t("nodes.dockerBadge", "Managed Docker node")}>
|
||||
<Box size={12} aria-hidden />
|
||||
Docker
|
||||
</span>
|
||||
@@ -220,8 +226,10 @@ function NodeCardInner({
|
||||
{node.type === "remote" && authSyncState && (
|
||||
<span
|
||||
className={`node-card__auth-indicator node-card__auth-indicator--${authSyncState}`}
|
||||
title={buildAuthTooltip(authSyncState, authSyncProviders)}
|
||||
aria-label={`Auth sync: ${authSyncState === "match" ? "credentials match" : authSyncState === "differs" ? "credentials differ" : "not synced"}`}
|
||||
title={buildAuthTooltip(authSyncState, authSyncProviders, t)}
|
||||
aria-label={t("nodes.authSync.label", "Auth sync: {{status}}", {
|
||||
status: authSyncState === "match" ? t("nodes.authSync.match", "credentials match") : authSyncState === "differs" ? t("nodes.authSync.differ", "credentials differ") : t("nodes.authSync.notSynced", "not synced")
|
||||
})}
|
||||
style={{ color: AUTH_SYNC_COLORS[authSyncState] }}
|
||||
>
|
||||
<Shield size={14} />
|
||||
@@ -249,11 +257,11 @@ function NodeCardInner({
|
||||
|
||||
<div className="node-card__metrics">
|
||||
<div className="node-card__metric">
|
||||
<span className="node-card__metric-label">Projects</span>
|
||||
<span className="node-card__metric-label">{t("nodes.metrics.projects", "Projects")}</span>
|
||||
<span className="node-card__metric-value">{assignedProjectCount}</span>
|
||||
</div>
|
||||
<div className="node-card__metric">
|
||||
<span className="node-card__metric-label">Concurrency</span>
|
||||
<span className="node-card__metric-label">{t("nodes.metrics.concurrency", "Concurrency")}</span>
|
||||
<span className="node-card__metric-value">{node.maxConcurrent}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,11 +291,11 @@ function NodeCardInner({
|
||||
type="button"
|
||||
onClick={handleHealthCheck}
|
||||
disabled={isLoading}
|
||||
aria-label="Run node health check"
|
||||
title="Health Check"
|
||||
aria-label={t("nodes.actions.health.ariaLabel", "Run node health check")}
|
||||
title={t("nodes.actions.health.title", "Health Check")}
|
||||
>
|
||||
<Activity size={14} />
|
||||
<span>Health</span>
|
||||
<span>{t("nodes.actions.health.label", "Health")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -295,11 +303,11 @@ function NodeCardInner({
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
aria-label="Edit node"
|
||||
title="Edit"
|
||||
aria-label={t("nodes.actions.edit.ariaLabel", "Edit node")}
|
||||
title={t("nodes.actions.edit.title", "Edit")}
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>Edit</span>
|
||||
<span>{t("nodes.actions.edit.label", "Edit")}</span>
|
||||
</button>
|
||||
|
||||
{managedDockerNode && (
|
||||
@@ -308,33 +316,33 @@ function NodeCardInner({
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Start node container"
|
||||
title="Available after FN-3113"
|
||||
aria-label={t("nodes.actions.start.ariaLabel", "Start node container")}
|
||||
title={t("nodes.actions.start.title", "Available after FN-3113")}
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>Start</span>
|
||||
<span>{t("nodes.actions.start.label", "Start")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Stop node container"
|
||||
title="Available after FN-3113"
|
||||
aria-label={t("nodes.actions.stop.ariaLabel", "Stop node container")}
|
||||
title={t("nodes.actions.stop.title", "Available after FN-3113")}
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
<span>{t("nodes.actions.stop.label", "Stop")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm node-card__action"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label="Restart node container"
|
||||
title="Available after FN-3113"
|
||||
aria-label={t("nodes.actions.restart.ariaLabel", "Restart node container")}
|
||||
title={t("nodes.actions.restart.title", "Available after FN-3113")}
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
<span>Restart</span>
|
||||
<span>{t("nodes.actions.restart.label", "Restart")}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -344,11 +352,11 @@ function NodeCardInner({
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading}
|
||||
aria-label={removeArmed ? "Confirm remove node" : "Remove node"}
|
||||
title={removeArmed ? "Confirm remove" : "Remove"}
|
||||
aria-label={removeArmed ? t("nodes.actions.remove.ariaLabelConfirm", "Confirm remove node") : t("nodes.actions.remove.ariaLabel", "Remove node")}
|
||||
title={removeArmed ? t("nodes.actions.remove.titleConfirm", "Confirm remove") : t("nodes.actions.remove.title", "Remove")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>{removeArmed ? "Confirm" : "Remove"}</span>
|
||||
<span>{removeArmed ? t("nodes.actions.remove.labelConfirm", "Confirm") : t("nodes.actions.remove.label", "Remove")}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
ChevronDown,
|
||||
@@ -135,6 +136,7 @@ export function NodeDetailModal({
|
||||
onUpdateDockerConfig,
|
||||
onFetchDockerConfigDiff,
|
||||
}: NodeDetailModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const isMountedRef = useRef(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
@@ -212,15 +214,15 @@ export function NodeDetailModal({
|
||||
|
||||
const dockerHost = useMemo(() => {
|
||||
if (!managedDockerNode) return "—";
|
||||
return managedDockerNode.hostConfig.type === "remote" ? managedDockerNode.hostConfig.host ?? "—" : "Local Docker";
|
||||
}, [managedDockerNode]);
|
||||
return managedDockerNode.hostConfig.type === "remote" ? managedDockerNode.hostConfig.host ?? "—" : t("nodes.localDocker", "Local Docker");
|
||||
}, [managedDockerNode, t]);
|
||||
|
||||
const dockerResourceSizing = useMemo(() => {
|
||||
if (!managedDockerNode?.resourceSizing?.cpuLimit && !managedDockerNode?.resourceSizing?.memoryLimit) {
|
||||
return "Default";
|
||||
return t("nodes.dockerResourceDefault", "Default");
|
||||
}
|
||||
return `${managedDockerNode.resourceSizing?.cpuLimit ?? "Default CPU"} / ${managedDockerNode.resourceSizing?.memoryLimit ?? "Default memory"}`;
|
||||
}, [managedDockerNode]);
|
||||
return `${managedDockerNode.resourceSizing?.cpuLimit ?? t("nodes.dockerDefaultCpu", "Default CPU")} / ${managedDockerNode.resourceSizing?.memoryLimit ?? t("nodes.dockerDefaultMemory", "Default memory")}`;
|
||||
}, [managedDockerNode, t]);
|
||||
|
||||
const handleHealthCheck = useCallback(async () => {
|
||||
if (!node) return;
|
||||
@@ -228,10 +230,10 @@ export function NodeDetailModal({
|
||||
try {
|
||||
await onHealthCheck(node.id);
|
||||
if (!isMountedRef.current) return;
|
||||
addToast(`Health check completed for ${node.name}`, "success");
|
||||
addToast(t("nodes.healthCheckSuccess", "Health check completed for {{name}}", { name: node.name }), "success");
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : "Health check failed";
|
||||
const message = error instanceof Error ? error.message : t("nodes.healthCheckFailed", "Health check failed");
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [addToast, node, onHealthCheck]);
|
||||
@@ -243,10 +245,10 @@ export function NodeDetailModal({
|
||||
try {
|
||||
await onPushSettings(node.id);
|
||||
if (!isMountedRef.current) return;
|
||||
addToast("Settings pushed successfully", "success");
|
||||
addToast(t("nodes.pushSettingsSuccess", "Settings pushed successfully"), "success");
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : "Push settings failed";
|
||||
const message = error instanceof Error ? error.message : t("nodes.pushSettingsFailed", "Push settings failed");
|
||||
setSyncError(message);
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
@@ -263,10 +265,10 @@ export function NodeDetailModal({
|
||||
try {
|
||||
await onPullSettings(node.id);
|
||||
if (!isMountedRef.current) return;
|
||||
addToast("Settings pulled successfully", "success");
|
||||
addToast(t("nodes.pullSettingsSuccess", "Settings pulled successfully"), "success");
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : "Pull settings failed";
|
||||
const message = error instanceof Error ? error.message : t("nodes.pullSettingsFailed", "Pull settings failed");
|
||||
setSyncError(message);
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
@@ -283,10 +285,10 @@ export function NodeDetailModal({
|
||||
try {
|
||||
await onSyncAuth(node.id);
|
||||
if (!isMountedRef.current) return;
|
||||
addToast("Auth credentials synced successfully", "success");
|
||||
addToast(t("nodes.syncAuthSuccess", "Auth credentials synced successfully"), "success");
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : "Auth sync failed";
|
||||
const message = error instanceof Error ? error.message : t("nodes.syncAuthFailed", "Auth sync failed");
|
||||
setSyncError(message);
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
@@ -308,7 +310,7 @@ export function NodeDetailModal({
|
||||
if (!isMountedRef.current) return;
|
||||
setLiveContainerStatus(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch container status";
|
||||
const message = error instanceof Error ? error.message : t("nodes.fetchContainerStatusFailed", "Failed to fetch container status");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
@@ -328,7 +330,7 @@ export function NodeDetailModal({
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
setLogs("");
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch container logs";
|
||||
const message = error instanceof Error ? error.message : t("nodes.fetchContainerLogsFailed", "Failed to fetch container logs");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
@@ -342,17 +344,17 @@ export function NodeDetailModal({
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
addToast("Name is required", "error");
|
||||
addToast(t("nodes.nameRequired", "Name is required"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "remote" && !url.trim()) {
|
||||
addToast("URL is required for remote nodes", "error");
|
||||
addToast(t("nodes.urlRequired", "URL is required for remote nodes"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(maxConcurrent) || maxConcurrent < 1) {
|
||||
addToast("Concurrency must be at least 1", "error");
|
||||
addToast(t("nodes.concurrencyMin", "Concurrency must be at least 1"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,10 +366,10 @@ export function NodeDetailModal({
|
||||
apiKey: node.type === "remote" ? apiKey || undefined : undefined,
|
||||
maxConcurrent,
|
||||
});
|
||||
addToast(`Updated ${trimmedName}`, "success");
|
||||
addToast(t("nodes.updateSuccess", "Updated {{name}}", { name: trimmedName }), "success");
|
||||
setEditMode(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to update node";
|
||||
const message = error instanceof Error ? error.message : t("nodes.updateFailed", "Failed to update node");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -403,10 +405,10 @@ export function NodeDetailModal({
|
||||
});
|
||||
if (!isMountedRef.current) return;
|
||||
setDockerConfigDraft(result);
|
||||
addToast("Docker config saved", "success");
|
||||
addToast(t("nodes.dockerConfigSaveSuccess", "Docker config saved"), "success");
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : "Failed to save Docker config";
|
||||
const message = error instanceof Error ? error.message : t("nodes.dockerConfigSaveFailed", "Failed to save Docker config");
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
if (isMountedRef.current) setDockerConfigSaving(false);
|
||||
@@ -434,28 +436,28 @@ export function NodeDetailModal({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Node details for ${node.name}`}
|
||||
aria-label={t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Node Details</h3>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close node detail modal">×</button>
|
||||
<h3>{t("nodes.modalTitle", "Node Details")}</h3>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t("nodes.closeModalAriaLabel", "Close node detail modal")}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body node-detail-modal__body">
|
||||
<section className="node-detail-modal__section">
|
||||
<div className="node-detail-modal__section-header">
|
||||
<h4>Overview</h4>
|
||||
<h4>{t("nodes.sectionOverview", "Overview")}</h4>
|
||||
{!editMode && (
|
||||
<button className="btn btn-sm" onClick={() => setEditMode(true)}>
|
||||
<Pencil size={14} />
|
||||
Edit
|
||||
{t("nodes.editButton", "Edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="node-detail-modal__grid">
|
||||
<label className="node-detail-modal__field">
|
||||
<span>Name</span>
|
||||
<span>{t("nodes.fieldName", "Name")}</span>
|
||||
{editMode ? (
|
||||
<input className="input" value={name} onChange={(event) => setName(event.target.value)} disabled={isSaving} />
|
||||
) : (
|
||||
@@ -464,17 +466,17 @@ export function NodeDetailModal({
|
||||
</label>
|
||||
|
||||
<div className="node-detail-modal__field">
|
||||
<span>Type</span>
|
||||
<strong>{node.type === "local" ? "Local" : "Remote"}</strong>
|
||||
<span>{t("nodes.fieldType", "Type")}</span>
|
||||
<strong>{node.type === "local" ? t("nodes.typeLocal", "Local") : t("nodes.typeRemote", "Remote")}</strong>
|
||||
</div>
|
||||
|
||||
<div className="node-detail-modal__field">
|
||||
<span>Status</span>
|
||||
<span>{t("nodes.fieldStatus", "Status")}</span>
|
||||
<strong>{node.status}</strong>
|
||||
</div>
|
||||
|
||||
<label className="node-detail-modal__field">
|
||||
<span>Max Concurrent</span>
|
||||
<span>{t("nodes.fieldMaxConcurrent", "Max Concurrent")}</span>
|
||||
{editMode ? (
|
||||
<input
|
||||
className="input"
|
||||
@@ -493,7 +495,7 @@ export function NodeDetailModal({
|
||||
{node.type === "remote" && (
|
||||
<>
|
||||
<label className="node-detail-modal__field node-detail-modal__field--full">
|
||||
<span>URL</span>
|
||||
<span>{t("nodes.fieldUrl", "URL")}</span>
|
||||
{editMode ? (
|
||||
<input className="input" value={url} onChange={(event) => setUrl(event.target.value)} disabled={isSaving} />
|
||||
) : (
|
||||
@@ -502,30 +504,30 @@ export function NodeDetailModal({
|
||||
</label>
|
||||
|
||||
<label className="node-detail-modal__field node-detail-modal__field--full">
|
||||
<span>API Key</span>
|
||||
<span>{t("nodes.fieldApiKey", "API Key")}</span>
|
||||
{editMode ? (
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder="Leave blank to keep unchanged"
|
||||
placeholder={t("nodes.apiKeyPlaceholder", "Leave blank to keep unchanged")}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : (
|
||||
<strong>{node.apiKey ? "••••••••" : "Not configured"}</strong>
|
||||
<strong>{node.apiKey ? "••••••••" : t("nodes.apiKeyNotConfigured", "Not configured")}</strong>
|
||||
)}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="node-detail-modal__field">
|
||||
<span>Created</span>
|
||||
<span>{t("nodes.fieldCreated", "Created")}</span>
|
||||
<strong>{formatTimestamp(node.createdAt)}</strong>
|
||||
</div>
|
||||
|
||||
<div className="node-detail-modal__field">
|
||||
<span>Updated</span>
|
||||
<span>{t("nodes.fieldUpdated", "Updated")}</span>
|
||||
<strong>{formatTimestamp(node.updatedAt)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,23 +536,23 @@ export function NodeDetailModal({
|
||||
<div className="node-detail-modal__edit-actions">
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={isSaving}>
|
||||
<Save size={14} />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
{isSaving ? t("nodes.saving", "Saving...") : t("nodes.saveButton", "Save")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={handleCancelEdit} disabled={isSaving}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
{t("nodes.cancelButton", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>{node.type === "local" ? "Projects" : "Assigned Projects"} ({assignedProjects.length})</h4>
|
||||
<h4>{node.type === "local" ? t("nodes.sectionProjects", "Projects") : t("nodes.sectionAssignedProjects", "Assigned Projects")} ({assignedProjects.length})</h4>
|
||||
{assignedProjects.length === 0 ? (
|
||||
<p className="node-detail-modal__empty">
|
||||
{node.type === "local"
|
||||
? "No projects are running on this node."
|
||||
: "No projects are assigned to this node."}
|
||||
? t("nodes.noProjectsRunning", "No projects are running on this node.")
|
||||
: t("nodes.noProjectsAssigned", "No projects are assigned to this node.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="node-detail-modal__project-list">
|
||||
@@ -565,10 +567,10 @@ export function NodeDetailModal({
|
||||
</section>
|
||||
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>Health</h4>
|
||||
<h4>{t("nodes.sectionHealth", "Health")}</h4>
|
||||
<div className="node-detail-modal__health-row">
|
||||
<span>Status: <strong>{node.status}</strong></span>
|
||||
<span>Last check: <strong>{formatTimestamp(node.updatedAt)}</strong></span>
|
||||
<span>{t("nodes.healthStatus", "Status:")} <strong>{node.status}</strong></span>
|
||||
<span>{t("nodes.healthLastCheck", "Last check:")} <strong>{formatTimestamp(node.updatedAt)}</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -580,29 +582,29 @@ export function NodeDetailModal({
|
||||
aria-expanded={dockerConfigExpanded}
|
||||
>
|
||||
<ChevronDown size={14} className={dockerConfigExpanded ? "node-detail-modal__docker-toggle-icon--expanded" : ""} />
|
||||
Docker Configuration
|
||||
{t("nodes.dockerConfiguration", "Docker Configuration")}
|
||||
</button>
|
||||
|
||||
{dockerConfigExpanded && (
|
||||
<div className="node-detail-modal__docker-config-content">
|
||||
<div className="node-detail-modal__grid">
|
||||
<label className="node-detail-modal__field node-detail-modal__field--full">
|
||||
<span>Image</span>
|
||||
<span>{t("nodes.dockerFieldImage", "Image")}</span>
|
||||
<input className="input" value={dockerConfigDraft.image} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, image: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Volume Mounts</summary>
|
||||
<summary>{t("nodes.dockerVolumeMounts", "Volume Mounts")}</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{dockerConfigDraft.volumeMounts.map((mount: DockerNodeConfigInfo["volumeMounts"][number], index: number) => (
|
||||
<div key={`${mount.hostPath}-${mount.containerPath}-${index}`} className="node-detail-modal__docker-row">
|
||||
<input className="input" value={mount.hostPath} placeholder="Host path" onChange={(event) => {
|
||||
<input className="input" value={mount.hostPath} placeholder={t("nodes.dockerHostPath", "Host path")} onChange={(event) => {
|
||||
const next = [...dockerConfigDraft.volumeMounts];
|
||||
next[index] = { ...next[index], hostPath: event.target.value };
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next });
|
||||
}} />
|
||||
<input className="input" value={mount.containerPath} placeholder="Container path" onChange={(event) => {
|
||||
<input className="input" value={mount.containerPath} placeholder={t("nodes.dockerContainerPath", "Container path")} onChange={(event) => {
|
||||
const next = [...dockerConfigDraft.volumeMounts];
|
||||
next[index] = { ...next[index], containerPath: event.target.value };
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: next });
|
||||
@@ -623,15 +625,15 @@ export function NodeDetailModal({
|
||||
<option value="volume">volume</option>
|
||||
<option value="bind">bind</option>
|
||||
</select>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i: number) => i !== index) })}>Remove</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i: number) => i !== index) })}>{t("nodes.removeButton", "Remove")}</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: [...dockerConfigDraft.volumeMounts, { hostPath: "", containerPath: "", mode: "rw", type: "volume" }] })}>Add Mount</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: [...dockerConfigDraft.volumeMounts, { hostPath: "", containerPath: "", mode: "rw", type: "volume" }] })}>{t("nodes.addMountButton", "Add Mount")}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Environment Variables</summary>
|
||||
<summary>{t("nodes.dockerEnvVars", "Environment Variables")}</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{Object.entries(dockerConfigDraft.environment as Record<string, string>).map(([key, value]: [string, string]) => {
|
||||
const masked = SENSITIVE_ENV_KEY_PATTERN.test(key) && !dockerEnvReveal[key];
|
||||
@@ -651,40 +653,40 @@ export function NodeDetailModal({
|
||||
const next = { ...dockerConfigDraft.environment };
|
||||
delete next[key];
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: next });
|
||||
}}>Remove</button>
|
||||
}}>{t("nodes.removeButton", "Remove")}</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const nextKey = `NEW_VAR_${Object.keys(dockerConfigDraft.environment).length + 1}`;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [nextKey]: "" } });
|
||||
}}>Add Variable</button>
|
||||
}}>{t("nodes.addVariableButton", "Add Variable")}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Resources</summary>
|
||||
<summary>{t("nodes.dockerResources", "Resources")}</summary>
|
||||
<div className="node-detail-modal__docker-stack">
|
||||
<input className="input" type="number" placeholder="Memory bytes (2 GB = 2147483648)" value={dockerConfigDraft.resources?.memoryBytes ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, memoryBytes: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder="CPU count" value={dockerConfigDraft.resources?.cpuCount ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, cpuCount: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder="PIDs limit" value={dockerConfigDraft.resources?.pidsLimit ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, pidsLimit: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder={t("nodes.dockerMemoryBytes", "Memory bytes (2 GB = 2147483648)")} value={dockerConfigDraft.resources?.memoryBytes ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, memoryBytes: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder={t("nodes.dockerCpuCount", "CPU count")} value={dockerConfigDraft.resources?.cpuCount ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, cpuCount: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
<input className="input" type="number" placeholder={t("nodes.dockerPidsLimit", "PIDs limit")} value={dockerConfigDraft.resources?.pidsLimit ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, resources: { ...dockerConfigDraft.resources, pidsLimit: event.target.value ? Number(event.target.value) : undefined } })} />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Host Config</summary>
|
||||
<summary>{t("nodes.dockerHostConfig", "Host Config")}</summary>
|
||||
<div className="node-detail-modal__docker-stack">
|
||||
<input className="input" placeholder="Context name" value={dockerConfigDraft.host?.contextName ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, contextName: event.target.value } })} />
|
||||
<input className="input" placeholder="Docker host URL" value={dockerConfigDraft.host?.dockerHost ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, dockerHost: event.target.value } })} />
|
||||
<input className="input" placeholder="TLS CA cert path" value={dockerConfigDraft.host?.tlsCaCert ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCaCert: event.target.value } })} />
|
||||
<input className="input" placeholder="TLS cert path" value={dockerConfigDraft.host?.tlsCert ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCert: event.target.value } })} />
|
||||
<input className="input" placeholder="TLS key path" value={dockerConfigDraft.host?.tlsKey ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsKey: event.target.value } })} />
|
||||
<label className="node-detail-modal__checkbox"><input type="checkbox" checked={dockerConfigDraft.host?.tlsVerify ?? true} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsVerify: event.target.checked } })} />TLS verify</label>
|
||||
<input className="input" placeholder={t("nodes.dockerContextName", "Context name")} value={dockerConfigDraft.host?.contextName ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, contextName: event.target.value } })} />
|
||||
<input className="input" placeholder={t("nodes.dockerHostUrl", "Docker host URL")} value={dockerConfigDraft.host?.dockerHost ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, dockerHost: event.target.value } })} />
|
||||
<input className="input" placeholder={t("nodes.dockerTlsCaCert", "TLS CA cert path")} value={dockerConfigDraft.host?.tlsCaCert ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCaCert: event.target.value } })} />
|
||||
<input className="input" placeholder={t("nodes.dockerTlsCert", "TLS cert path")} value={dockerConfigDraft.host?.tlsCert ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsCert: event.target.value } })} />
|
||||
<input className="input" placeholder={t("nodes.dockerTlsKey", "TLS key path")} value={dockerConfigDraft.host?.tlsKey ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsKey: event.target.value } })} />
|
||||
<label className="node-detail-modal__checkbox"><input type="checkbox" checked={dockerConfigDraft.host?.tlsVerify ?? true} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, host: { ...dockerConfigDraft.host, tlsVerify: event.target.checked } })} />{t("nodes.dockerTlsVerify", "TLS verify")}</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extra CLIs</summary>
|
||||
<summary>{t("nodes.dockerExtraClis", "Extra CLIs")}</summary>
|
||||
<div className="node-detail-modal__docker-list">
|
||||
{(dockerConfigDraft.extraClis ?? []).map((cli: string, index: number) => (
|
||||
<div key={`${cli}-${index}`} className="node-detail-modal__docker-row">
|
||||
@@ -693,29 +695,29 @@ export function NodeDetailModal({
|
||||
next[index] = event.target.value;
|
||||
setDockerConfigDraft({ ...dockerConfigDraft, extraClis: next });
|
||||
}} />
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i: number) => i !== index) })}>Remove</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i: number) => i !== index) })}>{t("nodes.removeButton", "Remove")}</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>Add CLI</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>{t("nodes.addCliButton", "Add CLI")}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Persistence</summary>
|
||||
<summary>{t("nodes.dockerPersistence", "Persistence")}</summary>
|
||||
<div className="node-detail-modal__docker-stack">
|
||||
<input className="input" placeholder="Volume name" value={dockerConfigDraft.persistence?.volumeName ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, volumeName: event.target.value } })} />
|
||||
<label className="node-detail-modal__checkbox"><input type="checkbox" checked={dockerConfigDraft.persistence?.retainOnDelete ?? false} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, retainOnDelete: event.target.checked } })} />Retain on delete</label>
|
||||
<input className="input" placeholder={t("nodes.dockerVolumeName", "Volume name")} value={dockerConfigDraft.persistence?.volumeName ?? ""} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, volumeName: event.target.value } })} />
|
||||
<label className="node-detail-modal__checkbox"><input type="checkbox" checked={dockerConfigDraft.persistence?.retainOnDelete ?? false} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, persistence: { ...dockerConfigDraft.persistence, retainOnDelete: event.target.checked } })} />{t("nodes.dockerRetainOnDelete", "Retain on delete")}</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="node-detail-modal__docker-meta">
|
||||
<span>Config v{dockerConfigDraft.configVersion} • Updated {formatRelativeTime(dockerConfigDraft.lastUpdated ?? node.updatedAt)}</span>
|
||||
{dockerConfigNeedsRecreate && <span className="node-detail-modal__docker-recreate">Needs Recreate</span>}
|
||||
{dockerConfigNeedsRecreate && <span className="node-detail-modal__docker-recreate">{t("nodes.dockerNeedsRecreate", "Needs Recreate")}</span>}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleDockerConfigSave()} disabled={dockerConfigSaving}>
|
||||
<Save size={14} />
|
||||
{dockerConfigSaving ? "Saving..." : "Save Docker Config"}
|
||||
{dockerConfigSaving ? t("nodes.saving", "Saving...") : t("nodes.saveDockerConfig", "Save Docker Config")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -724,62 +726,62 @@ export function NodeDetailModal({
|
||||
|
||||
{managedDockerNode && (
|
||||
<section className="node-detail-modal__section docker-management">
|
||||
<h4>Docker Management</h4>
|
||||
<h4>{t("nodes.sectionDockerManagement", "Docker Management")}</h4>
|
||||
|
||||
<div className="docker-management__status-card">
|
||||
<div className="docker-management__status-row">
|
||||
<span className={`docker-management__status-dot docker-management__status-dot--${dockerStatusTone}`} aria-hidden />
|
||||
<strong>{getDockerStatusLabel(effectiveDockerStatus)}</strong>
|
||||
<strong>{effectiveDockerStatus ? getDockerStatusLabel(effectiveDockerStatus) : t("nodes.dockerStatusUnknown", "Unknown")}</strong>
|
||||
{(effectiveDockerStatus === "creating" || effectiveDockerStatus === "recreating" || effectiveDockerStatus === "restarting") && (
|
||||
<RotateCcw size={14} className="spin" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="docker-management__status-meta">
|
||||
{effectiveDockerStatus === "running" && <span>Uptime: {formatDockerUptime(liveContainerStatus?.startedAt)}</span>}
|
||||
{effectiveDockerStatus === "running" && <span>{t("nodes.dockerUptime", "Uptime:")} {formatDockerUptime(liveContainerStatus?.startedAt)}</span>}
|
||||
{effectiveDockerStatus !== "running" && liveContainerStatus?.exitCode !== undefined && (
|
||||
<span>Exit code: {liveContainerStatus.exitCode}</span>
|
||||
<span>{t("nodes.dockerExitCode", "Exit code:")} {liveContainerStatus.exitCode}</span>
|
||||
)}
|
||||
{(liveContainerStatus?.error || managedDockerNode.errorMessage) && (
|
||||
<span>{liveContainerStatus?.error ?? managedDockerNode.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => void handleRefreshContainerStatus()} disabled={!onFetchContainerStatus || isRefreshingContainerStatus}>
|
||||
{isRefreshingContainerStatus ? "Refreshing..." : "Refresh Status"}
|
||||
{isRefreshingContainerStatus ? t("nodes.refreshing", "Refreshing...") : t("nodes.refreshStatus", "Refresh Status")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="node-detail-modal__grid docker-management__info-grid">
|
||||
<div className="node-detail-modal__field"><span>Image</span><strong><code>{managedDockerNode.imageName}:{managedDockerNode.imageTag}</code></strong></div>
|
||||
<div className="node-detail-modal__field"><span>Container ID</span><strong><code>{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}</code></strong></div>
|
||||
<div className="node-detail-modal__field"><span>Host</span><strong>{dockerHost}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>Persistent Storage</span><strong>{managedDockerNode.persistentStorage ? "Yes" : "No"}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>Port</span><strong>{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>Resource Sizing</span><strong>{dockerResourceSizing}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>{t("nodes.dockerFieldImage", "Image")}</span><strong><code>{managedDockerNode.imageName}:{managedDockerNode.imageTag}</code></strong></div>
|
||||
<div className="node-detail-modal__field"><span>{t("nodes.dockerContainerId", "Container ID")}</span><strong><code>{managedDockerNode.containerId ? managedDockerNode.containerId.slice(0, 12) : "—"}</code></strong></div>
|
||||
<div className="node-detail-modal__field"><span>{t("nodes.dockerHost", "Host")}</span><strong>{dockerHost}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>{t("nodes.dockerPersistentStorage", "Persistent Storage")}</span><strong>{managedDockerNode.persistentStorage ? t("nodes.yes", "Yes") : t("nodes.no", "No")}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>{t("nodes.dockerPort", "Port")}</span><strong>{parsePortFromReachableUrl(managedDockerNode.reachableUrl)}</strong></div>
|
||||
<div className="node-detail-modal__field"><span>{t("nodes.dockerResourceSizing", "Resource Sizing")}</span><strong>{dockerResourceSizing}</strong></div>
|
||||
</div>
|
||||
|
||||
<div className="docker-management__actions">
|
||||
<button className="btn btn-sm" disabled title="Available after FN-3113"><Play size={14} />Start</button>
|
||||
<button className="btn btn-sm" disabled title="Available after FN-3113"><Square size={14} />Stop</button>
|
||||
<button className="btn btn-sm" disabled title="Available after FN-3113"><RotateCcw size={14} />Restart</button>
|
||||
<button className="btn btn-sm" onClick={() => void handleFetchLogs()} disabled={!onFetchLogs}><FileText size={14} />View Logs</button>
|
||||
<button className="btn btn-sm" disabled title={t("nodes.availableSoon", "Available after FN-3113")}><Play size={14} />{t("nodes.startButton", "Start")}</button>
|
||||
<button className="btn btn-sm" disabled title={t("nodes.availableSoon", "Available after FN-3113")}><Square size={14} />{t("nodes.stopButton", "Stop")}</button>
|
||||
<button className="btn btn-sm" disabled title={t("nodes.availableSoon", "Available after FN-3113")}><RotateCcw size={14} />{t("nodes.restartButton", "Restart")}</button>
|
||||
<button className="btn btn-sm" onClick={() => void handleFetchLogs()} disabled={!onFetchLogs}><FileText size={14} />{t("nodes.viewLogsButton", "View Logs")}</button>
|
||||
</div>
|
||||
|
||||
{logsOpen && (
|
||||
<div className="docker-management__log-viewer">
|
||||
<div className="docker-management__log-viewer-header">
|
||||
<strong>Container Logs</strong>
|
||||
<button className="btn-icon" onClick={() => setLogsOpen(false)} aria-label="Close logs"><X size={14} /></button>
|
||||
<strong>{t("nodes.containerLogs", "Container Logs")}</strong>
|
||||
<button className="btn-icon" onClick={() => setLogsOpen(false)} aria-label={t("nodes.closeLogsAriaLabel", "Close logs")}><X size={14} /></button>
|
||||
</div>
|
||||
{logsLoading ? (
|
||||
<p>Fetching logs...</p>
|
||||
<p>{t("nodes.fetchingLogs", "Fetching logs...")}</p>
|
||||
) : (
|
||||
<pre>{logs.trim() || "No logs available"}</pre>
|
||||
<pre>{logs.trim() || t("nodes.noLogsAvailable", "No logs available")}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details>
|
||||
<summary>Environment Variables</summary>
|
||||
<summary>{t("nodes.dockerEnvVars", "Environment Variables")}</summary>
|
||||
<dl className="docker-management__env-list">
|
||||
{Object.entries(managedDockerNode.envVars).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
@@ -791,12 +793,12 @@ export function NodeDetailModal({
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Volume Mounts</summary>
|
||||
<summary>{t("nodes.dockerVolumeMounts", "Volume Mounts")}</summary>
|
||||
<ul className="docker-management__mounts-list">
|
||||
{managedDockerNode.volumeMounts.map((mount) => (
|
||||
<li key={`${mount.hostPath}:${mount.containerPath}`}>
|
||||
<span>{mount.hostPath} → {mount.containerPath}</span>
|
||||
{mount.readOnly && <span className="node-card__type-badge">Read-only</span>}
|
||||
{mount.readOnly && <span className="node-card__type-badge">{t("nodes.readOnly", "Read-only")}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -806,7 +808,7 @@ export function NodeDetailModal({
|
||||
|
||||
{node.type === "remote" && (
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>Settings Sync</h4>
|
||||
<h4>{t("nodes.sectionSettingsSync", "Settings Sync")}</h4>
|
||||
|
||||
{syncStatus && (
|
||||
<div className="node-detail-modal__sync-status">
|
||||
@@ -815,11 +817,11 @@ export function NodeDetailModal({
|
||||
aria-hidden
|
||||
/>
|
||||
<span>
|
||||
Last sync: <strong>{syncStatus.lastSyncAt ? formatRelativeTime(syncStatus.lastSyncAt) : "Never synced"}</strong>
|
||||
{t("nodes.syncLastSync", "Last sync:")} <strong>{syncStatus.lastSyncAt ? formatRelativeTime(syncStatus.lastSyncAt) : t("nodes.syncNeverSynced", "Never synced")}</strong>
|
||||
</span>
|
||||
{syncStatus.diffCount > 0 && (
|
||||
<span className="node-detail-modal__sync-diff">
|
||||
Differences: <strong>{syncStatus.diffCount}</strong>
|
||||
{t("nodes.syncDifferences", "Differences:")} <strong>{syncStatus.diffCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -828,24 +830,24 @@ export function NodeDetailModal({
|
||||
<div className="node-detail-modal__sync-actions">
|
||||
<button className="btn btn-sm" onClick={handlePushSettings} disabled={isPushing || !onPushSettings}>
|
||||
<Upload size={14} />
|
||||
{isPushing ? "Pushing..." : "Push Settings"}
|
||||
{isPushing ? t("nodes.pushing", "Pushing...") : t("nodes.pushSettings", "Push Settings")}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-sm" onClick={handlePullSettings} disabled={isPulling || !onPullSettings}>
|
||||
<Download size={14} />
|
||||
{isPulling ? "Pulling..." : "Pull Settings"}
|
||||
{isPulling ? t("nodes.pulling", "Pulling...") : t("nodes.pullSettings", "Pull Settings")}
|
||||
</button>
|
||||
|
||||
<button className="btn btn-sm" onClick={handleSyncAuth} disabled={isSyncingAuth || !onSyncAuth}>
|
||||
<Shield size={14} />
|
||||
{isSyncingAuth ? "Syncing..." : "Sync Auth"}
|
||||
{isSyncingAuth ? t("nodes.syncing", "Syncing...") : t("nodes.syncAuth", "Sync Auth")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syncError && (
|
||||
<div className="node-detail-modal__sync-error">
|
||||
<span>{syncError}</span>
|
||||
<button className="node-detail-modal__sync-error-dismiss" onClick={handleDismissSyncError} aria-label="Dismiss error">
|
||||
<button className="node-detail-modal__sync-error-dismiss" onClick={handleDismissSyncError} aria-label={t("nodes.dismissError", "Dismiss error")}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -855,7 +857,7 @@ export function NodeDetailModal({
|
||||
|
||||
{node.type === "remote" && (
|
||||
<section className="node-detail-modal__section">
|
||||
<h4>Sync History</h4>
|
||||
<h4>{t("nodes.sectionSyncHistory", "Sync History")}</h4>
|
||||
<SettingsSyncLog nodeId={node.id} entries={syncHistory} singleNode={true} />
|
||||
</section>
|
||||
)}
|
||||
@@ -864,9 +866,9 @@ export function NodeDetailModal({
|
||||
<div className="modal-actions node-detail-modal__actions">
|
||||
<button className="btn btn-sm" onClick={handleHealthCheck}>
|
||||
<Activity size={14} />
|
||||
Health Check
|
||||
{t("nodes.healthCheckButton", "Health Check")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={onClose}>Close</button>
|
||||
<button className="btn btn-sm" onClick={onClose}>{t("nodes.closeButton", "Close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -876,7 +878,7 @@ export function NodeDetailModal({
|
||||
onClose={() => setShowConflictModal(false)}
|
||||
onResolve={onResolveConflicts ?? (async () => {})}
|
||||
conflicts={conflicts}
|
||||
localNodeName="Local"
|
||||
localNodeName={t("nodes.localNodeName", "Local")}
|
||||
remoteNodeName={node.name}
|
||||
addToast={addToast}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Shows a green/red/yellow dot and optional text based on node state.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
|
||||
export interface NodeStatusIndicatorProps {
|
||||
@@ -15,21 +16,21 @@ export interface NodeStatusIndicatorProps {
|
||||
/**
|
||||
* Get display configuration for a node status
|
||||
*/
|
||||
function getStatusDisplay(status: NodeConfig["status"]): {
|
||||
function getStatusDisplay(status: NodeConfig["status"], t: (key: string, defaultValue: string) => string): {
|
||||
label: string;
|
||||
dotClass: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return { label: "Online", dotClass: "node-status-indicator__dot--online" };
|
||||
return { label: t("nodeStatus.online", "Online"), dotClass: "node-status-indicator__dot--online" };
|
||||
case "offline":
|
||||
return { label: "Offline", dotClass: "node-status-indicator__dot--offline" };
|
||||
return { label: t("nodeStatus.offline", "Offline"), dotClass: "node-status-indicator__dot--offline" };
|
||||
case "connecting":
|
||||
return { label: "Connecting", dotClass: "node-status-indicator__dot--connecting" };
|
||||
return { label: t("nodeStatus.connecting", "Connecting"), dotClass: "node-status-indicator__dot--connecting" };
|
||||
case "error":
|
||||
return { label: "Error", dotClass: "node-status-indicator__dot--error" };
|
||||
return { label: t("nodeStatus.error", "Error"), dotClass: "node-status-indicator__dot--error" };
|
||||
default:
|
||||
return { label: "Unknown", dotClass: "node-status-indicator__dot--offline" };
|
||||
return { label: t("nodeStatus.unknown", "Unknown"), dotClass: "node-status-indicator__dot--offline" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,17 +39,18 @@ function getStatusDisplay(status: NodeConfig["status"]): {
|
||||
* Shows connection status with a colored dot and optional details.
|
||||
*/
|
||||
export function NodeStatusIndicator({ node, showDetails = false }: NodeStatusIndicatorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Local or null node - show "Local" badge
|
||||
if (!node || node.type === "local") {
|
||||
return (
|
||||
<div className="node-status-indicator node-status-indicator--local">
|
||||
<span className="node-status-indicator__label">Local</span>
|
||||
<span className="node-status-indicator__label">{t("nodeStatus.local", "Local")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remote node - show status with dot and optional details
|
||||
const { label: statusLabel, dotClass } = getStatusDisplay(node.status);
|
||||
const { label: statusLabel, dotClass } = getStatusDisplay(node.status, (key, defaultValue) => t(key, defaultValue));
|
||||
const isConnecting = node.status === "connecting";
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Plus, Server, Wifi, WifiOff, Globe, RefreshCw, X } from "lucide-react";
|
||||
import "./NodesView.css";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
@@ -21,6 +22,7 @@ interface NodesViewProps {
|
||||
}
|
||||
|
||||
export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const {
|
||||
nodes,
|
||||
loading,
|
||||
@@ -83,14 +85,14 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
const handleCreateDockerNode = useCallback(async (input: ManagedDockerNodeInput) => {
|
||||
try {
|
||||
await createDockerNode(input);
|
||||
addToast(`Docker node "${input.name}" created`, "success");
|
||||
addToast(t("nodes.dockerNodeCreated", `Docker node "{{name}}" created`, { name: input.name }), "success");
|
||||
setDockerOnboardingOpen(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create Docker node";
|
||||
const message = err instanceof Error ? err.message : t("nodes.failedCreateDocker", "Failed to create Docker node");
|
||||
addToast(message, "error");
|
||||
throw err;
|
||||
}
|
||||
}, [addToast, createDockerNode]);
|
||||
}, [addToast, createDockerNode, t]);
|
||||
|
||||
const dockerNodeMap = useMemo(() => {
|
||||
const map = new Map<string, ManagedDockerNodeInfo>();
|
||||
@@ -106,32 +108,32 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
try {
|
||||
await Promise.all([refresh(), refreshDocker()]);
|
||||
} catch {
|
||||
addToast("Failed to refresh nodes", "error");
|
||||
addToast(t("nodes.failedRefresh", "Failed to refresh nodes"), "error");
|
||||
}
|
||||
}, [addToast, refresh, refreshDocker]);
|
||||
}, [addToast, refresh, refreshDocker, t]);
|
||||
|
||||
const handleHealthCheck = useCallback(async (id: string) => {
|
||||
try {
|
||||
await healthCheck(id);
|
||||
addToast("Node health check complete", "success");
|
||||
addToast(t("nodes.healthCheckComplete", "Node health check complete"), "success");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Health check failed";
|
||||
const message = err instanceof Error ? err.message : t("nodes.healthCheckFailed", "Health check failed");
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [addToast, healthCheck]);
|
||||
}, [addToast, healthCheck, t]);
|
||||
|
||||
const handleUnregister = useCallback(async (id: string) => {
|
||||
try {
|
||||
await unregister(id);
|
||||
addToast("Node removed", "success");
|
||||
addToast(t("nodes.removed", "Node removed"), "success");
|
||||
if (selectedNode?.id === id) {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to remove node";
|
||||
const message = err instanceof Error ? err.message : t("nodes.failedRemove", "Failed to remove node");
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [addToast, selectedNode?.id, unregister]);
|
||||
}, [addToast, selectedNode?.id, unregister, t]);
|
||||
|
||||
const handleUpdate = useCallback(async (id: string, updates: NodeUpdateInput) => {
|
||||
await update(id, updates);
|
||||
@@ -152,48 +154,48 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
<button
|
||||
className="btn-icon nodes-view-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close nodes view"
|
||||
aria-label={t("nodes.closeAriaLabel", "Close nodes view")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => void handleRefresh()} disabled={loading || dockerLoading}>
|
||||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||||
Refresh
|
||||
{t("nodes.refresh", "Refresh")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setAddModalOpen(true)}>
|
||||
<Plus size={14} />
|
||||
Add Node
|
||||
{t("nodes.addNode", "Add Node")}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)} title="Add a managed Docker node">
|
||||
<button className="btn btn-sm" onClick={() => setDockerOnboardingOpen(true)} title={t("nodes.addDockerNodeTitle", "Add a managed Docker node")}>
|
||||
<Box size={14} />
|
||||
Add Docker Node
|
||||
{t("nodes.addDockerNode", "Add Docker Node")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nodes-view-stats">
|
||||
<div className="nodes-view-stat" data-testid="nodes-stat-total">
|
||||
<span>Total</span>
|
||||
<span>{t("nodes.total", "Total")}</span>
|
||||
<strong>{stats.total}</strong>
|
||||
</div>
|
||||
<div className="nodes-view-stat nodes-view-stat--online" data-testid="nodes-stat-online">
|
||||
<span><Wifi size={14} /> Online</span>
|
||||
<span><Wifi size={14} /> {t("nodes.online", "Online")}</span>
|
||||
<strong>{stats.online}</strong>
|
||||
</div>
|
||||
<div className="nodes-view-stat nodes-view-stat--offline" data-testid="nodes-stat-offline">
|
||||
<span><WifiOff size={14} /> Offline</span>
|
||||
<span><WifiOff size={14} /> {t("nodes.offline", "Offline")}</span>
|
||||
<strong>{stats.offline}</strong>
|
||||
</div>
|
||||
<div className="nodes-view-stat" data-testid="nodes-stat-remote">
|
||||
<span><Globe size={14} /> Remote</span>
|
||||
<span><Globe size={14} /> {t("nodes.remote", "Remote")}</span>
|
||||
<strong>{stats.remote}</strong>
|
||||
</div>
|
||||
<div className="nodes-view-stat nodes-view-stat--synced" data-testid="nodes-stat-synced">
|
||||
<span><RefreshCw size={14} /> Synced</span>
|
||||
<span><RefreshCw size={14} /> {t("nodes.synced", "Synced")}</span>
|
||||
<strong>{stats.synced}</strong>
|
||||
</div>
|
||||
<div className="nodes-view-stat" data-testid="nodes-stat-docker">
|
||||
<span><Box size={14} /> Docker</span>
|
||||
<span><Box size={14} /> {t("nodes.docker", "Docker")}</span>
|
||||
<strong>{stats.docker}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,8 +204,8 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
|
||||
{/* Mesh Topology Visualization */}
|
||||
{!meshLoading && meshState.length > 0 && (
|
||||
<section className="nodes-view-topology" aria-label="Mesh Topology">
|
||||
<h3 className="nodes-view-section-title">Mesh Topology</h3>
|
||||
<section className="nodes-view-topology" aria-label={t("nodes.meshTopologyAriaLabel", "Mesh Topology")}>
|
||||
<h3 className="nodes-view-section-title">{t("nodes.meshTopology", "Mesh Topology")}</h3>
|
||||
<MeshTopology nodes={meshState} />
|
||||
</section>
|
||||
)}
|
||||
@@ -216,10 +218,10 @@ export function NodesView({ addToast, onClose }: NodesViewProps) {
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div className="nodes-view-empty">
|
||||
<p>No nodes are registered yet.</p>
|
||||
<p>{t("nodes.noRegistered", "No nodes are registered yet.")}</p>
|
||||
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
||||
<Plus size={14} />
|
||||
Add First Node
|
||||
{t("nodes.addFirstNode", "Add First Node")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { fetchAuthStatus } from "../api";
|
||||
import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
|
||||
@@ -35,6 +36,7 @@ export function OAuthReloginBanner({
|
||||
onReLogin: (providerId?: string) => void;
|
||||
pollIntervalMs?: number;
|
||||
}): JSX.Element | null {
|
||||
const { t } = useTranslation("app");
|
||||
const [expiredProviders, setExpiredProviders] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [dismissedProviderIds, setDismissedProviderIds] = useState<Set<string>>(() => loadDismissedProviderIds());
|
||||
|
||||
@@ -113,8 +115,8 @@ export function OAuthReloginBanner({
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<p className="oauth-relogin-banner__message">
|
||||
{isSingleProvider
|
||||
? `Re-login required: ${providerList}. Your ${providerList} session expired — sign in again to keep agents running.`
|
||||
: `Re-login required: ${providerList}`}
|
||||
? t("auth.reloginRequired", "Re-login required: {{provider}}. Your {{provider}} session expired — sign in again to keep agents running.", { provider: providerList })
|
||||
: t("auth.reloginRequiredMultiple", "Re-login required: {{providers}}", { providers: providerList })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="oauth-relogin-banner__actions">
|
||||
@@ -123,12 +125,12 @@ export function OAuthReloginBanner({
|
||||
className="btn btn-sm"
|
||||
onClick={() => onReLogin(isSingleProvider ? visibleExpiredProviders[0]?.id : undefined)}
|
||||
>
|
||||
Re-login
|
||||
{t("auth.relogin", "Re-login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon oauth-relogin-banner__dismiss"
|
||||
aria-label="Dismiss OAuth re-login banner"
|
||||
aria-label={t("actions.dismissOAuth", "Dismiss OAuth re-login banner")}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./OnboardingResumeCard.css";
|
||||
import { Play, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getOnboardingResumeStep, ONBOARDING_FLOW_STEPS } from "./model-onboarding-state";
|
||||
import { trackOnboardingEvent } from "./onboarding-events";
|
||||
|
||||
@@ -14,6 +15,7 @@ interface OnboardingResumeCardProps {
|
||||
* from where they left off.
|
||||
*/
|
||||
export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const resumeStep = getOnboardingResumeStep();
|
||||
|
||||
// Should not render if no resumable state exists
|
||||
@@ -24,23 +26,23 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
const completedCount = resumeStep.completedSteps.length;
|
||||
const totalSteps = ONBOARDING_FLOW_STEPS.length;
|
||||
const progressText = completedCount > 0
|
||||
? `${completedCount} of ${totalSteps} step${completedCount !== 1 ? "s" : ""} complete — You're on the `
|
||||
: "You're on the ";
|
||||
? t("onboarding.progressText", "{{completed}} of {{total}} step{{pluralS}} complete — You're on the ", { completed: completedCount, total: totalSteps, pluralS: completedCount !== 1 ? "s" : "" })
|
||||
: t("onboarding.onTheStep", "You're on the ");
|
||||
|
||||
return (
|
||||
<section
|
||||
className="onboarding-resume-card"
|
||||
role="region"
|
||||
aria-label="Resume onboarding"
|
||||
aria-label={t("onboarding.resumeOnboarding", "Resume onboarding")}
|
||||
>
|
||||
<div className="onboarding-resume-card__main">
|
||||
<div className="onboarding-resume-card__icon" aria-hidden="true">
|
||||
<Sparkles size={20} />
|
||||
</div>
|
||||
<div className="onboarding-resume-card__content">
|
||||
<h2 className="onboarding-resume-card__title">Continue Setup</h2>
|
||||
<h2 className="onboarding-resume-card__title">{t("onboarding.continueSetup", "Continue Setup")}</h2>
|
||||
<p className="onboarding-resume-card__description">
|
||||
{progressText}<strong>{resumeStep.label}</strong> step. Continue where you left off to complete your dashboard setup.
|
||||
{progressText}<strong>{resumeStep.label}</strong> {t("onboarding.stepContinue", "step. Continue where you left off to complete your dashboard setup.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +58,7 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
}}
|
||||
>
|
||||
<Play size={14} aria-hidden="true" />
|
||||
<span>Continue onboarding</span>
|
||||
<span>{t("onboarding.continueOnboarding", "Continue onboarding")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchOpenClawStatus,
|
||||
fetchPluginSettings,
|
||||
@@ -77,6 +78,7 @@ function settingsFromRecord(raw: Record<string, unknown>): OpenClawSettings {
|
||||
}
|
||||
|
||||
export function OpenClawRuntimeCard() {
|
||||
const { t } = useTranslation("app");
|
||||
const [settings, setSettings] = useState<OpenClawSettings>(DEFAULT_SETTINGS);
|
||||
const [status, setStatus] = useState<OpenClawProviderStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null);
|
||||
@@ -142,33 +144,33 @@ export function OpenClawRuntimeCard() {
|
||||
if (!mountedRef.current) return;
|
||||
setBusy(null);
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Test failed — see status above." });
|
||||
setToast({ kind: "err", message: t("openclaw.testFailed", "Test failed — see status above.") });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `✓ openclaw detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
|
||||
message: t("openclaw.detected", `✓ openclaw detected{{version}}{{path}}.`, { version: next.binary.version ? ` (${next.binary.version})` : "", path: next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : "" }),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ ${next.binary.reason ?? "openclaw not found"}`,
|
||||
message: t("openclaw.notFound", `✗ ${next.binary.reason ?? "openclaw not found"}`),
|
||||
});
|
||||
}
|
||||
}, [probe]);
|
||||
}, [probe, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setBusy("saving");
|
||||
setToast(null);
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) setToast({ kind: "ok", message: "Settings saved." });
|
||||
if (mountedRef.current) setToast({ kind: "ok", message: t("openclaw.saved", "Settings saved.") });
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setToast({ kind: "err", message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload]);
|
||||
}, [buildPayload, t]);
|
||||
|
||||
const handleSaveAndTest = useCallback(async () => {
|
||||
setBusy("save-test");
|
||||
@@ -178,16 +180,16 @@ export function OpenClawRuntimeCard() {
|
||||
const next = await probe();
|
||||
if (!mountedRef.current) return;
|
||||
if (!next) {
|
||||
setToast({ kind: "err", message: "Saved, but probe failed." });
|
||||
setToast({ kind: "err", message: t("openclaw.savedProbeFailed", "Saved, but probe failed.") });
|
||||
} else if (next.binary.available) {
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `Saved · ✓ openclaw detected${next.binary.version ? ` (${next.binary.version})` : ""}${next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : ""}.`,
|
||||
message: t("openclaw.savedDetected", `Saved · ✓ openclaw detected{{version}}{{path}}.`, { version: next.binary.version ? ` (${next.binary.version})` : "", path: next.binary.binaryPath ? ` at ${next.binary.binaryPath}` : "" }),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ ${next.binary.reason ?? "openclaw not found"}`,
|
||||
message: t("openclaw.savedNotFound", `Saved · ✗ ${next.binary.reason ?? "openclaw not found"}`),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -196,17 +198,17 @@ export function OpenClawRuntimeCard() {
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload, probe]);
|
||||
}, [buildPayload, probe, t]);
|
||||
|
||||
const binary = status?.binary;
|
||||
const statusKind =
|
||||
status === null ? "loading" : binary?.available ? "ok" : "err";
|
||||
const statusText =
|
||||
status === null
|
||||
? "Probing local openclaw binary…"
|
||||
? t("openclaw.probing", "Probing local openclaw binary…")
|
||||
: binary?.available
|
||||
? `✓ Detected${binary.version ? ` ${binary.version}` : ""}${binary.binaryPath ? ` · ${binary.binaryPath}` : ""}`
|
||||
: `✗ ${binary?.reason ?? "not detected on PATH"}`;
|
||||
? t("openclaw.statusDetected", `✓ Detected{{version}}{{path}}`, { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" })
|
||||
: t("openclaw.statusNotFound", `✗ ${binary?.reason ?? "not detected on PATH"}`);
|
||||
|
||||
return (
|
||||
<RuntimeCardShell
|
||||
@@ -249,43 +251,43 @@ export function OpenClawRuntimeCard() {
|
||||
}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-binaryPath">Binary path</label>
|
||||
<label htmlFor="openclaw-binaryPath">{t("openclaw.binaryPath", "Binary path")}</label>
|
||||
<input
|
||||
id="openclaw-binaryPath"
|
||||
type="text"
|
||||
placeholder="openclaw (defaults to PATH)"
|
||||
placeholder={t("openclaw.binaryPathPlaceholder", "openclaw (defaults to PATH)")}
|
||||
value={settings.binaryPath}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, binaryPath: e.target.value }))}
|
||||
/>
|
||||
<small>Leave blank to resolve <code>openclaw</code> from your PATH.</small>
|
||||
<small>{t("openclaw.binaryPathHint", "Leave blank to resolve openclaw from your PATH.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-agentId">Agent ID</label>
|
||||
<label htmlFor="openclaw-agentId">{t("openclaw.agentId", "Agent ID")}</label>
|
||||
<input
|
||||
id="openclaw-agentId"
|
||||
type="text"
|
||||
placeholder="main"
|
||||
placeholder={t("openclaw.agentIdPlaceholder", "main")}
|
||||
value={settings.agentId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, agentId: e.target.value }))}
|
||||
/>
|
||||
<small>OpenClaw agent definition to run (default: <code>main</code>).</small>
|
||||
<small>{t("openclaw.agentIdHint", "OpenClaw agent definition to run (default: main).")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-model">Model override</label>
|
||||
<label htmlFor="openclaw-model">{t("openclaw.model", "Model override")}</label>
|
||||
<input
|
||||
id="openclaw-model"
|
||||
type="text"
|
||||
placeholder="e.g. anthropic/claude-haiku-4-5, minimax/MiniMax-M3"
|
||||
placeholder={t("openclaw.modelPlaceholder", "e.g. anthropic/claude-haiku-4-5, minimax/MiniMax-M3")}
|
||||
value={settings.model}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
|
||||
/>
|
||||
<small>Optional — overrides the OpenClaw default model.</small>
|
||||
<small>{t("openclaw.modelHint", "Optional — overrides the OpenClaw default model.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-thinking">Thinking level</label>
|
||||
<label htmlFor="openclaw-thinking">{t("openclaw.thinking", "Thinking level")}</label>
|
||||
<select
|
||||
id="openclaw-thinking"
|
||||
value={settings.thinking}
|
||||
@@ -299,7 +301,7 @@ export function OpenClawRuntimeCard() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Controls how much extended thinking the model uses.</small>
|
||||
<small>{t("openclaw.thinkingHint", "Controls how much extended thinking the model uses.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -313,13 +315,13 @@ export function OpenClawRuntimeCard() {
|
||||
checked={settings.useGateway}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, useGateway: e.target.checked }))}
|
||||
/>
|
||||
Route through OpenClaw gateway (otherwise embedded)
|
||||
{t("openclaw.gateway", "Route through OpenClaw gateway (otherwise embedded)")}
|
||||
</label>
|
||||
<small>When enabled, calls pass through the OpenClaw gateway service.</small>
|
||||
<small>{t("openclaw.gatewayHint", "When enabled, calls pass through the OpenClaw gateway service.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-cliTimeoutSec">OpenClaw timeout (sec)</label>
|
||||
<label htmlFor="openclaw-cliTimeoutSec">{t("openclaw.cliTimeout", "OpenClaw timeout (sec)")}</label>
|
||||
<input
|
||||
id="openclaw-cliTimeoutSec"
|
||||
type="number"
|
||||
@@ -333,11 +335,11 @@ export function OpenClawRuntimeCard() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>OpenClaw-side run timeout in seconds (0 = no timeout).</small>
|
||||
<small>{t("openclaw.cliTimeoutHint", "OpenClaw-side run timeout in seconds (0 = no timeout).")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="openclaw-cliTimeoutMs">CLI subprocess timeout (ms)</label>
|
||||
<label htmlFor="openclaw-cliTimeoutMs">{t("openclaw.subprocess", "CLI subprocess timeout (ms)")}</label>
|
||||
<input
|
||||
id="openclaw-cliTimeoutMs"
|
||||
type="number"
|
||||
@@ -353,8 +355,7 @@ export function OpenClawRuntimeCard() {
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
Fusion-side hard cap before the CLI subprocess is killed (default:{" "}
|
||||
{DEFAULT_SETTINGS.cliTimeoutMs / 1000}s).
|
||||
{t("openclaw.subprocessHint", "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).", { default: DEFAULT_SETTINGS.cliTimeoutMs / 1000 })}
|
||||
</small>
|
||||
</div>
|
||||
</RuntimeCardShell>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchPaperclipAgents,
|
||||
fetchPaperclipCliAgents,
|
||||
@@ -119,6 +120,7 @@ function settingsFromRecord(raw: Record<string, unknown>): PaperclipSettings {
|
||||
}
|
||||
|
||||
export function PaperclipRuntimeCard() {
|
||||
const { t } = useTranslation("app");
|
||||
const [settings, setSettings] = useState<PaperclipSettings>(DEFAULT_SETTINGS);
|
||||
const [status, setStatus] = useState<PaperclipProviderStatus | null>(null);
|
||||
const [cliDiscovery, setCliDiscovery] =
|
||||
@@ -328,26 +330,26 @@ export function PaperclipRuntimeCard() {
|
||||
if (settings.transport === "cli" && cliDiscovery && !cliDiscovery.ok) {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ CLI discovery failed: ${cliDiscovery.reason}`,
|
||||
message: t("paperclip.cliDiscoveryFailed", "✗ CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason }),
|
||||
});
|
||||
} else {
|
||||
setToast({ kind: "err", message: "Test failed — see status above." });
|
||||
setToast({ kind: "err", message: t("paperclip.testFailed", "Test failed — see status above.") });
|
||||
}
|
||||
} else if (next.connection.available) {
|
||||
const id = next.connection.identity;
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: id
|
||||
? `✓ Connected as ${id.agentName}${id.companyName ? ` at ${id.companyName}` : ""}.`
|
||||
: "✓ Connected.",
|
||||
? t("paperclip.connectedAsAgent", "✓ Connected as {{agentName}}{{companyInfo}}.", { agentName: id.agentName, companyInfo: id.companyName ? ` at ${id.companyName}` : "" })
|
||||
: t("paperclip.connected", "✓ Connected."),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ ${next.connection.reason ?? "Paperclip server unreachable"}`,
|
||||
message: t("paperclip.unreachable", "✗ {{reason}}", { reason: next.connection.reason ?? "Paperclip server unreachable" }),
|
||||
});
|
||||
}
|
||||
}, [probe, settings.transport, cliDiscovery]);
|
||||
}, [probe, settings.transport, cliDiscovery, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setBusy("saving");
|
||||
@@ -355,7 +357,7 @@ export function PaperclipRuntimeCard() {
|
||||
try {
|
||||
await updatePluginSettings(PLUGIN_ID, buildPayload());
|
||||
if (mountedRef.current) {
|
||||
setToast({ kind: "ok", message: "Settings saved." });
|
||||
setToast({ kind: "ok", message: t("paperclip.settingsSaved", "Settings saved.") });
|
||||
setApiKeyDirty(false);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -364,7 +366,7 @@ export function PaperclipRuntimeCard() {
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload]);
|
||||
}, [buildPayload, t]);
|
||||
|
||||
const handleSaveAndTest = useCallback(async () => {
|
||||
setBusy("save-test");
|
||||
@@ -378,23 +380,23 @@ export function PaperclipRuntimeCard() {
|
||||
if (settings.transport === "cli" && cliDiscovery && !cliDiscovery.ok) {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ CLI discovery failed: ${cliDiscovery.reason}`,
|
||||
message: t("paperclip.savedCliDiscoveryFailed", "Saved · ✗ CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason }),
|
||||
});
|
||||
} else {
|
||||
setToast({ kind: "err", message: "Saved, but probe failed." });
|
||||
setToast({ kind: "err", message: t("paperclip.savedProbeFailed", "Saved, but probe failed.") });
|
||||
}
|
||||
} else if (next.connection.available) {
|
||||
const id = next.connection.identity;
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: id
|
||||
? `Saved · ✓ Connected as ${id.agentName}${id.companyName ? ` at ${id.companyName}` : ""}.`
|
||||
: "Saved · ✓ Connected.",
|
||||
? t("paperclip.savedConnectedAsAgent", "Saved · ✓ Connected as {{agentName}}{{companyInfo}}.", { agentName: id.agentName, companyInfo: id.companyName ? ` at ${id.companyName}` : "" })
|
||||
: t("paperclip.savedConnected", "Saved · ✓ Connected."),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `Saved · ✗ ${next.connection.reason ?? "Paperclip server unreachable"}`,
|
||||
message: t("paperclip.savedUnreachable", "Saved · ✗ {{reason}}", { reason: next.connection.reason ?? "Paperclip server unreachable" }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -403,7 +405,7 @@ export function PaperclipRuntimeCard() {
|
||||
} finally {
|
||||
if (mountedRef.current) setBusy(null);
|
||||
}
|
||||
}, [buildPayload, probe, settings.transport, cliDiscovery]);
|
||||
}, [buildPayload, probe, settings.transport, cliDiscovery, t]);
|
||||
|
||||
const connected = status?.connection.available ?? null;
|
||||
const identity = status?.connection.identity;
|
||||
@@ -419,19 +421,19 @@ export function PaperclipRuntimeCard() {
|
||||
const statusText =
|
||||
status === null
|
||||
? settings.transport === "cli" && cliDiscovery && !cliOk
|
||||
? `✗ CLI discovery failed: ${cliDiscovery.reason}`
|
||||
: "Probing Paperclip server…"
|
||||
? t("paperclip.statusCliDiscoveryFailed", "✗ CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason })
|
||||
: t("paperclip.statusProbing", "Probing Paperclip server…")
|
||||
: connected
|
||||
? identity
|
||||
? `✓ Connected as ${identity.agentName}${identity.role ? ` (${identity.role})` : ""}${identity.companyName ? ` at ${identity.companyName}` : ""}`
|
||||
: "✓ Connected"
|
||||
: `✗ ${status.connection.reason ?? "Unreachable"}`;
|
||||
? t("paperclip.statusConnectedAs", "✓ Connected as {{agentName}}{{roleInfo}}{{companyInfo}}", { agentName: identity.agentName, roleInfo: identity.role ? ` (${identity.role})` : "", companyInfo: identity.companyName ? ` at ${identity.companyName}` : "" })
|
||||
: t("paperclip.statusConnected", "✓ Connected")
|
||||
: t("paperclip.statusUnreachable", "✗ {{reason}}", { reason: status.connection.reason ?? "Unreachable" });
|
||||
|
||||
const tabs = (
|
||||
<div
|
||||
className="runtime-card__tabs"
|
||||
role="tablist"
|
||||
aria-label="Paperclip connection mode"
|
||||
aria-label={t("paperclip.connectionModeAriaLabel", "Paperclip connection mode")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -440,7 +442,7 @@ export function PaperclipRuntimeCard() {
|
||||
className="runtime-card__tab"
|
||||
onClick={() => setSettings((s) => ({ ...s, transport: "api" }))}
|
||||
>
|
||||
API (URL + token)
|
||||
{t("paperclip.tabApi", "API (URL + token)")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -449,7 +451,7 @@ export function PaperclipRuntimeCard() {
|
||||
className="runtime-card__tab"
|
||||
onClick={() => setSettings((s) => ({ ...s, transport: "cli" }))}
|
||||
>
|
||||
Local CLI (auto-derive)
|
||||
{t("paperclip.tabCliAutoDerve", "Local CLI (auto-derive)")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -458,16 +460,13 @@ export function PaperclipRuntimeCard() {
|
||||
<RuntimeCardShell
|
||||
testId="paperclip-runtime-card"
|
||||
logo={<ProviderIcon provider="paperclip" size="lg" />}
|
||||
name="Paperclip"
|
||||
name={t("paperclip.name", "Paperclip")}
|
||||
learnMoreHref={PAPERCLIP_LEARN_MORE}
|
||||
statusKind={statusKind}
|
||||
statusText={statusText}
|
||||
description={
|
||||
<>
|
||||
Drive a Paperclip agent ("employee") in a Paperclip company. Each
|
||||
prompt dispatches a task-shaped request; governance, budgets, and
|
||||
approvals are enforced by Paperclip. Expect seconds-to-minutes
|
||||
latency per turn.
|
||||
{t("paperclip.description", "Drive a Paperclip agent (\"employee\") in a Paperclip company. Each prompt dispatches a task-shaped request; governance, budgets, and approvals are enforced by Paperclip. Expect seconds-to-minutes latency per turn.")}
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
@@ -479,17 +478,17 @@ export function PaperclipRuntimeCard() {
|
||||
belowForm={
|
||||
connected === false ? (
|
||||
<div className="onboarding-helper-text">
|
||||
<p>Make sure a Paperclip server is running. To install Paperclip:</p>
|
||||
<p>{t("paperclip.onboardingStep1", "Make sure a Paperclip server is running. To install Paperclip:")}</p>
|
||||
<pre>
|
||||
<code>npm install -g paperclipai</code>
|
||||
</pre>
|
||||
<p>
|
||||
<a href={PAPERCLIP_LEARN_MORE} target="_blank" rel="noreferrer">
|
||||
Paperclip docs
|
||||
{t("paperclip.docsLink", "Paperclip docs")}
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a href={PAPERCLIP_GITHUB} target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
{t("paperclip.githubLink", "GitHub")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -501,14 +500,14 @@ export function PaperclipRuntimeCard() {
|
||||
<div className="settings-muted" style={{ marginBottom: "var(--space-sm)" }}>
|
||||
{cliOk ? (
|
||||
<small>
|
||||
CLI config: <code>{(cliDiscovery as { configPath: string }).configPath}</code>{" "}
|
||||
· resolved <code>{cliDiscovery.apiUrl}</code>
|
||||
{t("paperclip.cliConfigLabel", "CLI config:")} <code>{(cliDiscovery as { configPath: string }).configPath}</code>{" "}
|
||||
· {t("paperclip.resolved", "resolved")} <code>{cliDiscovery.apiUrl}</code>
|
||||
{(cliDiscovery as { deploymentMode?: string }).deploymentMode
|
||||
? ` · ${(cliDiscovery as { deploymentMode?: string }).deploymentMode}`
|
||||
: ""}
|
||||
</small>
|
||||
) : (
|
||||
<small>CLI discovery failed: {cliDiscovery.reason}</small>
|
||||
<small>{t("paperclip.cliDiscoveryFailedLabel", "CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason })}</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -517,7 +516,7 @@ export function PaperclipRuntimeCard() {
|
||||
{settings.transport === "api" && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-apiUrl">API URL</label>
|
||||
<label htmlFor="paperclip-apiUrl">{t("paperclip.apiUrlLabel", "API URL")}</label>
|
||||
<input
|
||||
id="paperclip-apiUrl"
|
||||
type="text"
|
||||
@@ -525,22 +524,22 @@ export function PaperclipRuntimeCard() {
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, apiUrl: e.target.value }))}
|
||||
/>
|
||||
<small>Base URL of the Paperclip server.</small>
|
||||
<small>{t("paperclip.apiUrlHelp", "Base URL of the Paperclip server.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-apiKey">API key</label>
|
||||
<label htmlFor="paperclip-apiKey">{t("paperclip.apiKeyLabel", "API key")}</label>
|
||||
<input
|
||||
id="paperclip-apiKey"
|
||||
type="password"
|
||||
placeholder={apiKeyDirty ? "" : "•••••••• (leave blank to keep existing)"}
|
||||
placeholder={apiKeyDirty ? "" : t("paperclip.apiKeyPlaceholder", "•••••••• (leave blank to keep existing)")}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => {
|
||||
setSettings((s) => ({ ...s, apiKey: e.target.value }));
|
||||
setApiKeyDirty(true);
|
||||
}}
|
||||
/>
|
||||
<small>Agent API key. Local-trusted deployments may leave this blank.</small>
|
||||
<small>{t("paperclip.apiKeyHelp", "Agent API key. Local-trusted deployments may leave this blank.")}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -549,7 +548,7 @@ export function PaperclipRuntimeCard() {
|
||||
{settings.transport === "cli" && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-cliBinaryPath">paperclipai binary</label>
|
||||
<label htmlFor="paperclip-cliBinaryPath">{t("paperclip.cliBinaryLabel", "paperclipai binary")}</label>
|
||||
<input
|
||||
id="paperclip-cliBinaryPath"
|
||||
type="text"
|
||||
@@ -560,12 +559,11 @@ export function PaperclipRuntimeCard() {
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
Optional — informational; the adapter currently reads the instance
|
||||
config file directly.
|
||||
{t("paperclip.cliBinaryHelp", "Optional — informational; the adapter currently reads the instance config file directly.")}
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-cliConfigPath">Instance config path</label>
|
||||
<label htmlFor="paperclip-cliConfigPath">{t("paperclip.cliConfigPathLabel", "Instance config path")}</label>
|
||||
<input
|
||||
id="paperclip-cliConfigPath"
|
||||
type="text"
|
||||
@@ -576,17 +574,16 @@ export function PaperclipRuntimeCard() {
|
||||
}
|
||||
/>
|
||||
<small>
|
||||
Override the path to <code>config.json</code>. Leave blank for the
|
||||
default.
|
||||
{t("paperclip.cliConfigPathHelp", "Override the path to config.json. Leave blank for the default.")}
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-cli-apikey">API key (override, optional)</label>
|
||||
<label htmlFor="paperclip-cli-apikey">{t("paperclip.cliApiKeyLabel", "API key (override, optional)")}</label>
|
||||
<input
|
||||
id="paperclip-cli-apikey"
|
||||
type="password"
|
||||
placeholder={
|
||||
apiKeyDirty ? "" : "Optional — only required for non-local-trusted modes"
|
||||
apiKeyDirty ? "" : t("paperclip.cliApiKeyPlaceholder", "Optional — only required for non-local-trusted modes")
|
||||
}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => {
|
||||
@@ -594,7 +591,7 @@ export function PaperclipRuntimeCard() {
|
||||
setApiKeyDirty(true);
|
||||
}}
|
||||
/>
|
||||
<small>Local-trusted deployments do not require a key.</small>
|
||||
<small>{t("paperclip.cliApiKeyHelp", "Local-trusted deployments do not require a key.")}</small>
|
||||
{/* Mint button: show when CLI mode, connection attempted but unavailable, and agent selected */}
|
||||
{status !== null && connected === false && settings.agentId && (
|
||||
<button
|
||||
@@ -608,7 +605,7 @@ export function PaperclipRuntimeCard() {
|
||||
if (!agentId || !companyId) {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: "✗ Company ID is required to mint a Paperclip API key.",
|
||||
message: t("paperclip.companyIdRequired", "✗ Company ID is required to mint a Paperclip API key."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -628,18 +625,18 @@ export function PaperclipRuntimeCard() {
|
||||
setApiKeyDirty(true);
|
||||
setToast({
|
||||
kind: "ok",
|
||||
message: `✓ API key minted via paperclipai (key 'fusion-runtime' installed for agent ${agentId}). Click Save to persist.`,
|
||||
message: t("paperclip.apiKeyMinted", "✓ API key minted via paperclipai (key 'fusion-runtime' installed for agent {{agentId}}). Click Save to persist.", { agentId }),
|
||||
});
|
||||
void probe();
|
||||
} else {
|
||||
setToast({
|
||||
kind: "err",
|
||||
message: `✗ Mint failed: ${result.reason}. Run \`paperclipai onboard\` first if your CLI isn't authenticated.`,
|
||||
message: t("paperclip.mintFailed", "✗ Mint failed: {{reason}}. Run `paperclipai onboard` first if your CLI isn't authenticated.", { reason: result.reason }),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
✨ Mint API key via paperclipai
|
||||
{t("paperclip.mintButton", "✨ Mint API key via paperclipai")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -648,7 +645,7 @@ export function PaperclipRuntimeCard() {
|
||||
|
||||
{/* Company picker */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-companyId">Company</label>
|
||||
<label htmlFor="paperclip-companyId">{t("paperclip.companyLabel", "Company")}</label>
|
||||
<select
|
||||
id="paperclip-companyId"
|
||||
value={settings.companyId}
|
||||
@@ -657,7 +654,7 @@ export function PaperclipRuntimeCard() {
|
||||
>
|
||||
{companies.length === 0 ? (
|
||||
<option value="">
|
||||
{connected ? "No companies discovered" : "Connect to populate"}
|
||||
{connected ? t("paperclip.noCompaniesDiscovered", "No companies discovered") : t("paperclip.connectToPopulate", "Connect to populate")}
|
||||
</option>
|
||||
) : (
|
||||
companies.map((c) => (
|
||||
@@ -667,12 +664,12 @@ export function PaperclipRuntimeCard() {
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<small>Select a Paperclip company.</small>
|
||||
<small>{t("paperclip.companyHelp", "Select a Paperclip company.")}</small>
|
||||
</div>
|
||||
|
||||
{/* Agent picker */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-agentId">Agent</label>
|
||||
<label htmlFor="paperclip-agentId">{t("paperclip.agentLabel", "Agent")}</label>
|
||||
<select
|
||||
id="paperclip-agentId"
|
||||
value={settings.agentId}
|
||||
@@ -681,7 +678,7 @@ export function PaperclipRuntimeCard() {
|
||||
>
|
||||
{agents.length === 0 ? (
|
||||
<option value="">
|
||||
{settings.companyId ? "No agents discovered" : "Pick a company first"}
|
||||
{settings.companyId ? t("paperclip.noAgentsDiscovered", "No agents discovered") : t("paperclip.pickCompanyFirst", "Pick a company first")}
|
||||
</option>
|
||||
) : (
|
||||
agents.map((a) => (
|
||||
@@ -692,12 +689,12 @@ export function PaperclipRuntimeCard() {
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<small>Pick the Paperclip agent this Fusion runtime will proxy.</small>
|
||||
<small>{t("paperclip.agentHelp", "Pick the Paperclip agent this Fusion runtime will proxy.")}</small>
|
||||
</div>
|
||||
|
||||
{/* Conversation mode */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-mode">Conversation mode</label>
|
||||
<label htmlFor="paperclip-mode">{t("paperclip.modeLabel", "Conversation mode")}</label>
|
||||
<select
|
||||
id="paperclip-mode"
|
||||
value={settings.mode}
|
||||
@@ -707,7 +704,7 @@ export function PaperclipRuntimeCard() {
|
||||
>
|
||||
{MODE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(`paperclip.mode.${opt.value}`, opt.label)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -716,43 +713,43 @@ export function PaperclipRuntimeCard() {
|
||||
|
||||
{/* Optional scoping */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-projectId">Project ID (optional)</label>
|
||||
<label htmlFor="paperclip-projectId">{t("paperclip.projectIdLabel", "Project ID (optional)")}</label>
|
||||
<input
|
||||
id="paperclip-projectId"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
placeholder={t("paperclip.optionalPlaceholder", "Optional")}
|
||||
value={settings.projectId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, projectId: e.target.value }))}
|
||||
/>
|
||||
<small>Pin work to a specific Paperclip project.</small>
|
||||
<small>{t("paperclip.projectIdHelp", "Pin work to a specific Paperclip project.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-parentIssueId">Parent issue ID (optional)</label>
|
||||
<label htmlFor="paperclip-parentIssueId">{t("paperclip.parentIssueIdLabel", "Parent issue ID (optional)")}</label>
|
||||
<input
|
||||
id="paperclip-parentIssueId"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
placeholder={t("paperclip.optionalPlaceholder", "Optional")}
|
||||
value={settings.parentIssueId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, parentIssueId: e.target.value }))}
|
||||
/>
|
||||
<small>Scope work under an existing parent issue.</small>
|
||||
<small>{t("paperclip.parentIssueIdHelp", "Scope work under an existing parent issue.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-goalId">Goal ID (optional)</label>
|
||||
<label htmlFor="paperclip-goalId">{t("paperclip.goalIdLabel", "Goal ID (optional)")}</label>
|
||||
<input
|
||||
id="paperclip-goalId"
|
||||
type="text"
|
||||
placeholder="Optional"
|
||||
placeholder={t("paperclip.optionalPlaceholder", "Optional")}
|
||||
value={settings.goalId}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, goalId: e.target.value }))}
|
||||
/>
|
||||
<small>Associate work with a Paperclip goal.</small>
|
||||
<small>{t("paperclip.goalIdHelp", "Associate work with a Paperclip goal.")}</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="paperclip-runTimeoutMs">Run timeout (ms)</label>
|
||||
<label htmlFor="paperclip-runTimeoutMs">{t("paperclip.runTimeoutLabel", "Run timeout (ms)")}</label>
|
||||
<input
|
||||
id="paperclip-runTimeoutMs"
|
||||
type="number"
|
||||
@@ -766,7 +763,7 @@ export function PaperclipRuntimeCard() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<small>Local cap before Fusion gives up on a Paperclip run.</small>
|
||||
<small>{t("paperclip.runTimeoutHelp", "Local cap before Fusion gives up on a Paperclip run.")}</small>
|
||||
</div>
|
||||
</RuntimeCardShell>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user