diff --git a/src/App.tsx b/src/App.tsx index 1a2220fb..e0c406f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { Welcome } from "./components/Welcome"; import { Workspace } from "./components/Workspace"; +import { shouldShowOnboarding } from "./lib/onboarding"; import { loadLastWorkspace, recordRecent, deriveName } from "./lib/recents"; import { api } from "./lib/ipc"; import type { WorkspaceBootstrap } from "./types"; @@ -30,7 +31,7 @@ export default function App() { // Try to auto-open last workspace on boot. Silent fallback to the // welcome screen if the folder no longer exists or fails to open. useEffect(() => { - if (startsEmpty) return; + if (startsEmpty || shouldShowOnboarding()) return; const last = loadLastWorkspace(); if (!last) return; (async () => { diff --git a/src/components/ConversationList.tsx b/src/components/ConversationList.tsx index f56f7b5d..2b3dc917 100644 --- a/src/components/ConversationList.tsx +++ b/src/components/ConversationList.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { Icon } from "@iconify/react"; +import { useLanguage } from "../lib/i18n"; import type { ConversationSummary } from "../types"; type Props = { @@ -21,9 +22,17 @@ export function ConversationList({ onRename, onDelete, }: Props) { + const language = useLanguage(); + const copy = conversationCopy[language]; const [editingId, setEditingId] = useState(null); const editRef = useRef(null); + const displayTitle = (title: string) => { + const trimmed = title.trim(); + if (!trimmed) return copy.untitled; + return trimmed === "New conversation" ? copy.newConversation : trimmed; + }; + const commitRename = (id: string) => { const value = editRef.current?.textContent?.trim() ?? ""; setEditingId(null); @@ -37,12 +46,12 @@ export function ConversationList({
- Conversations + {copy.title} @@ -50,7 +59,7 @@ export function ConversationList({
{conversations.length === 0 && ( -
No conversations yet.
+
{copy.empty}
)} {conversations.map((conv) => { const isEditing = editingId === conv.id; @@ -66,7 +75,7 @@ export function ConversationList({ > {isStreaming ? ( - + ) : ( - {conv.title || "Untitled"} + {displayTitle(conv.title)}
); } +const conversationCopy = { + en: { + title: "Conversations", + newConversation: "New conversation", + empty: "No conversations yet.", + streaming: "Streaming", + untitled: "Untitled", + rename: "Rename", + delete: "Delete", + deleteConfirm: "Delete this conversation?", + }, + fr: { + title: "Conversations", + newConversation: "Nouvelle conversation", + empty: "Aucune conversation pour le moment.", + streaming: "Génération en cours", + untitled: "Sans titre", + rename: "Renommer", + delete: "Supprimer", + deleteConfirm: "Supprimer cette conversation ?", + }, +} as const; diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 453e534d..90005f37 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -9,6 +9,7 @@ import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { Icon } from "@iconify/react"; +import { useLanguage } from "../lib/i18n"; import { languageForPath } from "../lib/language"; import { fileIcon } from "../lib/fileIcon"; import { Markdown } from "./chat/Markdown"; @@ -72,6 +73,8 @@ export function EditorPane({ onSettingsActivate, onSettingsClose, }: Props) { + const language = useLanguage(); + const copy = editorCopy[language]; const editorRef = useRef(null); const searchDecorationsRef = useRef(null); @@ -332,7 +335,7 @@ export function EditorPane({ event.stopPropagation(); onClose(index); }} - title="Close tab" + title={copy.closeTab} > @@ -343,19 +346,19 @@ export function EditorPane({ className="tab" data-active={settingsActive ? "true" : "false"} onClick={onSettingsActivate} - title="Settings" + title={copy.settings} > - Settings + {copy.settings} @@ -370,8 +373,8 @@ export function EditorPane({ onClick={toggleMarkdownPreview} title={ activePreview - ? "Show raw markdown source" - : "Show rendered markdown preview" + ? copy.showSource + : copy.showPreview } > - {activePreview ? "Source" : "Preview"} + {activePreview ? copy.source : copy.preview} )}
@@ -403,9 +406,9 @@ export function EditorPane({ - Nothing open + {copy.nothingOpen} - Click a file in the sidebar to get started + {copy.clickFile}
) : isPreviewableImagePath(activeTab.relativePath) ? ( @@ -480,8 +483,8 @@ export function EditorPane({ ) : !activeTab.doc.editable ? (
-
This file can’t be edited here.
- {activeTab.doc.reason ?? "binary or too large"} +
{copy.cannotEdit}
+ {activeTab.doc.reason ?? copy.binaryOrTooLarge} {activeTab.doc.relativePath} ·{" "} {formatBytes(activeTab.doc.size)} @@ -629,3 +632,29 @@ function isMarkdownPath(relativePath: string): boolean { function isPlanMarkdownPath(relativePath: string): boolean { return /^\.sinew\/plans\/.+\.md$/i.test(relativePath); } +const editorCopy = { + en: { + settings: "Settings", + closeTab: "Close tab", + showSource: "Show raw markdown source", + showPreview: "Show rendered markdown preview", + source: "Source", + preview: "Preview", + nothingOpen: "Nothing open", + clickFile: "Click a file in the sidebar to get started", + cannotEdit: "This file can't be edited here.", + binaryOrTooLarge: "binary or too large", + }, + fr: { + settings: "Paramètres", + closeTab: "Fermer l'onglet", + showSource: "Afficher la source markdown", + showPreview: "Afficher l'aperçu markdown", + source: "Source", + preview: "Aperçu", + nothingOpen: "Aucun fichier ouvert", + clickFile: "Cliquez sur un fichier dans la barre latérale pour commencer", + cannotEdit: "Ce fichier ne peut pas être modifié ici.", + binaryOrTooLarge: "binaire ou trop volumineux", + }, +} as const; diff --git a/src/components/SearchPane.tsx b/src/components/SearchPane.tsx index 060ad841..858b0c88 100644 --- a/src/components/SearchPane.tsx +++ b/src/components/SearchPane.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { Icon } from "@iconify/react"; import { api } from "../lib/ipc"; +import { useLanguage } from "../lib/i18n"; import { fileIcon } from "../lib/fileIcon"; import type { EditorRevealTarget, @@ -21,6 +22,8 @@ type Props = { const SEARCH_DELAY_MS = 180; export function SearchPane({ workspacePath, refreshToken, onOpenFile }: Props) { + const language = useLanguage(); + const copy = searchPaneCopy[language]; const [query, setQuery] = useState(""); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); @@ -102,7 +105,7 @@ export function SearchPane({ workspacePath, refreshToken, onOpenFile }: Props) { setQuery(event.target.value)} onKeyDown={(event) => { @@ -116,7 +119,7 @@ export function SearchPane({ workspacePath, refreshToken, onOpenFile }: Props) {
))} {hasQuery && !loading && !error && files.length === 0 && ( -
No results
+
{copy.noResults}
)} @@ -192,3 +195,21 @@ function highlightMatch(match: WorkspaceSearchMatch): ReactNode { ); } +const searchPaneCopy = { + en: { + search: "Search", + clear: "Clear", + searching: "Searching...", + searchInFiles: "Search in files", + pathMatch: "Path match", + noResults: "No results", + }, + fr: { + search: "Rechercher", + clear: "Effacer", + searching: "Recherche...", + searchInFiles: "Rechercher dans les fichiers", + pathMatch: "Chemin correspondant", + noResults: "Aucun résultat", + }, +} as const; diff --git a/src/components/SettingsPane.tsx b/src/components/SettingsPane.tsx index 05cd135d..5a24a7a7 100644 --- a/src/components/SettingsPane.tsx +++ b/src/components/SettingsPane.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Editor, { type OnMount } from "@monaco-editor/react"; import { Icon } from "@iconify/react"; -import { Wrench } from "lucide-react"; +import { Languages, Wrench } from "lucide-react"; import { api } from "../lib/ipc"; +import { setLanguage, useLanguage, type AppLanguage } from "../lib/i18n"; +import { replayOnboarding } from "../lib/onboarding"; import { Markdown } from "./chat/Markdown"; import { SinewMark } from "./SinewMark"; import { @@ -56,14 +58,17 @@ const FALLBACK_TOOL_SETTINGS: ToolSettings = { const PROVIDERS_CHANGED_EVENT = "sinew:providers-changed"; const TOOL_SETTINGS_CHANGED_EVENT = "sinew:tool-settings-changed"; +type Section = "about" | "providers" | "tools" | "mcp" | "skills" | "subagents"; + type Props = { workspacePath: string; + initialSection?: Section; }; -type Section = "about" | "providers" | "tools" | "mcp" | "skills" | "subagents"; - -export function SettingsPane({ workspacePath }: Props) { - const [section, setSection] = useState
("about"); +export function SettingsPane({ workspacePath, initialSection = "about" }: Props) { + const language = useLanguage(); + const copy = settingsPaneCopy[language]; + const [section, setSection] = useState
(initialSection); const [settings, setSettings] = useState(EMPTY_SETTINGS); const [savedJson, setSavedJson] = useState(""); const [jsonText, setJsonText] = useState(""); @@ -112,6 +117,10 @@ export function SettingsPane({ workspacePath }: Props) { const [providersMessage, setProvidersMessage] = useState(null); const [configuredProviders, setConfiguredProviders] = useState([]); + useEffect(() => { + setSection(initialSection); + }, [initialSection]); + useEffect(() => { setToolSettings(null); setSavedToolSettingsJson(""); @@ -972,7 +981,7 @@ export function SettingsPane({ workspacePath }: Props) { return (
-
Providers to use your subscription.", + disableOpenAiSubscription: "Disable OpenAI subscription mode", + enableOpenAiSubscription: "Enable OpenAI subscription mode", + mainAgent: "Main Agent", + swarmAgents: "Swarm Agents", + noTools: "No tools", + promptInjected: "Prompt injected into Plan mode", + resetPlanPrompt: "Reset Plan mode prompt", + resetPrompt: "Reset prompt", + planPromptHelp: "This text is appended to the system prompt only when the conversation is in Plan mode.", + planInstructions: "Plan mode instructions...", + resetDescription: "Reset description", + disable: "Disable", + enable: "Enable", + description: "description", + }, + mcp: { + title: "MCP servers", + subtitle: "Add a server in the JSON config to extend the agent.", + checking: "Checking...", + saveProbe: "Save & probe", + servers: "Servers", + probing: "probing...", + rawConfig: "Raw config", + unsaved: "Unsaved", + synced: "Synced", + untitled: "Untitled", + noServers: "No servers yet - add one in the raw config.", + disabled: "disabled", + pending: "pending", + failed: "failed", + error: "error", + tools: "Tools", + tool: "tool", + noDescription: "No description provided.", + noTools: "Server returned no tools.", + probingServer: "Probing server...", + noProbe: "No probe data yet.", + disable: "Disable", + enable: "Enable", + }, + agents: { + title: "Sub-agents", + emptySubtitle: "Create focused agents the main agent can call as tools.", + available: "available to the main agent", + save: "Save", + saving: "Saving...", + agents: "Agents", + newAgent: "New agent", + untitled: "Untitled", + noAgents: "No sub-agents yet - click + to start.", + selectOrCreate: "Select or create an agent", + untitledAgent: "Untitled agent", + agentName: "Agent name", + clickConfirm: "Click again to confirm", + deleteAgent: "Delete agent", + confirmDelete: "Confirm delete", + deleteConfirmShort: "Delete?", + disable: "Disable", + enable: "Enable", + description: "Description seen by the main agent", + model: "Model", + thinking: "Thinking", + internalPrompt: "Internal prompt", + }, + skills: { + title: "Skills", + scanning: "Scanning...", + drop: "Drop SKILL.md files in .agents/skills or ~/.agents/skills.", + available: "available to the agent", + rescan: "Rescan", + save: "Save", + saving: "Saving...", + search: "Search skills", + searchCount: "Search {count} skills", + workspace: "workspace", + global: "global", + enabled: "enabled", + off: "off", + disable: "Disable", + enable: "Enable", + noMatch: "No skills match.", + noSkills: "No skills yet", + createFolder: "Create a folder under", + withSkill: "with a SKILL.md file.", + nothingPreview: "Nothing to preview", + selectSkill: "Select a skill", + revealFinder: "Reveal in Finder", + deleteSkill: "Delete skill", + confirmSkillDelete: "Confirm skill delete", + deleting: "Deleting...", + confirmDelete: "Confirm delete", + delete: "Delete", + }, + }, + fr: { + openRouter: { + description: "Utilisez n'importe quel modèle OpenRouter avec votre propre clé API.", + keySaved: "Clé enregistrée", + hideKey: "Masquer la clé", + showKey: "Afficher la clé", + removeKey: "Supprimer la clé API", + search: "Rechercher", + typeModel: "Tapez un nom de modèle...", + saveKeyFirst: "Enregistrez d'abord une clé valide", + searching: "Recherche...", + noMatchingModel: "Aucun modèle correspondant.", + added: "Ajouté", + adding: "Ajout...", + add: "Ajouter", + removeModel: "Retirer le modèle", + }, + tools: { + title: "Outils", + loading: "Chargement...", + enabled: "activés", + save: "Enregistrer", + saving: "Enregistrement...", + planPromptTitle: "Prompt du mode Plan", + default: "Par défaut", + custom: "Personnalisé", + imageGeneration: "Génération d'image", + imageProvider: "Fournisseur d'images", + webSearch: "Recherche web", + webSearchProvider: "Fournisseur de recherche web", + openAiSubscription: "Utiliser l'abonnement OpenAI", + openAiSubscriptionConnected: "Authentifie les requêtes d'image avec votre compte OpenAI connecté au lieu d'une clé API.", + openAiSubscriptionDisconnected: "Connectez OpenAI dans Paramètres -> Modèles pour utiliser votre abonnement.", + disableOpenAiSubscription: "Désactiver le mode abonnement OpenAI", + enableOpenAiSubscription: "Activer le mode abonnement OpenAI", + mainAgent: "Agent principal", + swarmAgents: "Agents parallèles", + noTools: "Aucun outil", + promptInjected: "Prompt injecté dans le mode Plan", + resetPlanPrompt: "Réinitialiser le prompt du mode Plan", + resetPrompt: "Réinitialiser le prompt", + planPromptHelp: "Ce texte est ajouté au prompt système uniquement quand la conversation est en mode Plan.", + planInstructions: "Instructions du mode Plan...", + resetDescription: "Réinitialiser la description", + disable: "Désactiver", + enable: "Activer", + description: "description", + }, + mcp: { + title: "Serveurs MCP", + subtitle: "Ajoutez un serveur dans la configuration JSON pour étendre l'agent.", + checking: "Vérification...", + saveProbe: "Enregistrer et tester", + servers: "Serveurs", + probing: "test...", + rawConfig: "Configuration brute", + unsaved: "Non enregistré", + synced: "Synchronisé", + untitled: "Sans titre", + noServers: "Aucun serveur pour le moment - ajoutez-en un dans la configuration brute.", + disabled: "désactivé", + pending: "en attente", + failed: "échec", + error: "erreur", + tools: "Outils", + tool: "outil", + noDescription: "Aucune description fournie.", + noTools: "Le serveur n'a retourné aucun outil.", + probingServer: "Test du serveur...", + noProbe: "Aucune donnée de test pour le moment.", + disable: "Désactiver", + enable: "Activer", + }, + agents: { + title: "Agents", + emptySubtitle: "Créez des agents spécialisés que l'agent principal peut appeler comme outils.", + available: "disponibles pour l'agent principal", + save: "Enregistrer", + saving: "Enregistrement...", + agents: "Agents", + newAgent: "Nouvel agent", + untitled: "Sans titre", + noAgents: "Aucun agent pour le moment - cliquez sur + pour commencer.", + selectOrCreate: "Sélectionnez ou créez un agent", + untitledAgent: "Agent sans titre", + agentName: "Nom de l'agent", + clickConfirm: "Cliquez encore pour confirmer", + deleteAgent: "Supprimer l'agent", + confirmDelete: "Confirmer la suppression", + deleteConfirmShort: "Supprimer ?", + disable: "Désactiver", + enable: "Activer", + description: "Description vue par l'agent principal", + model: "Modèle", + thinking: "Raisonnement", + internalPrompt: "Prompt interne", + }, + skills: { + title: "Compétences", + scanning: "Analyse...", + drop: "Déposez des fichiers SKILL.md dans .agents/skills ou ~/.agents/skills.", + available: "disponibles pour l'agent", + rescan: "Réanalyser", + save: "Enregistrer", + saving: "Enregistrement...", + search: "Rechercher des compétences", + searchCount: "Rechercher parmi {count} compétences", + workspace: "workspace", + global: "global", + enabled: "activée", + off: "désactivée", + disable: "Désactiver", + enable: "Activer", + noMatch: "Aucune compétence ne correspond.", + noSkills: "Aucune compétence", + createFolder: "Créez un dossier dans", + withSkill: "avec un fichier SKILL.md.", + nothingPreview: "Rien à prévisualiser", + selectSkill: "Sélectionnez une compétence", + revealFinder: "Afficher dans l'explorateur", + deleteSkill: "Supprimer la compétence", + confirmSkillDelete: "Confirmer la suppression de la compétence", + deleting: "Suppression...", + confirmDelete: "Confirmer la suppression", + delete: "Supprimer", + }, + }, +} as const; // ---- Providers section ------------------------------------------------- type ProvidersSectionProps = { @@ -1290,13 +1659,15 @@ function ProvidersSection({ onOpenRouterModelsChange, onOpenRouterChanged, }: ProvidersSectionProps) { + const language = useLanguage(); + const copy = providersCopy[language]; return ( <>
-

Providers

+

{copy.title}

- Connect model providers for Sinew. + {copy.subtitle}

@@ -1315,7 +1686,7 @@ function ProvidersSection({ disabled={loading || busy} > - {loading ? "Refreshing…" : "Refresh"} + {loading ? copy.refreshing : copy.refresh}
@@ -1324,7 +1695,7 @@ function ProvidersSection({ - Cancel + {copy.cancel} ) : connected ? ( ) : ( )}
@@ -1533,6 +1906,9 @@ function OpenRouterProviderCard({ onModelsChange, onChanged, }: OpenRouterProviderCardProps) { + const language = useLanguage(); + const copy = providerCardCopy[language]; + const openRouterCopy = settingsDetailCopy[language].openRouter; const [apiKey, setApiKey] = useState(""); const [revealed, setRevealed] = useState(false); const [validating, setValidating] = useState(false); @@ -1561,12 +1937,12 @@ function OpenRouterProviderCard({ const connecting = state === "connecting"; const error = validationError ?? (state === "error" ? displayStatus.error : null); const statusLabel = connecting - ? "Connecting" + ? copy.connecting : connected - ? "Connected" + ? copy.connected : state === "error" - ? "Needs attention" - : "Not connected"; + ? copy.needsAttention + : copy.notConnected; const statusTone = connecting ? "pending" : connected @@ -1705,7 +2081,7 @@ function OpenRouterProviderCard({ {statusLabel} -

Use any OpenRouter model with your own API key.

+

{openRouterCopy.description}

{error &&
{error}
} @@ -1717,7 +2093,7 @@ function OpenRouterProviderCard({ setApiKey(event.target.value)} autoComplete="off" spellCheck={false} @@ -1727,8 +2103,8 @@ function OpenRouterProviderCard({ type="button" className="settings-pane__icon-btn" onClick={() => setRevealed((value) => !value)} - title={revealed ? "Hide key" : "Show key"} - aria-label={revealed ? "Hide key" : "Show key"} + title={revealed ? openRouterCopy.hideKey : openRouterCopy.showKey} + aria-label={revealed ? openRouterCopy.hideKey : openRouterCopy.showKey} > @@ -1753,13 +2129,13 @@ function OpenRouterProviderCard({